@khorsheed/dsh-reader 0.3.0 → 0.3.2
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/CHANGELOG.md +29 -0
- package/README.en.md +26 -121
- package/README.i18n.yaml +3 -3
- package/README.md +26 -121
- package/cordis.patch.yml +7 -0
- package/lib/client.js +29105 -6692
- package/lib/index.js +3500 -321
- package/lib/typert.host.js +2145 -259
- package/lib/typert.remote-client.d.ts +97 -3
- package/lib/typert.remote-client.js +1590 -250
- package/lib/types/captions.d.ts +99 -0
- package/lib/types/captions.js +236 -0
- package/lib/types/client/MediaPlayer.d.ts +55 -0
- package/lib/types/client/MediaPlayer.js +106 -0
- package/lib/types/client/NotesView.d.ts +57 -0
- package/lib/types/client/NotesView.js +124 -0
- package/lib/types/client/ReaderPane.d.ts +20 -3
- package/lib/types/client/ReaderPane.js +860 -164
- package/lib/types/client/SpeakerLegend.d.ts +18 -0
- package/lib/types/client/SpeakerLegend.js +26 -0
- package/lib/types/client/canvas-sync.d.ts +84 -0
- package/lib/types/client/canvas-sync.js +59 -0
- package/lib/types/client/contract.d.ts +130 -3
- package/lib/types/client/extract-article.d.ts +38 -1
- package/lib/types/client/extract-article.js +561 -33
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/icons.js +1 -1
- package/lib/types/client/index.js +81 -2
- package/lib/types/client/locales.d.ts +279 -11
- package/lib/types/client/locales.js +278 -10
- package/lib/types/client/notes.d.ts +142 -0
- package/lib/types/client/notes.js +334 -0
- package/lib/types/client/parse-rss.js +40 -0
- package/lib/types/client/preset-visibility.d.ts +93 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/selectors.d.ts +5 -0
- package/lib/types/client/selectors.js +5 -0
- package/lib/types/client/session.d.ts +21 -1
- package/lib/types/client/session.js +12 -0
- package/lib/types/client/store.js +4 -1
- package/lib/types/client/translate.d.ts +26 -1
- package/lib/types/client/translate.js +29 -12
- package/lib/types/client/useLocalTranscript.d.ts +43 -0
- package/lib/types/client/useLocalTranscript.js +129 -0
- package/lib/types/client/useMedia.d.ts +68 -0
- package/lib/types/client/useMedia.js +299 -0
- package/lib/types/client/useMediaStates.d.ts +9 -0
- package/lib/types/client/useMediaStates.js +43 -0
- package/lib/types/client/useNotes.d.ts +110 -0
- package/lib/types/client/useNotes.js +423 -0
- package/lib/types/client/usePolish.d.ts +47 -0
- package/lib/types/client/usePolish.js +162 -0
- package/lib/types/document-fetch.d.ts +106 -0
- package/lib/types/document-fetch.js +100 -0
- package/lib/types/index.d.ts +15 -0
- package/lib/types/index.js +3 -0
- package/lib/types/local-transcribe.d.ts +171 -0
- package/lib/types/local-transcribe.js +473 -0
- package/lib/types/media-host.d.ts +91 -0
- package/lib/types/media-host.js +230 -0
- package/lib/types/media.d.ts +100 -0
- package/lib/types/media.js +207 -0
- package/lib/types/notes-types.d.ts +158 -0
- package/lib/types/notes-types.js +8 -0
- package/lib/types/notes.d.ts +213 -0
- package/lib/types/notes.js +718 -0
- package/lib/types/polish.d.ts +41 -0
- package/lib/types/polish.js +136 -0
- package/lib/types/remote.d.ts +118 -0
- package/lib/types/remote.js +151 -1
- package/lib/types/service.d.ts +81 -6
- package/lib/types/service.js +286 -65
- package/lib/types/skill.d.ts +20 -0
- package/lib/types/skill.js +78 -0
- package/lib/types/store.js +30 -8
- package/lib/types/tool.d.ts +23 -0
- package/lib/types/tool.js +40 -0
- package/lib/types/tools.d.ts +69 -0
- package/lib/types/tools.js +400 -0
- package/lib/types/types.d.ts +90 -2
- package/lib/types/types.js +66 -0
- package/lib/types/xiaoyuzhou.d.ts +43 -0
- package/lib/types/xiaoyuzhou.js +134 -0
- package/lib/types/ytdlp.d.ts +111 -0
- package/lib/types/ytdlp.js +269 -0
- package/package.json +33 -20
- package/lib/invariant.js +0 -38
- package/lib/types/invariant.d.ts +0 -29
- package/lib/types/invariant.js +0 -38
|
@@ -11,8 +11,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
11
11
|
* round trip.
|
|
12
12
|
* - **the detail view** — clicking a card opens it. This is the whole point:
|
|
13
13
|
* the body renders as DOM text, which is what makes passage quoting work
|
|
14
|
-
* (the QUOTE plugin's own selection menu sees it)
|
|
15
|
-
*
|
|
14
|
+
* (the QUOTE plugin's own selection menu sees it). The one non-text island:
|
|
15
|
+
* an article's content embeds survive as sandboxed iframes (opaque origin,
|
|
16
|
+
* no `allow-same-origin`) or controls-only media elements — neither can see
|
|
17
|
+
* this page, and neither carries quotable text.
|
|
16
18
|
* - **the add dialog** — an overlay in the members-tab idiom, because adding is
|
|
17
19
|
* a momentary act and the wall should stay where it was behind it. The host
|
|
18
20
|
* decides whether a pasted URL is a feed or an article from what comes back
|
|
@@ -30,14 +32,44 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
30
32
|
*/
|
|
31
33
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
32
34
|
import { IconChevronDownOutlineMedium, IconChevronLeftOutlineMedium, IconClockOutlineMedium, IconCopyOutlineMedium, IconGlobeOutlineMedium, IconPlusOutlineMedium, IconRefreshOutlineMedium, IconRightUpOutlineMedium, IconSettingsOutlineMedium, IconTrashOutlineMedium } from "./icons.js";
|
|
33
|
-
import {
|
|
35
|
+
import { ChipRow, PanelHeader, PanelRoot, UnderlineTabs, usePanelCompactContext } from '@khorsheed/dsh-client-ui-panel-kit/src/client/index.ts';
|
|
36
|
+
import { NoteSheet, NotesView } from "./NotesView.js";
|
|
37
|
+
import { MediaPlayer } from "./MediaPlayer.js";
|
|
38
|
+
import { useMedia } from "./useMedia.js";
|
|
39
|
+
import { useLocalTranscript } from "./useLocalTranscript.js";
|
|
40
|
+
import { useMediaStates } from "./useMediaStates.js";
|
|
41
|
+
import { SpeakerLegend } from "./SpeakerLegend.js";
|
|
42
|
+
import { usePolish } from "./usePolish.js";
|
|
43
|
+
import { blockElements } from "./notes.js";
|
|
44
|
+
import { readMediaMeta } from "../media.js";
|
|
45
|
+
import { formatClock } from "../captions.js";
|
|
46
|
+
import { MEDIA_QUERIES } from "./selectors.js";
|
|
47
|
+
import { useNotes } from "./useNotes.js";
|
|
48
|
+
import { kindQuery, linkEntryId, READER_SOURCE_KINDS, isCookieHandshake, } from "../types.js";
|
|
34
49
|
import { extractArticle } from "./extract-article.js";
|
|
35
50
|
import { parseFeed } from "./parse-rss.js";
|
|
36
|
-
import { absoluteDate, clockOf,
|
|
37
|
-
import { TARGET_CANDIDATES, buildArticle, createSession, detectSourceLanguage, detectTranslator, isTargetLanguage, primeMemory, restoreArticle, runTranslation, segmentAt, segmentsOf, setPairHover, setView, toggleSegment, translateTexts, translationHash, } from "./translate.js";
|
|
51
|
+
import { absoluteDate, clockOf, relativeWhen } from "./quote.js";
|
|
52
|
+
import { TARGET_CANDIDATES, applyRemembered, buildArticle, createSession, detectSourceLanguage, detectTranslator, isTargetLanguage, primeMemory, restoreArticle, runTranslation, segmentAt, segmentsOf, setPairHover, setView, toggleSegment, remembered, translateTexts, translationHash, } from "./translate.js";
|
|
38
53
|
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";
|
|
54
|
+
import { cachedTranslator, forgetTranslation, patchSession, readSession, rememberReadingPosition, rememberTranslation, rememberWallScroll, rememberTranslator, readWallCache, sourceStamp, writeWallCache, } from "./session.js";
|
|
40
55
|
import css from './ReaderPane.module.css';
|
|
56
|
+
/**
|
|
57
|
+
* An in-flight line that says WHAT is running and, once it has taken a moment,
|
|
58
|
+
* how long it has been — the difference between "working" and "dead" on a slow
|
|
59
|
+
* page. The elapsed figure only appears after a few seconds: a fast fetch never
|
|
60
|
+
* shows a number at all.
|
|
61
|
+
*/
|
|
62
|
+
function BusyLine({ t, label, since }) {
|
|
63
|
+
const [now, setNow] = useState(() => Date.now());
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const timer = setInterval(() => { setNow(Date.now()); }, 1000);
|
|
66
|
+
return () => { clearInterval(timer); };
|
|
67
|
+
}, []);
|
|
68
|
+
const elapsed = since === undefined ? 0 : Math.max(0, Math.round((now - since) / 1000));
|
|
69
|
+
return (_jsx("p", { className: css.incomplete, children: _jsxs("span", { className: css.renderingLine, children: [_jsx("span", { className: css.renderingRing, "aria-hidden": "true" }), label, elapsed >= 3 ? ` · ${t('detail.fetchElapsed', { seconds: elapsed })}` : ''] }) }));
|
|
70
|
+
}
|
|
71
|
+
/** The global 「转完自动翻译」 preference (a per-browser convenience). */
|
|
72
|
+
const AUTO_TRANSLATE_KEY = 'dsh-reader.autoTranslateTranscripts';
|
|
41
73
|
/** A tiny stroke glyph set for the chrome the host has no icon for. */
|
|
42
74
|
const STROKE = {
|
|
43
75
|
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',
|
|
@@ -72,6 +104,9 @@ function isCjk(html) {
|
|
|
72
104
|
/** The wall's tag panel box: the width it is clamped with, and the height
|
|
73
105
|
* placement estimates from the vocabulary (its real height is bounded by CSS). */
|
|
74
106
|
const TAG_PANEL_WIDTH = 236;
|
|
107
|
+
/** The card menu's footprint, for keeping a menu opened at the screen's edge on screen. */
|
|
108
|
+
const CARD_MENU_WIDTH = 168;
|
|
109
|
+
const CARD_MENU_HEIGHT = 120;
|
|
75
110
|
const TAG_PANEL_MAX_HEIGHT = 296;
|
|
76
111
|
/**
|
|
77
112
|
* How many sources it takes before the picker's own search box appears. Below
|
|
@@ -113,7 +148,7 @@ function TagSuggestions({ t, tags, applied, draft, onToggle, onCreate }) {
|
|
|
113
148
|
const exact = tags.some(tag => tag.name.toLowerCase() === needle);
|
|
114
149
|
return (_jsxs(_Fragment, { children: [suggested.map(tag => {
|
|
115
150
|
const on = applied.includes(tag.id);
|
|
116
|
-
return (_jsxs("button", { type: "button", className: css.
|
|
151
|
+
return (_jsxs("button", { type: "button", className: css.tagOption, "data-on": on || undefined,
|
|
117
152
|
// Keep the field focused: the detail editor closes on blur, and a
|
|
118
153
|
// mousedown that moves focus would dismiss the list mid-click.
|
|
119
154
|
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));
|
|
@@ -203,7 +238,7 @@ function whenLabel(t, iso) {
|
|
|
203
238
|
* @returns the reader-facing sentence.
|
|
204
239
|
*/
|
|
205
240
|
function describeFetchFailure(reason, t) {
|
|
206
|
-
if (/^HTTP (401|403)/.test(reason))
|
|
241
|
+
if (/^HTTP (401|403)/.test(reason) || /bot challenge/i.test(reason) || isCookieHandshake(reason))
|
|
207
242
|
return t('sources.blocked');
|
|
208
243
|
if (/^HTTP \d{3}/.test(reason))
|
|
209
244
|
return t('sources.httpError');
|
|
@@ -212,6 +247,19 @@ function describeFetchFailure(reason, t) {
|
|
|
212
247
|
}
|
|
213
248
|
return t('detail.fetchFailed', { reason: reason.slice(0, 160) });
|
|
214
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Whether a fetch failure is one a real browser gets past: a bot challenge,
|
|
252
|
+
* a 401/403 that refuses non-browser requests, or a cookie handshake
|
|
253
|
+
* (Springer Nature's idp bounce). These are the reasons the
|
|
254
|
+
* rendered fetch (the capture package's headless Chrome) exists to answer; a
|
|
255
|
+
* login wall is not among them — a cookie-less browser meets it too.
|
|
256
|
+
*
|
|
257
|
+
* @param reason - the host's recorded message.
|
|
258
|
+
* @returns true when escalating to the rendered fetch is worth a try.
|
|
259
|
+
*/
|
|
260
|
+
export function browserSolvable(reason) {
|
|
261
|
+
return /bot challenge|^HTTP 40[13]\b/i.test(reason) || isCookieHandshake(reason);
|
|
262
|
+
}
|
|
215
263
|
/** How many entries the automatic backfill fetches at once. */
|
|
216
264
|
const BACKFILL_CONCURRENCY = 2;
|
|
217
265
|
/**
|
|
@@ -438,9 +486,17 @@ function linkMetaOf(row) {
|
|
|
438
486
|
return undefined;
|
|
439
487
|
return { title: row.entry.title, ...(row.entry.summary === undefined ? {} : { excerpt: row.entry.summary }) };
|
|
440
488
|
}
|
|
441
|
-
/**
|
|
489
|
+
/**
|
|
490
|
+
* The reader tab body, inside the shared panel root: the `dsh-panel` size
|
|
491
|
+
* container its compact rules query, and the compact flag the wall's and the
|
|
492
|
+
* detail's headers read (panel-responsive-spec P2).
|
|
493
|
+
*/
|
|
442
494
|
export function ReaderPane(props) {
|
|
443
|
-
|
|
495
|
+
return _jsx(PanelRoot, { className: css.panelRoot, children: _jsx(ReaderPaneBody, { ...props }) });
|
|
496
|
+
}
|
|
497
|
+
/** The reader's pages: wall, detail, recent, manage. */
|
|
498
|
+
function ReaderPaneBody(props) {
|
|
499
|
+
const { useStore, actions, t } = props;
|
|
444
500
|
const sources = useStore(s => s.sources);
|
|
445
501
|
const parsed = useStore(s => s.parsed);
|
|
446
502
|
const articleHtml = useStore(s => s.articleHtml);
|
|
@@ -469,6 +525,8 @@ export function ReaderPane(props) {
|
|
|
469
525
|
const refreshing = useStore(s => s.refreshing);
|
|
470
526
|
const tags = useStore(s => s.tags);
|
|
471
527
|
const tagCounts = useStore(s => s.tagCounts);
|
|
528
|
+
/** A panel at or below the compact width (a phone, a narrow sidebar). */
|
|
529
|
+
const compact = usePanelCompactContext() === true;
|
|
472
530
|
const entryTagIds = useStore(s => s.entryTagIds);
|
|
473
531
|
const cacheTtlHours = useStore(s => s.cacheTtlHours);
|
|
474
532
|
const backfill = useStore(s => s.backfill);
|
|
@@ -567,12 +625,29 @@ export function ReaderPane(props) {
|
|
|
567
625
|
* (cached or fresh), so reopening an entry shows the same note.
|
|
568
626
|
*/
|
|
569
627
|
const [scriptFigures, setScriptFigures] = useState(0);
|
|
570
|
-
/**
|
|
571
|
-
|
|
628
|
+
/**
|
|
629
|
+
* The entry whose capture-rendered refetch is in flight. Per entry, not a
|
|
630
|
+
* pane-wide flag: opening another article mid-render must not show it the
|
|
631
|
+
* first one's progress line (or disable its 「重新抓取」).
|
|
632
|
+
*/
|
|
633
|
+
const [captureBusyId, setCaptureBusyId] = useState(null);
|
|
634
|
+
const captureBusy = captureBusyId !== null && captureBusyId === openEntryId;
|
|
635
|
+
/**
|
|
636
|
+
* Why the last rendered fetch of an entry came back without a body. The
|
|
637
|
+
* recorded refusal (「拒绝自动抓取」) stays the line's lead, so without this
|
|
638
|
+
* a failed render would be indistinguishable from one never attempted.
|
|
639
|
+
*/
|
|
640
|
+
const [captureFailure, setCaptureFailure] = useState(null);
|
|
572
641
|
/** Entries whose stored body came from a rendered fetch (the「重新抓取」route reads it). */
|
|
573
642
|
const renderedBodiesRef = useRef(new Set());
|
|
574
643
|
/** Auto-render fires once per entry per pane session; failures leave the manual retry. */
|
|
575
644
|
const autoRenderTriedRef = useRef(new Set());
|
|
645
|
+
/**
|
|
646
|
+
* Which leg of a fetch is running, keyed to its entry: the detail view's
|
|
647
|
+
* 「抓取中」 line renders the phase (download → extract → store) and the
|
|
648
|
+
* elapsed time, so a slow page reads as progress instead of a dead click.
|
|
649
|
+
*/
|
|
650
|
+
const [bodyFetch, setBodyFetch] = useState(null);
|
|
576
651
|
/** What the plugin holds per entry, as the host reports it (the 抓取 pills). */
|
|
577
652
|
const fetchStates = useStore(s => s.fetchStates);
|
|
578
653
|
/** What the reader opened, as the host stores it (the 「最近阅读」 page). */
|
|
@@ -580,11 +655,15 @@ export function ReaderPane(props) {
|
|
|
580
655
|
const [addOpen, setAddOpen] = useState(false);
|
|
581
656
|
const [sortOpen, setSortOpen] = useState(false);
|
|
582
657
|
const [draftUrl, setDraftUrl] = useState('');
|
|
658
|
+
/** When the in-flight add began — the dialog's busy line, and the double-submit guard. */
|
|
659
|
+
const [addBusySince, setAddBusySince] = useState(null);
|
|
583
660
|
const [verdict, setVerdict] = useState(null);
|
|
584
|
-
const [draft, setDraft] = useState('');
|
|
585
|
-
const [sideChatAvailable, setSideChatAvailable] = useState(false);
|
|
586
661
|
const [timeOfDay, setTimeOfDay] = useState('10:00');
|
|
587
662
|
const [filterOpen, setFilterOpen] = useState(false);
|
|
663
|
+
/** Compact only: the search row is folded behind the header's search icon until asked for. */
|
|
664
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
665
|
+
/** Several incomplete sources fold into one line until the reader asks for the list. */
|
|
666
|
+
const [noticeOpen, setNoticeOpen] = useState(false);
|
|
588
667
|
/** The filter panel's page: its root, or the drilled-in source list. */
|
|
589
668
|
const [filterPage, setFilterPage] = useState('root');
|
|
590
669
|
/** The source page's own search box (the list grows without bound). */
|
|
@@ -617,6 +696,13 @@ export function ReaderPane(props) {
|
|
|
617
696
|
/** The page's Translator API, read once: no API means no button at all. */
|
|
618
697
|
const translator = useMemo(() => detectTranslator(), []);
|
|
619
698
|
const [translateAvailability, setTranslateAvailability] = useState(null);
|
|
699
|
+
/**
|
|
700
|
+
* Without a Translator API (iOS, every non-Chromium browser): the pair of the
|
|
701
|
+
* host's stored translation of THIS body, when there is one — the globe then
|
|
702
|
+
* shows that translation and can make no new one. Null on a page that has the
|
|
703
|
+
* API, and on a body nothing was stored for.
|
|
704
|
+
*/
|
|
705
|
+
const [storedPair, setStoredPair] = useState(null);
|
|
620
706
|
const [translatePhase, setTranslatePhase] = useState('idle');
|
|
621
707
|
const [translateView, setTranslateView] = useState('trans');
|
|
622
708
|
const [packProgress, setPackProgress] = useState(null);
|
|
@@ -630,6 +716,14 @@ export function ReaderPane(props) {
|
|
|
630
716
|
const rescuedImagesRef = useRef(new Map());
|
|
631
717
|
/** The detail view's scroller, which is where a reading position lives. */
|
|
632
718
|
const detailRef = useRef(null);
|
|
719
|
+
/** The article's own heading, which the compact bar watches scroll away. */
|
|
720
|
+
const detailTitleRef = useRef(null);
|
|
721
|
+
/**
|
|
722
|
+
* Whether the article's heading has scrolled up out of the body. The compact
|
|
723
|
+
* bar starts empty (the heading sits right below it) and takes the title only
|
|
724
|
+
* then, so a reader deep in the text still sees what they are reading.
|
|
725
|
+
*/
|
|
726
|
+
const [titleScrolledAway, setTitleScrolledAway] = useState(false);
|
|
633
727
|
/** The live segmentation, or null while the article is untouched. */
|
|
634
728
|
const builtRef = useRef(null);
|
|
635
729
|
/** The running translation's cancel flag. */
|
|
@@ -652,6 +746,9 @@ export function ReaderPane(props) {
|
|
|
652
746
|
const [wallAvailability, setWallAvailability] = useState(null);
|
|
653
747
|
/** Translated card fields, by entry id. */
|
|
654
748
|
const [cardTranslations, setCardTranslations] = useState(() => ({ ...snapshot.cardTranslations }));
|
|
749
|
+
/** The card texts as the wall pass sees them (it runs outside render). */
|
|
750
|
+
const cardTranslationsRef = useRef(cardTranslations);
|
|
751
|
+
cardTranslationsRef.current = cardTranslations;
|
|
655
752
|
const [wallProgress, setWallProgress] = useState(null);
|
|
656
753
|
/**
|
|
657
754
|
* The translated FIELD under the pointer, in the translation-only view.
|
|
@@ -721,23 +818,52 @@ export function ReaderPane(props) {
|
|
|
721
818
|
reveal: css.reveal ?? 'dsh-reader-reveal',
|
|
722
819
|
line: css.revealLine ?? 'dsh-reader-reveal-line',
|
|
723
820
|
}), []);
|
|
821
|
+
/** This mount has not loaded yet (the cached wall is shown once, on the first load). */
|
|
822
|
+
const firstLoadRef = useRef(true);
|
|
724
823
|
/** Load the source list and parse whatever payloads the host is holding. */
|
|
725
824
|
const load = useCallback(async () => {
|
|
726
|
-
|
|
825
|
+
// A remount (the reader came back from another sidebar tab) shows the wall
|
|
826
|
+
// it already parsed at once, and only re-reads what changed meanwhile.
|
|
827
|
+
const cached = readWallCache();
|
|
828
|
+
const restored = cached !== undefined && firstLoadRef.current;
|
|
829
|
+
firstLoadRef.current = false;
|
|
830
|
+
if (restored) {
|
|
831
|
+
actions.setSources([...cached.sources]);
|
|
832
|
+
for (const { parsed: entry, label } of cached.parsed.values()) {
|
|
833
|
+
actions.setParsed(entry);
|
|
834
|
+
if (label !== undefined)
|
|
835
|
+
actions.setSourceLabel(entry.id, label);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (cached === undefined)
|
|
839
|
+
actions.setLoading(true);
|
|
840
|
+
const fresh = new Map(cached?.parsed ?? []);
|
|
841
|
+
const labels = new Map();
|
|
842
|
+
let stampOf = new Map();
|
|
843
|
+
const put = (parsed) => {
|
|
844
|
+
actions.setParsed(parsed);
|
|
845
|
+
const stamp = stampOf.get(parsed.id);
|
|
846
|
+
if (stamp !== undefined)
|
|
847
|
+
fresh.set(parsed.id, { stamp, parsed, ...(labels.has(parsed.id) ? { label: labels.get(parsed.id) } : {}) });
|
|
848
|
+
};
|
|
849
|
+
let known = [];
|
|
727
850
|
try {
|
|
728
851
|
const listed = await props.listSources();
|
|
729
852
|
if (!listed.ok) {
|
|
730
853
|
actions.setError(listed.error.message);
|
|
731
854
|
return;
|
|
732
855
|
}
|
|
733
|
-
|
|
856
|
+
known = [...listed.value.sources];
|
|
734
857
|
actions.setSources(known);
|
|
858
|
+
stampOf = new Map(known.map(source => [source.id, sourceStamp(source)]));
|
|
735
859
|
// Sources whose payload is wanted: the ones that have a body, PLUS every
|
|
736
860
|
// saved link — a link with no body is still a card the reader must see,
|
|
737
861
|
// and the host answers that request with the reason instead of a payload.
|
|
738
862
|
// (Asking only for sources with bodies made an unreadable link vanish from
|
|
739
863
|
// the wall entirely, which is a worse answer than "link only".)
|
|
740
|
-
|
|
864
|
+
// Unchanged since the cached parse: nothing to fetch (the wall already shows it).
|
|
865
|
+
// (Only on that restore: a refresh or an add re-reads everything, as before.)
|
|
866
|
+
const wanted = known.filter(source => (source.hasBody || source.kind === 'link') && (!restored || fresh.get(source.id)?.stamp !== stampOf.get(source.id)));
|
|
741
867
|
if (wanted.length === 0)
|
|
742
868
|
return;
|
|
743
869
|
const bodies = await props.getBodies(wanted.map(source => source.id));
|
|
@@ -758,10 +884,10 @@ export function ReaderPane(props) {
|
|
|
758
884
|
const cut = body.truncated === true;
|
|
759
885
|
if (body.raw === undefined) {
|
|
760
886
|
if (source.kind === 'rss') {
|
|
761
|
-
|
|
887
|
+
put({ id: source.id, entries: [], error: body.error ?? t('detail.extractFailed') });
|
|
762
888
|
}
|
|
763
889
|
else {
|
|
764
|
-
|
|
890
|
+
put({
|
|
765
891
|
id: source.id,
|
|
766
892
|
entries: [{
|
|
767
893
|
id: linkEntryId(source.id),
|
|
@@ -791,8 +917,9 @@ export function ReaderPane(props) {
|
|
|
791
917
|
// parse a feed — it has no XML parser), so the parsed title wins.
|
|
792
918
|
if (result.ok && result.feed.title !== undefined) {
|
|
793
919
|
actions.setSourceLabel(source.id, result.feed.title);
|
|
920
|
+
labels.set(source.id, result.feed.title);
|
|
794
921
|
}
|
|
795
|
-
|
|
922
|
+
put(result.ok
|
|
796
923
|
? {
|
|
797
924
|
id: source.id,
|
|
798
925
|
entries: result.feed.entries.map(entry => cut ? { ...entry, truncated: true } : entry),
|
|
@@ -803,13 +930,15 @@ export function ReaderPane(props) {
|
|
|
803
930
|
}
|
|
804
931
|
else {
|
|
805
932
|
const extracted = extractArticle(body.raw, source.url);
|
|
933
|
+
// A video / episode link's synthetic page carries its media (media-host.ts).
|
|
934
|
+
const linkMedia = readMediaMeta(body.raw);
|
|
806
935
|
// The card's title/excerpt: the article's own when THIS extraction
|
|
807
936
|
// read one, else the captured ones from an earlier extraction, else
|
|
808
937
|
// the URL-derived label — a saved link's card names the paper.
|
|
809
938
|
const extractedTitle = extracted.ok ? extracted.title : undefined;
|
|
810
939
|
const extractedExcerpt = extracted.ok ? extracted.excerpt : undefined;
|
|
811
940
|
const excerpt = extractedExcerpt ?? body.excerpt;
|
|
812
|
-
|
|
941
|
+
put({
|
|
813
942
|
id: source.id,
|
|
814
943
|
entries: [{
|
|
815
944
|
id: linkEntryId(source.id),
|
|
@@ -827,6 +956,7 @@ export function ReaderPane(props) {
|
|
|
827
956
|
// Incompleteness comes from the FETCH, not from extraction: the
|
|
828
957
|
// seam truncated the page, so whatever we extracted is partial.
|
|
829
958
|
...(cut ? { truncated: true } : {}),
|
|
959
|
+
...(linkMedia === undefined ? {} : { media: linkMedia }),
|
|
830
960
|
}],
|
|
831
961
|
});
|
|
832
962
|
}
|
|
@@ -834,6 +964,10 @@ export function ReaderPane(props) {
|
|
|
834
964
|
}
|
|
835
965
|
finally {
|
|
836
966
|
actions.setLoading(false);
|
|
967
|
+
if (known.length > 0) {
|
|
968
|
+
const ids = new Set(known.map(source => source.id));
|
|
969
|
+
writeWallCache({ sources: known, parsed: new Map([...fresh].filter(([id]) => ids.has(id))) });
|
|
970
|
+
}
|
|
837
971
|
}
|
|
838
972
|
}, [actions, props, t]);
|
|
839
973
|
const backfillRunning = useRef(false);
|
|
@@ -888,7 +1022,6 @@ export function ReaderPane(props) {
|
|
|
888
1022
|
void props.capabilities().then(result => {
|
|
889
1023
|
if (!result.ok)
|
|
890
1024
|
return;
|
|
891
|
-
setSideChatAvailable(result.value.hasSideChat);
|
|
892
1025
|
actions.setSchedule(result.value.lastRefreshAt, result.value.nextRefreshAt);
|
|
893
1026
|
if (result.value.nextRefreshAt !== undefined)
|
|
894
1027
|
setTimeOfDay(clockOf(result.value.nextRefreshAt));
|
|
@@ -940,6 +1073,17 @@ export function ReaderPane(props) {
|
|
|
940
1073
|
}
|
|
941
1074
|
return counts;
|
|
942
1075
|
}, [allEntries, presentation]);
|
|
1076
|
+
/** How many video / audio entries the wall holds, for the type filter. */
|
|
1077
|
+
const mediaCounts = useMemo(() => {
|
|
1078
|
+
const counts = { video: 0, audio: 0 };
|
|
1079
|
+
for (const entry of allEntries)
|
|
1080
|
+
if (entry.media !== undefined)
|
|
1081
|
+
counts[entry.media.kind] += 1;
|
|
1082
|
+
return counts;
|
|
1083
|
+
}, [allEntries]);
|
|
1084
|
+
/** The wall's video / audio cards, for their transcript pills. */
|
|
1085
|
+
const mediaEntryIds = useMemo(() => allEntries.filter(entry => entry.media !== undefined).slice(0, LIST_RENDER_LIMIT).map(entry => entry.id), [allEntries]);
|
|
1086
|
+
const mediaCards = useMediaStates(mediaEntryIds, view === 'list', props);
|
|
943
1087
|
/** One sweep at a time: the wall can re-render while an extraction runs. */
|
|
944
1088
|
const sweepRunning = useRef(false);
|
|
945
1089
|
/** The entry ids currently on the wall, for the fetch-state round trip. */
|
|
@@ -1111,6 +1255,126 @@ export function ReaderPane(props) {
|
|
|
1111
1255
|
return item === undefined ? undefined : entryFromRecent(item);
|
|
1112
1256
|
}, [allEntries, recent]);
|
|
1113
1257
|
const openEntry = openEntryId === null ? undefined : entryById(openEntryId);
|
|
1258
|
+
/** The open article's notes (see useNotes — they read the article, never drive it). */
|
|
1259
|
+
// A transcript's speakers: the reader's names for them (host-kept), else 说话人 A, B, ….
|
|
1260
|
+
const hasSpeakers = articleHtml?.includes('data-spk=') === true;
|
|
1261
|
+
const [speakerNamesOf, setSpeakerNamesOf] = useState(undefined);
|
|
1262
|
+
const speakerNames = useMemo(() => (speakerNamesOf !== undefined && speakerNamesOf.entryId === openEntry?.id ? speakerNamesOf.names : {}), [speakerNamesOf, openEntry?.id]);
|
|
1263
|
+
useEffect(() => {
|
|
1264
|
+
const id = openEntry?.id;
|
|
1265
|
+
if (id === undefined || !hasSpeakers)
|
|
1266
|
+
return;
|
|
1267
|
+
void props.speakerNames({ entryId: id }).then(answer => { if (answer.ok)
|
|
1268
|
+
setSpeakerNamesOf({ entryId: id, names: answer.value }); });
|
|
1269
|
+
}, [openEntry?.id, hasSpeakers, props]);
|
|
1270
|
+
const speakerLabel = useCallback((speaker) => speakerNames[String(speaker)] ?? t('media.speakerDefault', { letter: String.fromCharCode(65 + speaker) }), [speakerNames, t]);
|
|
1271
|
+
/** How many voices the open transcript has (its highest speaker index + 1). */
|
|
1272
|
+
const speakerCount = useMemo(() => {
|
|
1273
|
+
if (!hasSpeakers || articleHtml === null)
|
|
1274
|
+
return 0;
|
|
1275
|
+
let top = -1;
|
|
1276
|
+
for (const match of articleHtml.matchAll(/data-spk="(\d+)"/g))
|
|
1277
|
+
top = Math.max(top, Number(match[1]));
|
|
1278
|
+
return top + 1;
|
|
1279
|
+
}, [hasSpeakers, articleHtml]);
|
|
1280
|
+
const renameSpeaker = useCallback((speaker, name) => {
|
|
1281
|
+
const id = openEntry?.id;
|
|
1282
|
+
if (id === undefined)
|
|
1283
|
+
return;
|
|
1284
|
+
void props.nameSpeaker({ entryId: id, speaker, name }).then(answer => { if (answer.ok)
|
|
1285
|
+
setSpeakerNamesOf({ entryId: id, names: answer.value }); });
|
|
1286
|
+
}, [openEntry?.id, props]);
|
|
1287
|
+
const notesFace = useNotes({
|
|
1288
|
+
props,
|
|
1289
|
+
entry: openEntry,
|
|
1290
|
+
sourceLabel: openEntry === undefined ? '' : presentation.get(openEntry.sourceId)?.label ?? '',
|
|
1291
|
+
articleHtml,
|
|
1292
|
+
articleRef,
|
|
1293
|
+
builtRef,
|
|
1294
|
+
detailRef,
|
|
1295
|
+
paintKey: `${translatePhase}:${String(translateProgress?.done ?? 0)}`,
|
|
1296
|
+
translateView,
|
|
1297
|
+
...(hasSpeakers ? { speakerLabel } : {}),
|
|
1298
|
+
});
|
|
1299
|
+
/** The open entry's video / audio (see useMedia — it decorates the transcript, never rewrites it). */
|
|
1300
|
+
const openProvider = openEntry?.media?.provider;
|
|
1301
|
+
const chipTitle = useCallback((clock) => t(openProvider === 'bilibili' ? 'media.chipTitleReload' : 'media.chipTitle', { time: clock }), [t, openProvider]);
|
|
1302
|
+
const mediaFace = useMedia({ entryId: openEntry?.id, media: openEntry?.media, articleHtml, articleRef, detailRef, chipTitle, speakerLabel });
|
|
1303
|
+
// 转完自动翻译: one global preference, off until the reader ticks it once.
|
|
1304
|
+
const [autoTranslate, setAutoTranslate] = useState(() => {
|
|
1305
|
+
try {
|
|
1306
|
+
return globalThis.localStorage?.getItem(AUTO_TRANSLATE_KEY) === '1';
|
|
1307
|
+
}
|
|
1308
|
+
catch {
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
const setAutoTranslatePref = useCallback((on) => {
|
|
1313
|
+
setAutoTranslate(on);
|
|
1314
|
+
try {
|
|
1315
|
+
if (on)
|
|
1316
|
+
globalThis.localStorage?.setItem(AUTO_TRANSLATE_KEY, '1');
|
|
1317
|
+
else
|
|
1318
|
+
globalThis.localStorage?.removeItem(AUTO_TRANSLATE_KEY);
|
|
1319
|
+
}
|
|
1320
|
+
catch {
|
|
1321
|
+
// Remembered for this page only.
|
|
1322
|
+
}
|
|
1323
|
+
}, []);
|
|
1324
|
+
const setTranscriptState = mediaFace.setTranscript;
|
|
1325
|
+
// No captions and nothing stored: the open entry transcribes on this machine.
|
|
1326
|
+
const localFace = useLocalTranscript({
|
|
1327
|
+
entry: openEntry,
|
|
1328
|
+
wanted: openEntry?.media !== undefined && mediaFace.transcript === 'none',
|
|
1329
|
+
hidden: mediaFace.hidden,
|
|
1330
|
+
rejoin: openEntry?.media !== undefined && mediaFace.transcript === 'ready' && articleHtml?.includes('data-src="local"') === true,
|
|
1331
|
+
remote: props,
|
|
1332
|
+
onHtml: (entryId, html, partial) => {
|
|
1333
|
+
if (openRequestRef.current !== entryId)
|
|
1334
|
+
return;
|
|
1335
|
+
actions.setArticle(html, false, null);
|
|
1336
|
+
if (!partial)
|
|
1337
|
+
setTranscriptState(entryId, 'ready');
|
|
1338
|
+
},
|
|
1339
|
+
});
|
|
1340
|
+
const localActive = localFace.status !== undefined && ['fetching', 'queued', 'transcribing'].includes(localFace.status.state);
|
|
1341
|
+
// 润色: the agent's translation layer over the body.
|
|
1342
|
+
const polishFace = usePolish({
|
|
1343
|
+
entryId: openEntry?.id,
|
|
1344
|
+
articleHtml,
|
|
1345
|
+
articleRef,
|
|
1346
|
+
translating: translatePhase === 'ready' || translatePhase === 'working',
|
|
1347
|
+
translateView,
|
|
1348
|
+
remote: props,
|
|
1349
|
+
tagLabel: t('polish.tag'),
|
|
1350
|
+
tagTitle: t('polish.tagTitle'),
|
|
1351
|
+
});
|
|
1352
|
+
/** Ask the agent to polish the whole article (or continue at a paragraph). */
|
|
1353
|
+
const requestPolish = useCallback((from) => {
|
|
1354
|
+
const article = articleRef.current;
|
|
1355
|
+
const total = polishFace.view?.total ?? (article === null ? 0 : blockElements(article).length);
|
|
1356
|
+
notesFace.polish(total, from);
|
|
1357
|
+
polishFace.requested();
|
|
1358
|
+
}, [polishFace, notesFace]);
|
|
1359
|
+
// The compact bar's title (see `titleScrolledAway`): watch the article's
|
|
1360
|
+
// heading leave the body through its top.
|
|
1361
|
+
useEffect(() => {
|
|
1362
|
+
setTitleScrolledAway(false);
|
|
1363
|
+
const root = detailRef.current;
|
|
1364
|
+
const heading = detailTitleRef.current;
|
|
1365
|
+
// No observer (jsdom, an old engine) degrades to the bar staying empty.
|
|
1366
|
+
if (!compact || view !== 'detail' || root === null || heading === null || typeof IntersectionObserver === 'undefined')
|
|
1367
|
+
return undefined;
|
|
1368
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
1369
|
+
if (entry === undefined)
|
|
1370
|
+
return;
|
|
1371
|
+
// Away only when it left through the TOP: a heading below the fold (a
|
|
1372
|
+
// restored reading position mid-render) is not "scrolled past".
|
|
1373
|
+
setTitleScrolledAway(!entry.isIntersecting && entry.boundingClientRect.bottom <= (entry.rootBounds?.top ?? 0));
|
|
1374
|
+
}, { root, threshold: 0 });
|
|
1375
|
+
observer.observe(heading);
|
|
1376
|
+
return () => { observer.disconnect(); };
|
|
1377
|
+
}, [compact, view, openEntryId, openEntry !== undefined]);
|
|
1114
1378
|
/** The source the strip is currently narrowing to, if any. */
|
|
1115
1379
|
const activeSource = sources.find(source => query.trim() === sourceQuery(source.id))?.id ?? null;
|
|
1116
1380
|
/** The source KIND the search box is narrowing to, if any (`#rss` / `#link`). */
|
|
@@ -1179,14 +1443,14 @@ export function ReaderPane(props) {
|
|
|
1179
1443
|
}, [view, articleHtml, guessSource]);
|
|
1180
1444
|
/**
|
|
1181
1445
|
* Whether the globe belongs on this article at all. Three ways it does not:
|
|
1182
|
-
* the browser has no Translator API
|
|
1183
|
-
*
|
|
1184
|
-
*
|
|
1446
|
+
* the browser has no Translator API (and the host stored no translation of
|
|
1447
|
+
* this body), the pair is unavailable on this device, or the body is already
|
|
1448
|
+
* the target language. All three hide the control rather than offering one
|
|
1449
|
+
* that cannot work — the pass's own degradation rule.
|
|
1185
1450
|
*/
|
|
1186
|
-
const translationOffered =
|
|
1187
|
-
|
|
1188
|
-
&& translateAvailability !== 'unavailable'
|
|
1189
|
-
&& translationSource !== TRANSLATION_TARGET;
|
|
1451
|
+
const translationOffered = translationSource !== TRANSLATION_TARGET && (translator === null
|
|
1452
|
+
? storedPair !== null
|
|
1453
|
+
: translateAvailability !== null && translateAvailability !== 'unavailable');
|
|
1190
1454
|
// Ask the browser about this pair once per opened article, in every target
|
|
1191
1455
|
// spelling: the probe is async, and the control appears only once one of them
|
|
1192
1456
|
// is not `unavailable`.
|
|
@@ -1247,7 +1511,34 @@ export function ReaderPane(props) {
|
|
|
1247
1511
|
setTranslateError(null);
|
|
1248
1512
|
setTranslateMenu(false);
|
|
1249
1513
|
setTranslateAvailability(null);
|
|
1514
|
+
setStoredPair(null);
|
|
1250
1515
|
}, [openEntryId, articleHtml]);
|
|
1516
|
+
// No Translator API: ask the host whether a translation of exactly this body
|
|
1517
|
+
// is stored (made earlier in a browser that had one). Only the entry's own
|
|
1518
|
+
// map counts — its bodyHash proves the pairing; the global sentence memory
|
|
1519
|
+
// alone could cover a few stray sentences of an untranslated article.
|
|
1520
|
+
useEffect(() => {
|
|
1521
|
+
if (view !== 'detail' || translator !== null || openEntryId === null)
|
|
1522
|
+
return undefined;
|
|
1523
|
+
if (articleHtml === null || articleHtml.length === 0)
|
|
1524
|
+
return undefined;
|
|
1525
|
+
let cancelled = false;
|
|
1526
|
+
void (async () => {
|
|
1527
|
+
try {
|
|
1528
|
+
const answer = await props.getEntryTranslation(openEntryId);
|
|
1529
|
+
const record = answer.ok ? answer.value.translation : undefined;
|
|
1530
|
+
if (cancelled || record === undefined)
|
|
1531
|
+
return;
|
|
1532
|
+
if (record.bodyHash !== translationHash(articleHtml) || Object.keys(record.segments).length === 0)
|
|
1533
|
+
return;
|
|
1534
|
+
setStoredPair(record.pair);
|
|
1535
|
+
}
|
|
1536
|
+
catch {
|
|
1537
|
+
// Unreadable is the same as absent: no globe.
|
|
1538
|
+
}
|
|
1539
|
+
})();
|
|
1540
|
+
return () => { cancelled = true; };
|
|
1541
|
+
}, [view, translator, openEntryId, articleHtml, props]);
|
|
1251
1542
|
// Leaving the pane must not leave spans behind in a DOM React will reuse.
|
|
1252
1543
|
useEffect(() => () => {
|
|
1253
1544
|
if (cancelRef.current !== null)
|
|
@@ -1345,6 +1636,106 @@ export function ReaderPane(props) {
|
|
|
1345
1636
|
...(entry === undefined ? {} : { entryId: entry.entryId, bodyHash: entry.bodyHash }),
|
|
1346
1637
|
});
|
|
1347
1638
|
}, [props]);
|
|
1639
|
+
/**
|
|
1640
|
+
* The open article's title in the reader's language, once the globe has
|
|
1641
|
+
* translated the body. Keyed by entry so a stale answer never titles the
|
|
1642
|
+
* next article.
|
|
1643
|
+
*/
|
|
1644
|
+
const [titleTranslation, setTitleTranslation] = useState(null);
|
|
1645
|
+
/**
|
|
1646
|
+
* Translate the open article's title alongside its body: the page memory,
|
|
1647
|
+
* then the host's sentence memory, then (with a live session) the model.
|
|
1648
|
+
* Without a session — the stored-translation path — a miss leaves the
|
|
1649
|
+
* title in its original, the same rule the body follows.
|
|
1650
|
+
*
|
|
1651
|
+
* @returns what a model call learned, for the caller to fold into its own
|
|
1652
|
+
* persist batch (one write per run, never a second one for the title).
|
|
1653
|
+
*/
|
|
1654
|
+
const translateTitle = useCallback(async (pair, session, cancelled) => {
|
|
1655
|
+
const entry = openEntryId === null ? undefined : entryById(openEntryId);
|
|
1656
|
+
const title = entry?.title.trim() ?? '';
|
|
1657
|
+
if (entry === undefined || title === '' || isTargetLanguage(title))
|
|
1658
|
+
return [];
|
|
1659
|
+
const settle = (text) => {
|
|
1660
|
+
if (text !== undefined && text.trim() !== '' && !cancelled())
|
|
1661
|
+
setTitleTranslation({ id: entry.id, text: text.trim() });
|
|
1662
|
+
};
|
|
1663
|
+
const known = remembered(pair, title) ?? cardTranslationsRef.current[entry.id]?.title;
|
|
1664
|
+
if (known !== undefined) {
|
|
1665
|
+
settle(known);
|
|
1666
|
+
return [];
|
|
1667
|
+
}
|
|
1668
|
+
try {
|
|
1669
|
+
const slice = await props.getSentenceTranslations({ pair, hashes: [translationHash(title)] });
|
|
1670
|
+
const stored = slice.ok ? slice.value.translations[translationHash(title)] : undefined;
|
|
1671
|
+
if (stored !== undefined) {
|
|
1672
|
+
primeMemory(pair, [{ text: title, translation: stored }]);
|
|
1673
|
+
settle(stored);
|
|
1674
|
+
return [];
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
catch {
|
|
1678
|
+
// A miss: the model's job, when there is one.
|
|
1679
|
+
}
|
|
1680
|
+
if (session === null || cancelled())
|
|
1681
|
+
return [];
|
|
1682
|
+
const outcome = await translateTexts([title], pair, session, cancelled);
|
|
1683
|
+
settle(outcome.translated.get(title));
|
|
1684
|
+
return outcome.learned;
|
|
1685
|
+
}, [openEntryId, entryById, props]);
|
|
1686
|
+
/**
|
|
1687
|
+
* The globe on a page with no Translator API: paint the host's stored
|
|
1688
|
+
* translation of this body and stop. Nothing is sent to a model; a sentence
|
|
1689
|
+
* the store does not cover stays in its original.
|
|
1690
|
+
*
|
|
1691
|
+
* @param initialView - the view to show.
|
|
1692
|
+
* @param pair - the stored record's own pair label.
|
|
1693
|
+
*/
|
|
1694
|
+
const showStoredTranslation = useCallback(async (initialView, pair) => {
|
|
1695
|
+
const container = articleRef.current;
|
|
1696
|
+
if (container === null || openEntryId === null || articleHtml === null)
|
|
1697
|
+
return;
|
|
1698
|
+
if (cancelRef.current !== null)
|
|
1699
|
+
cancelRef.current.cancelled = true;
|
|
1700
|
+
const previous = builtRef.current;
|
|
1701
|
+
if (previous !== null)
|
|
1702
|
+
restoreArticle(previous.root);
|
|
1703
|
+
builtRef.current = null;
|
|
1704
|
+
const cancel = { cancelled: false };
|
|
1705
|
+
cancelRef.current = cancel;
|
|
1706
|
+
setTranslateError(null);
|
|
1707
|
+
setTranslateProgress(null);
|
|
1708
|
+
const built = buildArticle(container, translateClasses);
|
|
1709
|
+
if (built === null) {
|
|
1710
|
+
cancelRef.current = null;
|
|
1711
|
+
setStoredPair(null);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
setTranslatePhase('working');
|
|
1715
|
+
void translateTitle(pair, null, () => cancel.cancelled);
|
|
1716
|
+
await warmMemory(pair, openEntryId, articleHtml, built);
|
|
1717
|
+
if (cancel.cancelled)
|
|
1718
|
+
return;
|
|
1719
|
+
cancelRef.current = null;
|
|
1720
|
+
const result = applyRemembered(built, pair);
|
|
1721
|
+
if (result.done === 0) {
|
|
1722
|
+
// The record named this body but none of its sentences matched the
|
|
1723
|
+
// segmentation here: nothing to show, so no globe either.
|
|
1724
|
+
restoreArticle(built.root);
|
|
1725
|
+
setTranslatePhase('idle');
|
|
1726
|
+
setStoredPair(null);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
builtRef.current = built;
|
|
1730
|
+
if (initialView !== 'orig')
|
|
1731
|
+
lastViewRef.current = initialView;
|
|
1732
|
+
setTranslateView(initialView);
|
|
1733
|
+
setView(built, initialView, translateClasses);
|
|
1734
|
+
setTranslatePhase('ready');
|
|
1735
|
+
rememberTranslation(openEntryId, initialView, pair.split('→')[0] ?? translationSource);
|
|
1736
|
+
// Only the LRU stamps move: a stored translation being read is a use.
|
|
1737
|
+
persistTranslations(pair, [], result.recalled, { entryId: openEntryId, bodyHash: translationHash(articleHtml) });
|
|
1738
|
+
}, [openEntryId, articleHtml, translateClasses, warmMemory, persistTranslations, translationSource, translateTitle]);
|
|
1348
1739
|
/**
|
|
1349
1740
|
* Get the article translated, reusing a session this page already built.
|
|
1350
1741
|
*
|
|
@@ -1361,8 +1752,13 @@ export function ReaderPane(props) {
|
|
|
1361
1752
|
const startTranslation = useCallback(async (initialView, sourceOverride) => {
|
|
1362
1753
|
const api = translator;
|
|
1363
1754
|
const container = articleRef.current;
|
|
1364
|
-
if (
|
|
1755
|
+
if (container === null)
|
|
1365
1756
|
return;
|
|
1757
|
+
if (api === null) {
|
|
1758
|
+
if (storedPair !== null)
|
|
1759
|
+
await showStoredTranslation(initialView, storedPair);
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1366
1762
|
// One run at a time. A second start (the reader's retry, or the restore
|
|
1367
1763
|
// re-applying a translation to a body that was just replaced) must not build
|
|
1368
1764
|
// its own segmentation over the first one's: two overlapping runs would
|
|
@@ -1444,6 +1840,9 @@ export function ReaderPane(props) {
|
|
|
1444
1840
|
// The host's tiers answer first: the entry's exact-fit map, then the global
|
|
1445
1841
|
// sentence memory. Only what neither knows is spent on the model.
|
|
1446
1842
|
const pair = `${sourceLanguage}→${TRANSLATION_TARGET}`;
|
|
1843
|
+
// The title rides the same session, beside the body rather than after it:
|
|
1844
|
+
// one short string, and the header should not wait for a long article.
|
|
1845
|
+
const titleRun = translateTitle(pair, session, () => cancel.cancelled);
|
|
1447
1846
|
if (openEntryId !== null && articleHtml !== null) {
|
|
1448
1847
|
await warmMemory(pair, openEntryId, articleHtml, built);
|
|
1449
1848
|
if (cancel.cancelled) {
|
|
@@ -1460,8 +1859,9 @@ export function ReaderPane(props) {
|
|
|
1460
1859
|
});
|
|
1461
1860
|
// What the run learned (and what it was served) persists in one batch: a
|
|
1462
1861
|
// reload loses the page's memory, and the next visit pays the model again
|
|
1463
|
-
// without this.
|
|
1464
|
-
|
|
1862
|
+
// without this. The title's lesson rides in the same batch.
|
|
1863
|
+
const titleLearned = await titleRun;
|
|
1864
|
+
persistTranslations(pair, [...result.learned, ...titleLearned], result.recalled, openEntryId !== null && articleHtml !== null
|
|
1465
1865
|
? { entryId: openEntryId, bodyHash: translationHash(articleHtml) }
|
|
1466
1866
|
: undefined);
|
|
1467
1867
|
if (cancel.cancelled)
|
|
@@ -1478,7 +1878,7 @@ export function ReaderPane(props) {
|
|
|
1478
1878
|
setTranslateProgress(null);
|
|
1479
1879
|
// Re-paint once the first units exist, so the translated typography applies.
|
|
1480
1880
|
setView(built, initialView, translateClasses);
|
|
1481
|
-
}, [translator, translationSource, translateClasses, t, openEntryId, articleHtml, warmMemory, persistTranslations]);
|
|
1881
|
+
}, [translator, storedPair, showStoredTranslation, translationSource, translateClasses, t, openEntryId, articleHtml, warmMemory, persistTranslations, translateTitle]);
|
|
1482
1882
|
/** Apply one view to a finished translation. */
|
|
1483
1883
|
const applyView = useCallback((next) => {
|
|
1484
1884
|
setTranslateView(next);
|
|
@@ -1670,10 +2070,12 @@ export function ReaderPane(props) {
|
|
|
1670
2070
|
continue;
|
|
1671
2071
|
// Chinese cards (and Chinese fields inside a mixed card) are left
|
|
1672
2072
|
// exactly as they are: translating them to Chinese is not a feature.
|
|
1673
|
-
|
|
2073
|
+
// Already translated (this visit, or before a remount): nothing to do.
|
|
2074
|
+
const done = cardTranslationsRef.current[id];
|
|
2075
|
+
if (!isTargetLanguage(row.entry.title) && done?.title === undefined)
|
|
1674
2076
|
fields.push({ id, field: 'title', text: row.entry.title });
|
|
1675
2077
|
const summary = row.entry.summary;
|
|
1676
|
-
if (summary !== undefined && summary.length > 0 && !isTargetLanguage(summary)) {
|
|
2078
|
+
if (summary !== undefined && summary.length > 0 && !isTargetLanguage(summary) && done?.summary === undefined) {
|
|
1677
2079
|
fields.push({ id, field: 'summary', text: summary });
|
|
1678
2080
|
}
|
|
1679
2081
|
}
|
|
@@ -1800,11 +2202,16 @@ export function ReaderPane(props) {
|
|
|
1800
2202
|
*
|
|
1801
2203
|
* @param entryId - the entry whose body is wanted.
|
|
1802
2204
|
* @param url - the article URL to fetch.
|
|
2205
|
+
* @returns whether the answer wants the rendered fetch next: a body that
|
|
2206
|
+
* still counts script figures, or a refusal a real browser gets past.
|
|
1803
2207
|
*/
|
|
1804
2208
|
const fetchBody = useCallback(async (entryId, url) => {
|
|
1805
2209
|
actions.setFetching(entryId, true);
|
|
2210
|
+
setBodyFetch({ entryId, phase: 'downloading', since: Date.now() });
|
|
1806
2211
|
try {
|
|
1807
|
-
const result = await props.fetchEntryBody(entryId, url
|
|
2212
|
+
const result = await props.fetchEntryBody(entryId, url, phase => {
|
|
2213
|
+
setBodyFetch(current => (current?.entryId === entryId ? { entryId, phase, since: current.since } : current));
|
|
2214
|
+
});
|
|
1808
2215
|
// A late answer belongs to the entry that ASKED for it: if the detail
|
|
1809
2216
|
// view has moved on (another entry, or the wall), the article area is no
|
|
1810
2217
|
// longer this entry's to write. The per-entry records are unaffected —
|
|
@@ -1826,7 +2233,7 @@ export function ReaderPane(props) {
|
|
|
1826
2233
|
}
|
|
1827
2234
|
// The caller escalates on a shell: a body that still counts script
|
|
1828
2235
|
// figures wants the rendered fetch next.
|
|
1829
|
-
return result.scriptFigures ?? 0;
|
|
2236
|
+
return (result.scriptFigures ?? 0) > 0;
|
|
1830
2237
|
}
|
|
1831
2238
|
else if (result.error !== undefined && onScreen) {
|
|
1832
2239
|
// Keep whatever is already rendered (a feed summary, say) and add the
|
|
@@ -1834,10 +2241,12 @@ export function ReaderPane(props) {
|
|
|
1834
2241
|
// It also leaves the host's copy — and its stale marker — untouched.
|
|
1835
2242
|
actions.setArticle(articleHtml ?? '', false, result.error);
|
|
1836
2243
|
}
|
|
1837
|
-
|
|
2244
|
+
// …and so does a bot wall: the plain fetch is what it refused.
|
|
2245
|
+
return result.error !== undefined && browserSolvable(result.error);
|
|
1838
2246
|
}
|
|
1839
2247
|
finally {
|
|
1840
2248
|
actions.setFetching(entryId, false);
|
|
2249
|
+
setBodyFetch(current => (current?.entryId === entryId ? null : current));
|
|
1841
2250
|
// The card reads the host's annotation through the mirror: re-read the
|
|
1842
2251
|
// one entry this fetch settled, so the pill flips on this gesture and
|
|
1843
2252
|
// not at the next mount.
|
|
@@ -1859,19 +2268,20 @@ export function ReaderPane(props) {
|
|
|
1859
2268
|
const capture = props.captureRemote();
|
|
1860
2269
|
if (capture === undefined)
|
|
1861
2270
|
return;
|
|
1862
|
-
|
|
2271
|
+
setCaptureBusyId(entryId);
|
|
2272
|
+
setCaptureFailure(null);
|
|
1863
2273
|
try {
|
|
1864
2274
|
const rendered = await capture.render({ url: link });
|
|
1865
2275
|
if (!rendered.ok) {
|
|
1866
|
-
|
|
1867
|
-
|
|
2276
|
+
// The reason the plain fetch failed stays the line's lead; the render's
|
|
2277
|
+
// own failure is said after it (captureFailure).
|
|
2278
|
+
setCaptureFailure({ entryId, message: rendered.error.message });
|
|
1868
2279
|
return;
|
|
1869
2280
|
}
|
|
1870
2281
|
const finalUrl = rendered.value.finalUrl ?? link;
|
|
1871
2282
|
const extracted = extractArticle(rendered.value.html, finalUrl);
|
|
1872
2283
|
if (!extracted.ok) {
|
|
1873
|
-
|
|
1874
|
-
actions.setArticle(articleHtml ?? '', false, extracted.error);
|
|
2284
|
+
setCaptureFailure({ entryId, message: extracted.error });
|
|
1875
2285
|
return;
|
|
1876
2286
|
}
|
|
1877
2287
|
const stored = await props.storeEntryBody({
|
|
@@ -1879,6 +2289,10 @@ export function ReaderPane(props) {
|
|
|
1879
2289
|
url: finalUrl,
|
|
1880
2290
|
html: extracted.html,
|
|
1881
2291
|
bodyHash: translationHash(extracted.html),
|
|
2292
|
+
// The article's own title/excerpt: a link saved behind a wall has only
|
|
2293
|
+
// its URL-derived label until the render reads the page.
|
|
2294
|
+
...(extracted.title === undefined ? {} : { title: extracted.title }),
|
|
2295
|
+
...(extracted.excerpt === undefined ? {} : { excerpt: extracted.excerpt }),
|
|
1882
2296
|
...(rendered.value.truncated === true ? { truncated: true } : {}),
|
|
1883
2297
|
// A capture render ALWAYS carries its count, zero included: the entry's
|
|
1884
2298
|
// own load-time count (84 script figures, say) is what the remount
|
|
@@ -1891,6 +2305,10 @@ export function ReaderPane(props) {
|
|
|
1891
2305
|
rendered: true,
|
|
1892
2306
|
});
|
|
1893
2307
|
renderedBodiesRef.current.add(entryId);
|
|
2308
|
+
actions.noteExtractedMeta(entryId, {
|
|
2309
|
+
...(extracted.title === undefined ? {} : { title: extracted.title }),
|
|
2310
|
+
...(extracted.excerpt === undefined ? {} : { excerpt: extracted.excerpt }),
|
|
2311
|
+
});
|
|
1894
2312
|
if (openRequestRef.current !== entryId)
|
|
1895
2313
|
return;
|
|
1896
2314
|
if (stored.ok && stored.value.html !== undefined) {
|
|
@@ -1901,19 +2319,21 @@ export function ReaderPane(props) {
|
|
|
1901
2319
|
}
|
|
1902
2320
|
}
|
|
1903
2321
|
finally {
|
|
1904
|
-
|
|
2322
|
+
setCaptureBusyId(current => (current === entryId ? null : current));
|
|
1905
2323
|
}
|
|
1906
|
-
}, [actions, props,
|
|
2324
|
+
}, [actions, props, syncFetchState]);
|
|
1907
2325
|
/**
|
|
1908
2326
|
* One gesture, the whole pipeline: an explicit per-entry ask (add a link,
|
|
1909
2327
|
* the card's 「抓取」, opening an entry, the detail's 「重新抓取」) that lands
|
|
1910
|
-
* on a script-figure shell
|
|
1911
|
-
*
|
|
2328
|
+
* on a script-figure shell — or on a bot wall that refuses the plain fetch
|
|
2329
|
+
* (mp.weixin.qq.com answers 「环境异常」 to anything but a browser) —
|
|
2330
|
+
* continues into the rendered fetch on its own: the gesture already
|
|
2331
|
+
* authorized capturing that URL. Fires once per entry
|
|
1912
2332
|
* per pane session (a failure leaves the manual「渲染抓取」retry); background
|
|
1913
2333
|
* paths (scheduled feed refresh, backfill) never reach here.
|
|
1914
2334
|
*/
|
|
1915
|
-
const maybeAutoRender = useCallback((entryId, link,
|
|
1916
|
-
if (link === undefined ||
|
|
2335
|
+
const maybeAutoRender = useCallback((entryId, link, wanted) => {
|
|
2336
|
+
if (link === undefined || !wanted)
|
|
1917
2337
|
return;
|
|
1918
2338
|
if (props.captureRemote() === undefined)
|
|
1919
2339
|
return;
|
|
@@ -1924,7 +2344,7 @@ export function ReaderPane(props) {
|
|
|
1924
2344
|
}, [props, captureBody]);
|
|
1925
2345
|
/** A plain fetch that escalates on a shell — the explicit-gesture fetch path. */
|
|
1926
2346
|
const fetchThenMaybeRender = useCallback((entryId, link) => {
|
|
1927
|
-
void fetchBody(entryId, link).then(
|
|
2347
|
+
void fetchBody(entryId, link).then(wanted => maybeAutoRender(entryId, link, wanted));
|
|
1928
2348
|
}, [fetchBody, maybeAutoRender]);
|
|
1929
2349
|
/** Open one entry: mark it read and make sure a body is available. */
|
|
1930
2350
|
const open = useCallback(async (row) => {
|
|
@@ -1958,6 +2378,43 @@ export function ReaderPane(props) {
|
|
|
1958
2378
|
// A cached fetch outlives the feed's own payload, so the host is the one
|
|
1959
2379
|
// that knows whether there is full text: it returns the fresh cache, else
|
|
1960
2380
|
// the feed's body, else nothing plus the reason a previous fetch failed.
|
|
2381
|
+
if (row.entry.link !== undefined && row.entry.media !== undefined) {
|
|
2382
|
+
// A video or audio entry: its text is a TRANSCRIPT, never the page behind
|
|
2383
|
+
// the link — none of the article paths below (fetch, backfill, persist)
|
|
2384
|
+
// may run for it, or they would crawl YouTube's watch page.
|
|
2385
|
+
const shownotes = row.entry.contentHtml ?? '';
|
|
2386
|
+
setScriptFigures(0);
|
|
2387
|
+
// A stored transcript (platform captions, or a finished local one) is the body.
|
|
2388
|
+
const cached = await props.getEntryBody({ entryId: row.entry.id, url: row.entry.link });
|
|
2389
|
+
if (openRequestRef.current !== row.entry.id)
|
|
2390
|
+
return;
|
|
2391
|
+
if (cached.ok && cached.value.cached === true && cached.value.html !== undefined) {
|
|
2392
|
+
actions.setArticle(cached.value.html, false, null);
|
|
2393
|
+
setTranscriptState(row.entry.id, 'ready');
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
if (row.entry.media.kind !== 'video' || (row.entry.media.provider !== 'youtube' && row.entry.media.provider !== 'bilibili')) {
|
|
2397
|
+
// Audio has no platform captions: the shownotes show until the local
|
|
2398
|
+
// transcript (`useLocalTranscript`) replaces them.
|
|
2399
|
+
actions.setArticle(shownotes, false, null);
|
|
2400
|
+
setTranscriptState(row.entry.id, 'none');
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
actions.setArticle(shownotes, false, null);
|
|
2404
|
+
setTranscriptState(row.entry.id, 'loading');
|
|
2405
|
+
const fetched = await props.mediaTranscript({ entryId: row.entry.id, url: row.entry.link });
|
|
2406
|
+
if (openRequestRef.current !== row.entry.id)
|
|
2407
|
+
return;
|
|
2408
|
+
if (fetched.ok && fetched.value.html !== undefined) {
|
|
2409
|
+
actions.setArticle(fetched.value.html, false, null);
|
|
2410
|
+
setTranscriptState(row.entry.id, 'ready');
|
|
2411
|
+
void syncFetchState(row.entry.id);
|
|
2412
|
+
}
|
|
2413
|
+
else {
|
|
2414
|
+
setTranscriptState(row.entry.id, fetched.ok && fetched.value.captions === 'none' ? 'none' : 'failed');
|
|
2415
|
+
}
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
1961
2418
|
if (row.entry.link !== undefined) {
|
|
1962
2419
|
// A feed that published only a summary still owes the reader an article,
|
|
1963
2420
|
// whichever branch below supplies the text on screen (the host answers
|
|
@@ -1998,7 +2455,7 @@ export function ReaderPane(props) {
|
|
|
1998
2455
|
// load-time extraction, whose count rides the entry instead.
|
|
1999
2456
|
const knownCount = view.value.scriptFigures ?? row.entry.scriptFigures ?? 0;
|
|
2000
2457
|
setScriptFigures(knownCount);
|
|
2001
|
-
maybeAutoRender(row.entry.id, row.entry.link, knownCount);
|
|
2458
|
+
maybeAutoRender(row.entry.id, row.entry.link, knownCount > 0);
|
|
2002
2459
|
actions.setStaleBody(row.entry.id, view.value.fresh === false);
|
|
2003
2460
|
// `html` here is either the FEED's own payload or a body this plugin
|
|
2004
2461
|
// already fetched and cached — a cached one is paid for already.
|
|
@@ -2049,6 +2506,10 @@ export function ReaderPane(props) {
|
|
|
2049
2506
|
if (row.sourceKind === 'rss' && view.value.error === undefined) {
|
|
2050
2507
|
fetchThenMaybeRender(row.entry.id, row.entry.link);
|
|
2051
2508
|
}
|
|
2509
|
+
// A recorded bot wall (the add-time preview, or an earlier fetch) is
|
|
2510
|
+
// the one recorded reason a gesture CAN change: opening the entry
|
|
2511
|
+
// asks for its text, and a headless browser is what the wall admits.
|
|
2512
|
+
maybeAutoRender(row.entry.id, row.entry.link, view.value.error !== undefined && browserSolvable(view.value.error));
|
|
2052
2513
|
}
|
|
2053
2514
|
return;
|
|
2054
2515
|
}
|
|
@@ -2078,11 +2539,11 @@ export function ReaderPane(props) {
|
|
|
2078
2539
|
// fetch paths set it from the host's answer, this one computes it locally.
|
|
2079
2540
|
const count = extracted.scriptFigures ?? 0;
|
|
2080
2541
|
setScriptFigures(count);
|
|
2081
|
-
maybeAutoRender(row.entry.id, row.entry.link, count);
|
|
2542
|
+
maybeAutoRender(row.entry.id, row.entry.link, count > 0);
|
|
2082
2543
|
}
|
|
2083
2544
|
else
|
|
2084
2545
|
actions.setArticle('', truncated, extracted.error);
|
|
2085
|
-
}, [actions, props, t, fetchThenMaybeRender, persistFeedBody, recent, fetchStates, syncFetchState, maybeAutoRender]);
|
|
2546
|
+
}, [actions, props, t, fetchThenMaybeRender, persistFeedBody, recent, fetchStates, syncFetchState, maybeAutoRender, setTranscriptState]);
|
|
2086
2547
|
/* ------------------------------ coming back to where the reader already was */
|
|
2087
2548
|
/**
|
|
2088
2549
|
* Re-open the article the pane had on screen when the sidebar went away.
|
|
@@ -2430,6 +2891,11 @@ export function ReaderPane(props) {
|
|
|
2430
2891
|
return;
|
|
2431
2892
|
if (translatePhase !== 'idle')
|
|
2432
2893
|
return;
|
|
2894
|
+
// A transcript still arriving replaces the body every poll: restoring on
|
|
2895
|
+
// each would re-translate (and re-raise its bar) over and over. The
|
|
2896
|
+
// record stays; the finished transcript gets it once.
|
|
2897
|
+
if (localActive)
|
|
2898
|
+
return;
|
|
2433
2899
|
if (restoredTranslationRef.current === openEntryId)
|
|
2434
2900
|
return;
|
|
2435
2901
|
const wanted = readSession().translationView?.[openEntryId];
|
|
@@ -2437,62 +2903,92 @@ export function ReaderPane(props) {
|
|
|
2437
2903
|
return;
|
|
2438
2904
|
const source = readSession().translationSource?.[openEntryId] ?? translationSource;
|
|
2439
2905
|
const sources = source === 'en' ? ['en'] : [source, 'en'];
|
|
2440
|
-
|
|
2906
|
+
// A stored translation needs no session (and no activation) to be shown.
|
|
2907
|
+
if (translator === null ? storedPair === null : cachedTranslator(sources, TARGET_CANDIDATES) === undefined)
|
|
2441
2908
|
return;
|
|
2442
2909
|
restoredTranslationRef.current = openEntryId;
|
|
2443
2910
|
void startTranslation(wanted, source);
|
|
2444
|
-
}, [view, openEntryId, articleHtml, translatePhase, translationSource, startTranslation]);
|
|
2911
|
+
}, [view, openEntryId, articleHtml, translatePhase, translationSource, translator, storedPair, startTranslation, localActive]);
|
|
2912
|
+
/**
|
|
2913
|
+
* 转完自动翻译: a whole transcript (platform captions, or a finished local
|
|
2914
|
+
* one) in another language opens translated, side by side, when the reader
|
|
2915
|
+
* ticked the preference. Only when the pair is already on the device:
|
|
2916
|
+
* building a session that must download needs a click (user activation),
|
|
2917
|
+
* and an automatic start must never fail at the reader.
|
|
2918
|
+
*/
|
|
2919
|
+
const autoTranslatedRef = useRef(null);
|
|
2920
|
+
useEffect(() => {
|
|
2921
|
+
if (!autoTranslate || view !== 'detail' || openEntry?.media === undefined || openEntryId === null)
|
|
2922
|
+
return;
|
|
2923
|
+
if (mediaFace.transcript !== 'ready' || localActive || !translationOffered || translatePhase !== 'idle')
|
|
2924
|
+
return;
|
|
2925
|
+
if (translator !== null && translateAvailability !== 'available')
|
|
2926
|
+
return;
|
|
2927
|
+
if (autoTranslatedRef.current === openEntryId)
|
|
2928
|
+
return;
|
|
2929
|
+
autoTranslatedRef.current = openEntryId;
|
|
2930
|
+
void startTranslation('both');
|
|
2931
|
+
}, [autoTranslate, view, openEntry, openEntryId, mediaFace.transcript, localActive, translationOffered, translatePhase, translator, translateAvailability, startTranslation]);
|
|
2445
2932
|
/** Submit the add form and report the host's verdict. */
|
|
2446
2933
|
const submit = useCallback(async () => {
|
|
2447
2934
|
const url = draftUrl.trim();
|
|
2448
|
-
|
|
2935
|
+
// The await below chains the resolver APIs, the fetch, and its fallback —
|
|
2936
|
+
// seconds to half a minute. Without the busy line (and this re-entry guard)
|
|
2937
|
+
// the dialog read as a dead click the whole time.
|
|
2938
|
+
if (url.length === 0 || addBusySince !== null)
|
|
2449
2939
|
return;
|
|
2450
2940
|
setVerdict(null);
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2941
|
+
setAddBusySince(Date.now());
|
|
2942
|
+
try {
|
|
2943
|
+
const result = await props.addSource(url);
|
|
2944
|
+
if (!result.ok) {
|
|
2945
|
+
// A transport-level failure: the host never answered, so the reason is
|
|
2946
|
+
// whatever the wire layer said.
|
|
2947
|
+
setVerdict({ kind: 'fetchFailed', reason: result.error.message });
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
const value = result.value;
|
|
2951
|
+
if (typeof value === 'string') {
|
|
2952
|
+
setVerdict({ kind: verdictForRefusal(value) });
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
if (value.outcome === 'fetch-failed') {
|
|
2956
|
+
// The fetch failed with a reason of its own ("connect ECONNREFUSED …",
|
|
2957
|
+
// "body over the seam's byte cap"). Showing it is the difference between
|
|
2958
|
+
// a retry and a report nobody can act on.
|
|
2959
|
+
setVerdict({ kind: 'fetchFailed', reason: value.reason });
|
|
2960
|
+
return;
|
|
2961
|
+
}
|
|
2962
|
+
if (value.failure !== undefined) {
|
|
2963
|
+
// Saved, but there is no body to preview. This is NOT a failure verdict:
|
|
2964
|
+
// the link is in the wall, and the sentence names the reason so the
|
|
2965
|
+
// reader knows whether it is worth opening in a browser. The URL rides
|
|
2966
|
+
// along: for the causes a real browser solves, the verdict itself offers
|
|
2967
|
+
// the way out (the moment "why" lands is the moment to act on it).
|
|
2968
|
+
setVerdict({
|
|
2969
|
+
kind: 'savedLinkNoPreview',
|
|
2970
|
+
label: value.label,
|
|
2971
|
+
reason: value.failure.message,
|
|
2972
|
+
code: value.failure.code,
|
|
2973
|
+
url,
|
|
2974
|
+
});
|
|
2975
|
+
setDraftUrl('');
|
|
2976
|
+
actions.refresh();
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2476
2979
|
setVerdict({
|
|
2477
|
-
kind: '
|
|
2980
|
+
kind: value.outcome === 'subscribed' ? 'subscribed' : 'savedLink',
|
|
2478
2981
|
label: value.label,
|
|
2479
|
-
reason: value.failure.message,
|
|
2480
|
-
code: value.failure.code,
|
|
2481
|
-
url,
|
|
2482
2982
|
});
|
|
2483
2983
|
setDraftUrl('');
|
|
2984
|
+
// Reload so the new entry is on the wall behind the dialog, not only in
|
|
2985
|
+
// the host's state.
|
|
2484
2986
|
actions.refresh();
|
|
2485
|
-
return;
|
|
2486
2987
|
}
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
setDraftUrl('');
|
|
2492
|
-
// Reload so the new entry is on the wall behind the dialog, not only in
|
|
2493
|
-
// the host's state.
|
|
2494
|
-
actions.refresh();
|
|
2495
|
-
}, [actions, draftUrl, props]);
|
|
2988
|
+
finally {
|
|
2989
|
+
setAddBusySince(null);
|
|
2990
|
+
}
|
|
2991
|
+
}, [actions, addBusySince, draftUrl, props]);
|
|
2496
2992
|
/** Re-fetch one source from the subscription page. */
|
|
2497
2993
|
const refreshOne = useCallback(async (id) => {
|
|
2498
2994
|
actions.setRefreshing(true);
|
|
@@ -2666,10 +3162,10 @@ export function ReaderPane(props) {
|
|
|
2666
3162
|
// stores the payload first, so leaving the page does not cancel it. It also
|
|
2667
3163
|
// re-reads this entry's state from the host when the fetch settles, so the
|
|
2668
3164
|
// pill flips without a full-wall poll here.
|
|
2669
|
-
const
|
|
3165
|
+
const wanted = await fetchBody(entry.id, entry.link);
|
|
2670
3166
|
// One gesture, the whole pipeline: a shell answer continues into the
|
|
2671
3167
|
// rendered fetch on its own (see maybeAutoRender).
|
|
2672
|
-
maybeAutoRender(entry.id, entry.link,
|
|
3168
|
+
maybeAutoRender(entry.id, entry.link, wanted);
|
|
2673
3169
|
}, [actions, fetchBody, maybeAutoRender]);
|
|
2674
3170
|
/** Reload the tags on one entry. */
|
|
2675
3171
|
const loadEntryTags = useCallback(async (entryId) => {
|
|
@@ -2763,19 +3259,6 @@ export function ReaderPane(props) {
|
|
|
2763
3259
|
setTagInputOpen(false);
|
|
2764
3260
|
await toggleTag(entryId, created.value.id, true);
|
|
2765
3261
|
}, [props, toggleTag]);
|
|
2766
|
-
/** Quote the open entry (or a selection) into the conversation draft. */
|
|
2767
|
-
const quote = useCallback((explicit) => {
|
|
2768
|
-
if (openEntry === undefined)
|
|
2769
|
-
return;
|
|
2770
|
-
const selection = explicit ?? window.getSelection()?.toString() ?? '';
|
|
2771
|
-
const source = presentation.get(openEntry.sourceId);
|
|
2772
|
-
const block = formatReaderRef(selection, provenanceOf(openEntry, {
|
|
2773
|
-
label: source?.label ?? openEntry.title,
|
|
2774
|
-
}));
|
|
2775
|
-
const next = mergedDraft(props.readDraft(), block);
|
|
2776
|
-
props.setDraft(next);
|
|
2777
|
-
setDraft(next);
|
|
2778
|
-
}, [openEntry, presentation, props]);
|
|
2779
3262
|
const copyLink = useCallback(async () => {
|
|
2780
3263
|
if (openEntry?.link === undefined)
|
|
2781
3264
|
return;
|
|
@@ -2797,6 +3280,115 @@ export function ReaderPane(props) {
|
|
|
2797
3280
|
props.openExternal(url);
|
|
2798
3281
|
}, [props]);
|
|
2799
3282
|
/* ------------------------------------------------------------------ render */
|
|
3283
|
+
/** The wall's translation switch (the globe in the wide header, a ⋯ row in the compact one). */
|
|
3284
|
+
const toggleWall = () => {
|
|
3285
|
+
setSortOpen(false);
|
|
3286
|
+
setFilterOpen(false);
|
|
3287
|
+
if (wallOn) {
|
|
3288
|
+
setWallOn(false);
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
wallCancelRef.current = { cancelled: false };
|
|
3292
|
+
setWallOn(true);
|
|
3293
|
+
// Cards already on screen are queued by the observer effect; a
|
|
3294
|
+
// wall that never scrolls still gets translated in one pass.
|
|
3295
|
+
void runWallPass();
|
|
3296
|
+
};
|
|
3297
|
+
/**
|
|
3298
|
+
* The wall's filter panel and sort menu. Built once and placed by whichever
|
|
3299
|
+
* header is showing: the wide header hangs each under its own button; the
|
|
3300
|
+
* compact header has no such buttons (sort lives in ⋯, the filter behind the
|
|
3301
|
+
* chip row's 「筛选」), so both hang under the compact header's right edge.
|
|
3302
|
+
*/
|
|
3303
|
+
const filterPanel = filterOpen && (_jsx("div", { className: css.filterPanel, onKeyDown: event => { if (event.key === 'Escape')
|
|
3304
|
+
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 => {
|
|
3305
|
+
const group = parsed[source.id];
|
|
3306
|
+
const broken = group?.error !== undefined || group?.incomplete === true;
|
|
3307
|
+
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
|
|
3308
|
+
? _jsx("span", { className: css.chipBroken, title: t('state.incomplete'), children: "!" })
|
|
3309
|
+
: _jsx("span", { className: css.filterCount, children: group?.entries.length ?? 0 })] }, source.id));
|
|
3310
|
+
}), 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))), ['video', 'audio'].filter(kind => mediaCounts[kind] > 0).map(kind => (_jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(MEDIA_QUERIES[kind]); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: query.trim() === MEDIA_QUERIES[kind] ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t(kind === 'video' ? 'media.kindVideo' : 'media.kindAudio') }), _jsx("span", { className: css.filterCount, children: mediaCounts[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)))] })) }));
|
|
3311
|
+
const sortMenu = 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))) }));
|
|
3312
|
+
/**
|
|
3313
|
+
* The compact wall header (panel-responsive-spec P2): the pane's name with
|
|
3314
|
+
* its unread count, search and add always visible, and everything else —
|
|
3315
|
+
* refresh, sort, the wall's translation switch, history, subscriptions — in
|
|
3316
|
+
* one ⋯ menu. Sort and filter have no buttons of their own here, so their
|
|
3317
|
+
* popovers hang under the header's right edge.
|
|
3318
|
+
*/
|
|
3319
|
+
const searchVisible = searchOpen || query !== '';
|
|
3320
|
+
const compactActions = [
|
|
3321
|
+
{
|
|
3322
|
+
id: 'search', label: t('search.placeholder'), icon: glyph('search', 16), pinned: true, pressed: searchVisible,
|
|
3323
|
+
onSelect: () => { if (searchVisible) {
|
|
3324
|
+
setSearchOpen(false);
|
|
3325
|
+
actions.setQuery('');
|
|
3326
|
+
}
|
|
3327
|
+
else
|
|
3328
|
+
setSearchOpen(true); },
|
|
3329
|
+
},
|
|
3330
|
+
{ id: 'add', label: t('action.add'), icon: _jsx(IconPlusOutlineMedium, { size: 16 }), pinned: true, onSelect: () => { setAddOpen(true); setVerdict(null); } },
|
|
3331
|
+
{ id: 'refresh', label: t('action.refresh'), icon: _jsx(IconRefreshOutlineMedium, { size: 16 }), disabled: refreshing, onSelect: () => { void refreshAll(); } },
|
|
3332
|
+
{
|
|
3333
|
+
id: 'sort', label: `${t('sort.title')} · ${t(`sort.${sort}`)}`, icon: glyph('sort', 16),
|
|
3334
|
+
onSelect: () => { setFilterOpen(false); setSortOpen(open => !open); },
|
|
3335
|
+
},
|
|
3336
|
+
...(wallTranslateOffered
|
|
3337
|
+
? [{ id: 'translate', label: t(wallOn ? 'translate.wallOff' : 'action.translate'), icon: _jsx(IconGlobeOutlineMedium, { size: 16 }), onSelect: toggleWall }]
|
|
3338
|
+
: []),
|
|
3339
|
+
{ id: 'recent', label: t('action.recent'), icon: _jsx(IconClockOutlineMedium, { size: 16 }), onSelect: () => { actions.closeEntry(); actions.setView('recent'); } },
|
|
3340
|
+
{ id: 'manage', label: t('action.manage'), icon: _jsx(IconSettingsOutlineMedium, { size: 16 }), onSelect: () => { actions.closeEntry(); actions.setView('manage'); } },
|
|
3341
|
+
];
|
|
3342
|
+
const compactWallHeader = (_jsxs("div", { className: css.compactHead, children: [_jsx(PanelHeader, { title: t('tab.label'), meta: `${countUnread(displayRows)} ${t('foot.unread')}`, actions: compactActions, moreLabel: t('action.more') }), _jsxs("span", { className: css.compactPop, children: [filterPanel, sortMenu] })] }));
|
|
3343
|
+
/**
|
|
3344
|
+
* The compact wall's filter chips: the narrowings a reader reaches for most
|
|
3345
|
+
* — everything, unread, the two source kinds, the reader's own tags — one tap
|
|
3346
|
+
* each, and 「筛选」 for the rest (one source, hiding duplicates) in the same
|
|
3347
|
+
* panel the wide header opens. Every chip but 未读 sets the search query (the
|
|
3348
|
+
* wall's one narrowing channel, `#source` / `@tag` included), so tapping a
|
|
3349
|
+
* pressed chip clears it and 全部 clears everything.
|
|
3350
|
+
*/
|
|
3351
|
+
const wallChips = [
|
|
3352
|
+
{ id: 'all', label: t('filter.all'), count: allEntries.length },
|
|
3353
|
+
{ id: 'unread', label: t('chip.unread') },
|
|
3354
|
+
...READER_SOURCE_KINDS.map(kind => ({ id: `kind:${kind}`, label: t(kind === 'link' ? 'sources.kindLink' : 'sources.kindRss'), count: kindCounts[kind] })),
|
|
3355
|
+
...['video', 'audio'].filter(kind => mediaCounts[kind] > 0)
|
|
3356
|
+
.map(kind => ({ id: `media:${kind}`, label: t(kind === 'video' ? 'media.kindVideo' : 'media.kindAudio'), count: mediaCounts[kind] })),
|
|
3357
|
+
...tags.map(tag => ({ id: `tag:${tag.id}`, label: tag.name, count: tagCounts[tag.id] ?? 0 })),
|
|
3358
|
+
{ id: 'more', label: activeSource === null ? t('chip.more') : `${t('chip.more')} · ${activeSourceLabel ?? ''}` },
|
|
3359
|
+
];
|
|
3360
|
+
const wallChipValue = [
|
|
3361
|
+
...(query.trim() === '' && !unreadOnly ? ['all'] : []),
|
|
3362
|
+
...(unreadOnly ? ['unread'] : []),
|
|
3363
|
+
...(activeKind === null ? [] : [`kind:${activeKind}`]),
|
|
3364
|
+
...['video', 'audio'].filter(kind => query.trim() === MEDIA_QUERIES[kind]).map(kind => `media:${kind}`),
|
|
3365
|
+
...tags.filter(tag => query.trim() === tagQuery(tag.id)).map(tag => `tag:${tag.id}`),
|
|
3366
|
+
...(activeSource === null ? [] : ['more']),
|
|
3367
|
+
];
|
|
3368
|
+
const pickChip = (id) => {
|
|
3369
|
+
if (id === 'all') {
|
|
3370
|
+
actions.setQuery('');
|
|
3371
|
+
if (unreadOnly)
|
|
3372
|
+
actions.toggleUnreadOnly();
|
|
3373
|
+
return;
|
|
3374
|
+
}
|
|
3375
|
+
if (id === 'unread') {
|
|
3376
|
+
actions.toggleUnreadOnly();
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
if (id === 'more') {
|
|
3380
|
+
setSortOpen(false);
|
|
3381
|
+
if (!filterOpen) {
|
|
3382
|
+
setFilterPage('root');
|
|
3383
|
+
setSourceFilter('');
|
|
3384
|
+
}
|
|
3385
|
+
setFilterOpen(!filterOpen);
|
|
3386
|
+
return;
|
|
3387
|
+
}
|
|
3388
|
+
const next = id.startsWith('media:') ? MEDIA_QUERIES[id.slice('media:'.length)]
|
|
3389
|
+
: id.startsWith('kind:') ? kindQuery(id.slice('kind:'.length)) : tagQuery(id.slice('tag:'.length));
|
|
3390
|
+
actions.setQuery(query.trim() === next ? '' : next);
|
|
3391
|
+
};
|
|
2800
3392
|
/**
|
|
2801
3393
|
* The pane's header.
|
|
2802
3394
|
*
|
|
@@ -2814,26 +3406,7 @@ export function ReaderPane(props) {
|
|
|
2814
3406
|
setSourceFilter('');
|
|
2815
3407
|
}
|
|
2816
3408
|
setFilterOpen(!filterOpen);
|
|
2817
|
-
}, children: glyph('funnel', 15) }),
|
|
2818
|
-
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 => {
|
|
2819
|
-
const group = parsed[source.id];
|
|
2820
|
-
const broken = group?.error !== undefined || group?.incomplete === true;
|
|
2821
|
-
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
|
|
2822
|
-
? _jsx("span", { className: css.chipBroken, title: t('state.incomplete'), children: "!" })
|
|
2823
|
-
: _jsx("span", { className: css.filterCount, children: group?.entries.length ?? 0 })] }, source.id));
|
|
2824
|
-
}), 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: () => {
|
|
2825
|
-
setSortOpen(false);
|
|
2826
|
-
setFilterOpen(false);
|
|
2827
|
-
if (wallOn) {
|
|
2828
|
-
setWallOn(false);
|
|
2829
|
-
return;
|
|
2830
|
-
}
|
|
2831
|
-
wallCancelRef.current = { cancelled: false };
|
|
2832
|
-
setWallOn(true);
|
|
2833
|
-
// Cards already on screen are queued by the observer effect; a
|
|
2834
|
-
// wall that never scrolls still gets translated in one pass.
|
|
2835
|
-
void runWallPass();
|
|
2836
|
-
}, 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 }) })] }));
|
|
3409
|
+
}, children: glyph('funnel', 15) }), filterPanel] }), _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) }), sortMenu] }), 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: toggleWall, 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 }) })] }));
|
|
2837
3410
|
/** Load the states once the wall knows which entries it is showing. */
|
|
2838
3411
|
useEffect(() => { void refreshFetchStates(); }, [refreshFetchStates, rev]);
|
|
2839
3412
|
/**
|
|
@@ -2861,12 +3434,12 @@ export function ReaderPane(props) {
|
|
|
2861
3434
|
/** The add dialog: overlay + centered card, Esc and overlay-click close. */
|
|
2862
3435
|
const dialog = addOpen && (_jsx("div", { className: css.overlay, role: "presentation", onClick: event => { if (event.target === event.currentTarget)
|
|
2863
3436
|
setAddOpen(false); }, children: _jsxs("div", { className: css.dialog, role: "dialog", "aria-modal": "true", "aria-label": t('add.title'), onKeyDown: event => { if (event.key === 'Escape')
|
|
2864
|
-
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')
|
|
2865
|
-
void submit(); } }), verdict !== null && (_jsxs("div", { className: css.verdict, children: [_jsx("b", { children: t(VERDICT_KEY[verdict.kind], verdict.kind === 'savedLinkNoPreview'
|
|
3437
|
+
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'), disabled: addBusySince !== null, onChange: event => setDraftUrl(event.target.value), onKeyDown: event => { if (event.key === 'Enter')
|
|
3438
|
+
void submit(); } }), addBusySince !== null && _jsx(BusyLine, { t: t, label: t('add.busy'), since: addBusySince }), verdict !== null && (_jsxs("div", { className: css.verdict, children: [_jsx("b", { children: t(VERDICT_KEY[verdict.kind], verdict.kind === 'savedLinkNoPreview'
|
|
2866
3439
|
? { label: verdict.label ?? '', reason: previewReason(t, verdict.code, verdict.reason ?? '') }
|
|
2867
|
-
: 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')
|
|
3440
|
+
: verdict.label === undefined ? {} : { label: verdict.label }) }), verdict.reason !== undefined && verdict.kind === 'fetchFailed' && (_jsx("span", { className: css.verdictReason, children: verdict.reason })), verdict.code === 'blocked' && captureRemote !== undefined && (_jsx("span", { className: css.verdictReason, children: t('verdict.willRender') })), 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')
|
|
2868
3441
|
? t('action.done')
|
|
2869
|
-
: t('action.cancel') }), _jsx("button", { type: "button", className: css.submit, onClick: () => { void submit(); }, children: t('action.submit') })] })] }) }));
|
|
3442
|
+
: t('action.cancel') }), _jsx("button", { type: "button", className: css.submit, disabled: addBusySince !== null || draftUrl.trim() === '', onClick: () => { void submit(); }, children: addBusySince !== null ? t('add.fetching') : t('action.submit') })] })] }) }));
|
|
2870
3443
|
/* ------------------------------------------------------------- detail view */
|
|
2871
3444
|
if (view === 'detail' && (openEntry !== undefined || openEntryId !== null)) {
|
|
2872
3445
|
/**
|
|
@@ -2881,9 +3454,25 @@ export function ReaderPane(props) {
|
|
|
2881
3454
|
if (openEntry === undefined) {
|
|
2882
3455
|
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') }) })] }));
|
|
2883
3456
|
}
|
|
3457
|
+
/**
|
|
3458
|
+
* The title follows the body's translation: 译文 shows it translated,
|
|
3459
|
+
* 对照 translated with the original beneath, 原文 (or no translation) as
|
|
3460
|
+
* the source wrote it. The header bar carries the shown one.
|
|
3461
|
+
*/
|
|
3462
|
+
const translatedTitle = titleTranslation?.id === openEntry.id && translateView !== 'orig'
|
|
3463
|
+
&& (translatePhase === 'ready' || translatePhase === 'working')
|
|
3464
|
+
? titleTranslation.text
|
|
3465
|
+
: undefined;
|
|
3466
|
+
const shownTitle = translatedTitle ?? openEntry.title;
|
|
2884
3467
|
const source = presentation.get(openEntry.sourceId);
|
|
2885
3468
|
/** The host's own row for this entry's source: the failure code lives here. */
|
|
2886
3469
|
const sourceSummary = sources.find(item => item.id === openEntry.sourceId);
|
|
3470
|
+
/**
|
|
3471
|
+
* The plain fetch met a wall a headless browser gets past, and the
|
|
3472
|
+
* rendered fetch is mounted: the error line is that render's slot.
|
|
3473
|
+
*/
|
|
3474
|
+
const wallRenderable = captureRemote !== undefined && openEntry.link !== undefined && articleError !== null
|
|
3475
|
+
&& (browserSolvable(articleError) || sourceSummary?.failure?.code === 'blocked' || captureFailure?.entryId === openEntry.id);
|
|
2887
3476
|
const when = relativeWhen(openEntry.publishedAt, new Date());
|
|
2888
3477
|
const date = absoluteDate(openEntry.publishedAt);
|
|
2889
3478
|
const alsoFrom = allEntries
|
|
@@ -2895,27 +3484,117 @@ export function ReaderPane(props) {
|
|
|
2895
3484
|
: translateProgress !== null && translateProgress.total > 0
|
|
2896
3485
|
? Math.round((translateProgress.done / translateProgress.total) * 100)
|
|
2897
3486
|
: 0;
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
3487
|
+
/*
|
|
3488
|
+
* The globe is a switch, not a verb: lit means this body is showing a
|
|
3489
|
+
* translation, dark means it is showing the original — and the caret holds
|
|
3490
|
+
* the view (translation only / side by side / original). Everything is
|
|
3491
|
+
* hidden when the browser has no Translator API (and no stored translation
|
|
3492
|
+
* of this body), the pair is unavailable, or the body is already Chinese:
|
|
3493
|
+
* an offer that cannot work is worse than no offer.
|
|
3494
|
+
*/
|
|
3495
|
+
const detailTranslate = translationOffered && localActive ? (
|
|
3496
|
+
// A transcript still arriving is rewritten every poll: a translation
|
|
3497
|
+
// would be wiped, so the globe waits for the whole transcript.
|
|
3498
|
+
_jsx("span", { className: css.translateWrap, children: _jsx("button", { type: "button", className: css.tool, disabled: true, "aria-disabled": "true", title: t('media.translateAfter'), children: _jsx(IconGlobeOutlineMedium, { size: 15 }) }) })) : 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(translator === null ? 'translate.stored' : '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))), _jsxs("button", { type: "button", role: "menuitem", className: css.translateMenuItem, onClick: () => { setTranslateMenu(false); requestPolish(); }, children: [_jsx("span", { className: css.translateCheck }), _jsx("span", { className: css.translateMenuLabel, children: t('polish.action') })] }), openEntry.media !== undefined && (_jsxs("button", { type: "button", role: "menuitemcheckbox", "aria-checked": autoTranslate, className: css.translateMenuItem, onClick: () => { setAutoTranslatePref(!autoTranslate); }, children: [_jsx("span", { className: css.translateCheck, children: autoTranslate ? '✓' : '' }), _jsx("span", { className: css.translateMenuLabel, children: t('media.autoTranslate') })] })), translator !== null && (translatePhase === 'ready' || translatePhase === 'failed') && (_jsxs("button", { type: "button", role: "menuitem", className: css.translateMenuItem, onClick: () => {
|
|
3499
|
+
setTranslateMenu(false);
|
|
3500
|
+
if (builtRef.current !== null)
|
|
3501
|
+
restoreArticle(builtRef.current.root);
|
|
3502
|
+
builtRef.current = null;
|
|
3503
|
+
void startTranslation(lastViewRef.current);
|
|
3504
|
+
}, children: [_jsx("span", { className: css.translateCheck }), _jsx("span", { className: css.translateMenuLabel, children: t('translate.retry') })] }))] }))] }));
|
|
3505
|
+
/** The detail's ordinary actions: inline in the wide bar, in ⋯ on a compact one. */
|
|
3506
|
+
/** A foreign body the globe does not offer (no on-device translator): the agent needs none. */
|
|
3507
|
+
const polishOutsideGlobe = !translationOffered && articleHtml !== null && articleHtml.length > 0 && !isTargetLanguage(articleHtml.slice(0, 2000));
|
|
3508
|
+
const detailActions = [
|
|
3509
|
+
// Also here, for a body the globe does not offer (no on-device translator): the agent needs none.
|
|
3510
|
+
...(polishOutsideGlobe
|
|
3511
|
+
? [{ id: 'polish', label: t('polish.action'), icon: _jsx(IconGlobeOutlineMedium, { size: 16 }), menuOnly: true, onSelect: () => { requestPolish(); } }]
|
|
3512
|
+
: []),
|
|
3513
|
+
{ id: 'copy', label: t('action.copyLink'), icon: _jsx(IconCopyOutlineMedium, { size: 16 }), menuOnly: true, onSelect: () => { void copyLink(); } },
|
|
3514
|
+
{
|
|
3515
|
+
id: 'open', label: t('action.openExternal'), icon: _jsx(IconRightUpOutlineMedium, { size: 16 }), menuOnly: true,
|
|
3516
|
+
onSelect: () => { if (openEntry.link !== undefined)
|
|
3517
|
+
props.openExternal(openEntry.link); },
|
|
3518
|
+
},
|
|
3519
|
+
...(sourceSummary?.kind === 'link'
|
|
3520
|
+
? [{ id: 'remove', label: t('detail.removeLink'), icon: _jsx(IconTrashOutlineMedium, { size: 16 }), menuOnly: true, danger: true, onSelect: () => { void removeLink(openEntry.sourceId); } }]
|
|
3521
|
+
: []),
|
|
3522
|
+
];
|
|
3523
|
+
return (
|
|
3524
|
+
// data-quote-source: a passage quoted from here names this article, not
|
|
3525
|
+
// the conversation the pane happens to sit beside (the quote plugin
|
|
3526
|
+
// reads it; without that plugin it is inert).
|
|
3527
|
+
_jsxs("div", { className: css.root, "data-quote-source": shownTitle, children: [compact ? (
|
|
3528
|
+
// No title at first: the article's own heading sits right below with
|
|
3529
|
+
// its source and date in the kicker, so a title here would say it
|
|
3530
|
+
// twice. Once that heading scrolls away, the bar takes the title.
|
|
3531
|
+
_jsx(PanelHeader, { className: css.compactBar, title: titleScrolledAway ? _jsx("span", { className: css.compactBarTitle, children: shownTitle }) : '', onBack: () => { actions.setView('list'); actions.closeEntry(); }, backLabel: t('action.back'), extra: detailTranslate, actions: detailActions, moreLabel: t('action.more') })) : _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, title: translatedTitle === undefined ? undefined : openEntry.title, children: shownTitle }), _jsx("span", { className: css.spacer }), detailTranslate, polishOutsideGlobe && (_jsx("button", { type: "button", className: css.tool, title: t('polish.action'), onClick: () => { requestPolish(); }, children: _jsx(IconGlobeOutlineMedium, { size: 15 }) })), _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)
|
|
3532
|
+
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 }) }))] }), mediaFace.media !== undefined && (_jsx(MediaPlayer, { ref: mediaFace.playerRef, media: mediaFace.media, title: openEntry.title, startAt: mediaFace.startAt, onTime: mediaFace.onTime, mini: mediaFace.mini, onMini: mediaFace.setMini, labels: {
|
|
3533
|
+
collapse: t('media.collapse'), expand: t('media.expand'), noFollow: t('media.noFollow'),
|
|
3534
|
+
play: t('media.play'), pause: t('media.pause'), startedAt: t('media.startedAt'),
|
|
3535
|
+
}, leading: (mediaFace.transcript === 'ready' || mediaFace.transcript === 'loading' || localActive || (mediaFace.transcript === 'none' && mediaFace.hidden)) && (_jsxs("label", { className: css.mediaSwitch, children: [_jsx("input", { type: "checkbox", checked: !mediaFace.hidden, onChange: event => {
|
|
3536
|
+
mediaFace.setHidden(!event.target.checked);
|
|
3537
|
+
// Back on with nothing to read: transcribe again.
|
|
3538
|
+
if (event.target.checked && mediaFace.transcript === 'none' && !localActive)
|
|
3539
|
+
localFace.retry();
|
|
3540
|
+
} }), t('media.captions')] })), trailing: localFace.status !== undefined && localActive
|
|
3541
|
+
? (_jsxs(_Fragment, { children: [_jsxs("span", { className: css.mediaSource, children: [localFace.status.state === 'fetching' ? t('media.localFetching')
|
|
3542
|
+
: localFace.status.state === 'queued' ? (localFace.status.ahead !== undefined && localFace.status.ahead > 0 ? t('media.localQueuedAhead', { count: localFace.status.ahead }) : t('media.localQueued'))
|
|
3543
|
+
: t('media.localRunning', { percent: Math.round((localFace.status.progress ?? 0) * 100) }), localFace.etaSeconds !== undefined && ` · ${t('media.localEta', { minutes: Math.max(1, Math.round(localFace.etaSeconds / 60)) })}`, ' ', _jsx("button", { type: "button", className: css.mediaLink, onClick: localFace.stop, children: t('media.localStop') })] }), _jsxs("label", { className: css.mediaSwitch, children: [_jsx("input", { type: "checkbox", checked: autoTranslate, onChange: event => { setAutoTranslatePref(event.target.checked); } }), t('media.autoTranslate')] })] }))
|
|
3544
|
+
: (_jsx("span", { className: css.mediaSource, children: mediaFace.transcript === 'ready' ? (articleHtml?.includes('data-src="local"') === true
|
|
3545
|
+
? localFace.status?.state === 'speakers'
|
|
3546
|
+
? _jsxs(_Fragment, { children: [t('media.sourceLocal'), " \u00B7 ", t('media.speakersRunning')] })
|
|
3547
|
+
: _jsxs(_Fragment, { children: [t('media.sourceLocal'), " \u00B7 ", _jsx("button", { type: "button", className: css.mediaLink, onClick: localFace.retry, children: t('media.localRetry') })] })
|
|
3548
|
+
: t('media.sourcePlatform'))
|
|
3549
|
+
: mediaFace.transcript === 'loading' ? t('media.loading')
|
|
3550
|
+
: mediaFace.transcript === 'failed' ? t('media.failed')
|
|
3551
|
+
: mediaFace.transcript === 'none' && !mediaFace.hidden ? (localFace.status?.state === 'unavailable' ? (mediaFace.media.kind === 'audio' ? t('media.noCaptionsAudio') : t('media.noCaptions'))
|
|
3552
|
+
: localFace.status?.state === 'failed' ? (_jsxs(_Fragment, { children: [t('media.localFailed', { error: localFace.status.error ?? '' }), " ", _jsx("button", { type: "button", className: css.mediaLink, onClick: localFace.retry, children: t('media.localRetry') })] }))
|
|
3553
|
+
: localFace.status?.state === 'idle' ? (_jsxs(_Fragment, { children: [t('media.localStopped'), " ", _jsx("button", { type: "button", className: css.mediaLink, onClick: localFace.retry, children: t('media.localRetry') })] })) : '') : '' })) }, openEntry.id)), mediaFace.media !== undefined && hasSpeakers && !mediaFace.hidden && !mediaFace.mini && (_jsx(SpeakerLegend, { count: speakerCount, label: speakerLabel, onRename: renameSpeaker, hint: t('media.speakerHint'), renameTitle: t('media.speakerRename') })), mediaFace.media !== undefined && localFace.status?.state === 'needs-models' && !mediaFace.hidden && (_jsxs("div", { className: css.mediaConfirm, children: [_jsx("span", { children: t('media.localModels', { size: Math.max(1, Math.round((localFace.status.bytes ?? 0) / 1e6)) }) }), _jsx("button", { type: "button", className: css.mediaConfirmButton, onClick: localFace.confirm, children: t('media.localModelsGo') })] })), !(mediaFace.media !== undefined && mediaFace.hidden && mediaFace.transcript === 'ready') && _jsx(UnderlineTabs, { className: css.detailTabs, label: t('notes.tabsLabel'), value: notesFace.tab, onChange: notesFace.setTab,
|
|
3554
|
+
// 考考我 is always in reach, not only at the end of a long body.
|
|
3555
|
+
trailing: articleHtml !== null && articleHtml.length > 0 && !localActive && (_jsx("button", { type: "button", className: css.checkChip, onClick: notesFace.check, title: t('notes.check'), children: t('notes.checkShort') })), tabs: [
|
|
3556
|
+
{ id: 'article', label: mediaFace.media === undefined ? t('notes.tabArticle') : t('media.tabTranscript') },
|
|
3557
|
+
{ id: 'notes', label: t('notes.tabNotes'), ...(notesFace.count > 0 ? { badge: notesFace.count } : {}) },
|
|
3558
|
+
] }), mediaFace.media !== undefined && mediaFace.hidden && mediaFace.transcript === 'ready' && _jsx("div", { className: css.mediaFill }), notesFace.tab === 'notes' && !(mediaFace.media !== undefined && mediaFace.hidden && mediaFace.transcript === 'ready') && (_jsx(NotesView, { t: t, entry: notesFace.entry, missing: notesFace.missing, sessionTitle: notesFace.sessionTitle, canOpenSession: notesFace.canOpenSession, onJump: notesFace.jump, onEdit: notesFace.edit, onDelete: notesFace.remove, onDiscuss: notesFace.discuss, onOpenSession: notesFace.openSession, onAddReply: notesFace.addReply, onEditReply: notesFace.editReply, onDeleteDiscussion: notesFace.deleteDiscussion, onJumpPara: notesFace.jumpPara, onCheck: notesFace.check, sync: notesFace.sync, ...(mediaFace.media === undefined ? {} : {
|
|
3559
|
+
onPlay: (note) => {
|
|
3560
|
+
notesFace.jump(note);
|
|
3561
|
+
if (note.anchor.time !== undefined)
|
|
3562
|
+
mediaFace.seek(note.anchor.time);
|
|
3563
|
+
},
|
|
3564
|
+
}) })), _jsxs("div", { className: css.detailBody, ref: detailRef, onScroll: onDetailScroll,
|
|
3565
|
+
// Hidden, not unmounted: the article's segmentation, its translation
|
|
3566
|
+
// and the reading position all live on this subtree. The 「字幕」
|
|
3567
|
+
// switch hides it the same way (just the video, no transcript).
|
|
3568
|
+
hidden: notesFace.tab === 'notes' || (mediaFace.media !== undefined && mediaFace.hidden && mediaFace.transcript === 'ready'), 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?:\/\//, '') })] }))] }), _jsxs("h1", { ref: detailTitleRef, className: css.detailTitle, children: [shownTitle, translatedTitle !== undefined && translateView === 'both' && (_jsx("span", { className: css.detailTitleOrig, 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 && openEntry.media === undefined && (_jsxs("button", { type: "button", className: css.refetch,
|
|
2906
3569
|
// A rendered entry's「重新抓取」re-renders through capture —
|
|
2907
3570
|
// captureBusy is that in-flight state, or the button looks
|
|
2908
3571
|
// dead for minutes.
|
|
2909
|
-
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 }), (
|
|
3572
|
+
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 }), mediaFace.media !== undefined && mediaFace.hintOwed && articleHtml?.includes('data-t=') === true && (_jsxs("div", { className: css.timeHint, children: [_jsx("span", { children: t(mediaFace.media.kind === 'video' ? 'media.timeHint' : 'media.timeHintAudio') }), _jsx("button", { type: "button", className: css.mediaLink, onClick: mediaFace.dismissHint, children: t('media.timeHintOk') })] })), polishFace.view !== undefined ? (_jsxs("div", { className: `${css.polishStatus} ${polishFace.view.complete ? css.polishDone : ''}`, children: [_jsx("span", { className: css.polishText, children: polishFace.view.complete ? t('polish.done', { done: polishFace.view.done })
|
|
3573
|
+
: polishFace.stalled ? t('polish.stalled', { done: polishFace.view.done, total: polishFace.view.total })
|
|
3574
|
+
: t('polish.running', { done: polishFace.view.done, total: polishFace.view.total }) }), !polishFace.view.complete && !polishFace.stalled && (_jsx("span", { className: css.translateBar, children: _jsx("i", { style: { width: `${String(Math.round((polishFace.view.done / Math.max(1, polishFace.view.total)) * 100))}%` } }) })), polishFace.stalled && (() => {
|
|
3575
|
+
const view = polishFace.view;
|
|
3576
|
+
let from = 1;
|
|
3577
|
+
while (from <= view.total && view.paragraphs[String(from)] !== undefined)
|
|
3578
|
+
from += 1;
|
|
3579
|
+
return (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { requestPolish(from); }, children: t('polish.continue', { count: view.total - view.done }) }));
|
|
3580
|
+
})(), _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { polishFace.setHidden(!polishFace.hidden); }, children: polishFace.hidden ? t('polish.showPolish') : t('polish.showMachine') })] })) : polishFace.awaiting && (_jsx("div", { className: css.polishStatus, children: _jsx("span", { className: css.polishText, children: t('polish.awaiting') }) })), (translatePhase === 'pack' || translatePhase === 'working') && (_jsxs("div", { className: css.translateStatus, children: [_jsx("span", { children: translatePhase === 'pack'
|
|
2910
3581
|
? t('translate.preparing')
|
|
2911
|
-
: _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(
|
|
2912
|
-
?
|
|
2913
|
-
: (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { void captureBody(openEntry.id, openEntry.link); }, children: t('detail.renderFetch') }))), ' ', openEntry.
|
|
3582
|
+
: _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(BusyLine, { t: t, label: bodyFetch?.entryId === openEntryId ? t(`detail.phase.${bodyFetch.phase}`) : t('detail.fetchingBody'), ...(bodyFetch?.entryId === openEntryId ? { since: bodyFetch.since } : {}) })), scriptFigures > 0 && (_jsxs("p", { className: css.incomplete, children: [t('detail.scriptFigures', { count: scriptFigures }), ' ', captureRemote !== undefined && openEntry.link !== undefined && (captureBusy
|
|
3583
|
+
? _jsxs("span", { className: css.renderingLine, children: [_jsx("span", { className: css.renderingRing, "aria-hidden": "true" }), t('detail.rendering')] })
|
|
3584
|
+
: (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { void captureBody(openEntry.id, openEntry.link); }, children: t('detail.renderFetch') }))), ' ', !captureBusy && captureFailure?.entryId === openEntry.id && (_jsxs("span", { children: [t('detail.renderFailed', { reason: captureFailure.message }), ' '] })), openEntry.link !== undefined && (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { openElsewhere(openEntry.link); }, children: browserTab ? t('action.openExternal') : t('detail.readOriginal') }))] })), articleError !== null && (wallRenderable && captureBusy
|
|
3585
|
+
? (
|
|
3586
|
+
// The open gesture already escalated: while the headless render
|
|
3587
|
+
// runs, the line says THAT (and how long it takes) instead of
|
|
3588
|
+
// leading with a refusal the reader cannot tell is being handled.
|
|
3589
|
+
_jsx("p", { className: css.incomplete, children: _jsxs("span", { className: css.renderingLine, children: [_jsx("span", { className: css.renderingRing, "aria-hidden": "true" }), t('detail.renderingWall')] }) }))
|
|
3590
|
+
: (_jsxs("p", { className: css.incomplete, children: [previewReason(t, sourceSummary?.failure?.code, articleError), captureFailure?.entryId === openEntry.id && (_jsxs(_Fragment, { children: [' ', t('detail.renderFailed', { reason: captureFailure.message })] })), wallRenderable && (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { void captureBody(openEntry.id, openEntry.link); }, children: t('detail.renderFetch') })] })), openEntry.link !== undefined && (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { openElsewhere(openEntry.link); }, children: t('detail.readOriginal') })] }))] }))), openEntry.summaryOnly === true && openEntry.media === undefined && articleHtml !== null && articleHtml === openEntry.contentHtml && (_jsx("p", { className: css.incomplete, children: t('detail.summaryOnly') })), articleHtml !== null && articleHtml.length > 0 && (_jsx("div", {
|
|
2914
3591
|
// The markup is the output of this package's own whitelist
|
|
2915
|
-
// normalizer (`extract-article`): scripts, styles, forms
|
|
2916
|
-
//
|
|
2917
|
-
//
|
|
2918
|
-
//
|
|
3592
|
+
// normalizer (`extract-article`): scripts, styles, forms and
|
|
3593
|
+
// event attributes are already gone, every URL that survives has
|
|
3594
|
+
// been scheme-checked, and a kept embed is a sandboxed
|
|
3595
|
+
// opaque-origin iframe or a controls-only media element.
|
|
3596
|
+
// Re-sanitizing here would mean a second implementation of the
|
|
3597
|
+
// same policy.
|
|
2919
3598
|
//
|
|
2920
3599
|
// `translate.ts` decorates THIS subtree in place when the reader
|
|
2921
3600
|
// asks for a translation (text nodes only), so the click handler
|
|
@@ -2923,8 +3602,8 @@ export function ReaderPane(props) {
|
|
|
2923
3602
|
ref: articleRef, onClick: onArticleClick, onMouseOver: onArticleHover, onMouseOut: onArticleHover,
|
|
2924
3603
|
// Image error events do not bubble; the capture phase is the
|
|
2925
3604
|
// only way one handler sees every failed figure in the body.
|
|
2926
|
-
onErrorCapture: onArticleImageError, 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)
|
|
2927
|
-
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
|
|
3605
|
+
onErrorCapture: onArticleImageError, className: `${css.article} ${isCjk(articleHtml) ? css.articleZh : css.articleEn}`, dangerouslySetInnerHTML: { __html: articleHtml } })), articleHtml !== null && articleHtml.length > 0 && (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)
|
|
3606
|
+
props.openExternal(openEntry.link); }, children: t('detail.readOriginal') })] })), articleHtml !== null && articleHtml.length > 0 && (_jsxs("div", { className: css.checkEnd, children: [_jsx("span", { className: css.checkLead, children: t('notes.checkLead') }), _jsxs("button", { type: "button", className: css.checkLink, onClick: notesFace.check, children: [t('notes.check'), " \u2192"] })] })), _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
|
|
2928
3607
|
? (_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),
|
|
2929
3608
|
// A suggestion's mousedown is suppressed, so this fires only
|
|
2930
3609
|
// when focus really left the editor.
|
|
@@ -2958,13 +3637,7 @@ export function ReaderPane(props) {
|
|
|
2958
3637
|
sourceKind: source?.kind ?? 'rss',
|
|
2959
3638
|
unread: read[entry.id] !== true,
|
|
2960
3639
|
});
|
|
2961
|
-
}, children: [_jsx("span", { className: css.alsoFromTitle, children: entry.title }), _jsx("span", { className: css.when, children: whenLabel(t, entry.publishedAt) })] }, entry.id)))] }))
|
|
2962
|
-
void props.quoteToSideChat({
|
|
2963
|
-
contextKey: sessionId,
|
|
2964
|
-
label: openEntry.title,
|
|
2965
|
-
text: formatReaderRef('', provenanceOf(openEntry, { label: source?.label ?? '' })),
|
|
2966
|
-
});
|
|
2967
|
-
}, children: t('quote.toSideChat') }))] })] }));
|
|
3640
|
+
}, children: [_jsx("span", { className: css.alsoFromTitle, children: entry.title }), _jsx("span", { className: css.when, children: whenLabel(t, entry.publishedAt) })] }, entry.id)))] })), mediaFace.followPaused && (_jsx("div", { className: css.followDock, children: _jsxs("button", { type: "button", className: css.followButton, onClick: mediaFace.resumeFollow, children: ["\u2193 ", t('media.backToNow')] }) }))] }), notesFace.sheet !== null && (_jsx(NoteSheet, { t: t, selection: notesFace.sheet, saving: notesFace.saving, failed: notesFace.failed, onSave: notesFace.saveNote, onCancel: notesFace.cancelSheet }))] }));
|
|
2968
3641
|
}
|
|
2969
3642
|
/* ------------------------------------------------------ subscriptions page */
|
|
2970
3643
|
if (view === 'recent') {
|
|
@@ -3025,7 +3698,7 @@ export function ReaderPane(props) {
|
|
|
3025
3698
|
}) }))] }), dialog] }));
|
|
3026
3699
|
}
|
|
3027
3700
|
/* ------------------------------------------------------------------- wall */
|
|
3028
|
-
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: () => {
|
|
3701
|
+
return (_jsxs("div", { className: css.root, children: [compact ? compactWallHeader : header(false), (!compact || searchVisible) && _jsx("div", { className: css.tools, children: _jsxs("div", { className: css.search, children: [glyph('search', 12), _jsx("input", { autoFocus: compact && searchOpen, 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) }))] }) }), compact && sources.length > 0 && (_jsx(ChipRow, { className: css.chips, label: t('action.filter'), chips: wallChips, value: wallChipValue, onSelect: pickChip })), 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: () => {
|
|
3029
3702
|
if (wallCancelRef.current !== null)
|
|
3030
3703
|
wallCancelRef.current.cancelled = true;
|
|
3031
3704
|
wallPendingRef.current.clear();
|
|
@@ -3036,7 +3709,7 @@ export function ReaderPane(props) {
|
|
|
3036
3709
|
_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 && (
|
|
3037
3710
|
// "Why is this list short?" answered where the reader is looking,
|
|
3038
3711
|
// once per affected source, with the way to the full text.
|
|
3039
|
-
|
|
3712
|
+
_jsxs("div", { className: css.notice, children: [brokenSources.length > 1 && !noticeOpen ? (_jsxs("div", { className: css.noticeRow, children: [_jsx("span", { className: css.noticeText, children: t('state.incompleteCount', { count: String(brokenSources.length) }) }), _jsx("button", { type: "button", className: css.noticeLink, onClick: () => { setNoticeOpen(true); }, children: t('action.showList') })] })) : 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))), brokenSources.length > 1 && noticeOpen && (_jsx("button", { type: "button", className: `${css.noticeLink} ${css.noticeFold}`, onClick: () => { setNoticeOpen(false); }, children: t('action.foldList') }))] })), !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 => {
|
|
3040
3713
|
// The card's text box is FIXED (two clamped lines per field, see
|
|
3041
3714
|
// the CSS), because Chinese and English wrap differently: swapping
|
|
3042
3715
|
// languages must not resize the card or shift the grid.
|
|
@@ -3060,6 +3733,12 @@ export function ReaderPane(props) {
|
|
|
3060
3733
|
// The duplicates this card stands for, when the wall's dedupe
|
|
3061
3734
|
// folded them behind it (a badge, never a deletion).
|
|
3062
3735
|
const dupes = dupesBy.get(row.entry.id) ?? [];
|
|
3736
|
+
// A video / audio card with a cover: the cover takes the summary's
|
|
3737
|
+
// place beside the title, so the card stays as tall as an article's.
|
|
3738
|
+
const media = row.entry.media;
|
|
3739
|
+
// YouTube's 16:9 still (mqdefault) beats a feed's 4:3 one with bars.
|
|
3740
|
+
const thumb = media?.provider === 'youtube' ? `https://i.ytimg.com/vi/${media.ref}/mqdefault.jpg`
|
|
3741
|
+
: media?.thumbnail === undefined ? undefined : media.thumbnail.replace(/^http:\/\//, 'https://');
|
|
3063
3742
|
// Only a field that actually has a translation can be peeked: an
|
|
3064
3743
|
// untranslated (or Chinese) card keeps its ordinary hover.
|
|
3065
3744
|
const peekable = (field) => wallOn && !wallBoth && card?.[field] !== undefined;
|
|
@@ -3069,7 +3748,7 @@ export function ReaderPane(props) {
|
|
|
3069
3748
|
onMouseLeave: () => { setHoverField(current => (current?.id === row.entry.id && current.field === field ? null : current)); },
|
|
3070
3749
|
}
|
|
3071
3750
|
: {};
|
|
3072
|
-
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) {
|
|
3751
|
+
return (_jsxs("div", { className: css.card, "data-reader-entry": row.entry.id, "data-translated": wallOn && card !== undefined ? '1' : undefined, "data-unread": row.unread || undefined, "data-thumb": thumb === undefined ? undefined : media?.kind, role: "button", tabIndex: 0, onClick: () => { if (cardMenu !== null || cardTag !== null) {
|
|
3073
3752
|
setCardMenu(null);
|
|
3074
3753
|
setCardTag(null);
|
|
3075
3754
|
return;
|
|
@@ -3080,8 +3759,25 @@ export function ReaderPane(props) {
|
|
|
3080
3759
|
if (row.entry.link === undefined)
|
|
3081
3760
|
return;
|
|
3082
3761
|
event.preventDefault();
|
|
3083
|
-
setCardMenu({ entryId: row.entry.id, top: event.clientY, left: event.clientX });
|
|
3084
|
-
}, 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 }),
|
|
3762
|
+
setCardMenu({ entryId: row.entry.id, top: Math.max(8, Math.min(event.clientY, window.innerHeight - CARD_MENU_HEIGHT - 8)), left: Math.max(8, Math.min(event.clientX, window.innerWidth - CARD_MENU_WIDTH - 8)) });
|
|
3763
|
+
}, 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: [row.unread && _jsx("span", { className: css.metaDot }), _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 }), thumb !== undefined && media !== undefined && (_jsxs("span", { className: css.cardThumb, "data-kind": media.kind, children: [_jsx("img", { src: thumb, alt: "", loading: "lazy", referrerPolicy: "no-referrer", onError: event => { event.currentTarget.style.visibility = 'hidden'; } }), media.kind === 'video' ? _jsx("span", { className: css.cardThumbPlay }) : _jsx("span", { className: css.cardThumbGlyph, children: "\u266A" }), media.durationSec !== undefined && _jsx("span", { className: css.cardThumbTime, children: formatClock(media.durationSec) })] })), thumb !== undefined
|
|
3764
|
+
? _jsx("span", { className: css.cardThumbSpacer })
|
|
3765
|
+
: 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') })), media !== undefined && thumb === undefined && (_jsxs("span", { className: css.tagMedia, children: [media.kind === 'video' ? '▶ ' : '♪ ', t(media.kind === 'video' ? 'media.kindVideo' : 'media.kindAudio'), media.durationSec === undefined ? '' : ` · ${formatClock(media.durationSec)}`] })), media !== undefined && (() => {
|
|
3766
|
+
// The transcript pill: where this card's transcript stands.
|
|
3767
|
+
const status = mediaCards?.states[row.entry.id];
|
|
3768
|
+
const engine = mediaCards?.engine === true;
|
|
3769
|
+
const state = status?.state ?? (media.kind === 'audio' ? 'none' : undefined);
|
|
3770
|
+
if (state === undefined)
|
|
3771
|
+
return null;
|
|
3772
|
+
const label = state === 'platform' ? t('media.cardPlatform')
|
|
3773
|
+
: state === 'local' ? t('media.cardLocal')
|
|
3774
|
+
: state === 'queued' ? (status?.ahead !== undefined && status.ahead > 0 ? t('media.cardQueuedAhead', { count: status.ahead + 1 }) : t('media.cardQueued'))
|
|
3775
|
+
: state === 'fetching' || state === 'transcribing' ? t('media.cardRunning', { percent: Math.round((status?.progress ?? 0) * 100) })
|
|
3776
|
+
: state === 'failed' ? t('media.cardFailed')
|
|
3777
|
+
: engine ? t('media.cardNoneAuto') : t('media.cardNone');
|
|
3778
|
+
const tone = state === 'platform' || state === 'local' ? 'ready' : state === 'failed' ? 'failed' : state === 'none' ? 'none' : 'fetching';
|
|
3779
|
+
return _jsx("span", { className: `${css.mediaPill} ${css[`fetchPill_${tone}`] ?? ''}`, children: label });
|
|
3780
|
+
})(), 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 && row.entry.media === undefined && (() => {
|
|
3085
3781
|
const fetchState = fetchStateOf(fetchStates, row.entry.id);
|
|
3086
3782
|
const state = fetchState.state;
|
|
3087
3783
|
const labelKey = state === 'none' ? 'fetch.none'
|