@anthusai/papyrus 5.43.0 → 5.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +16 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/news-desk-client-provider.tsx +24 -3
- package/components/news-desk-page.tsx +2 -1
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-access-gate.tsx +7 -5
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +6 -1
- package/components/newsroom-ops-shell.tsx +1 -1
- package/components/newsroom-organizer.css +48 -0
- package/components/newsroom-references-view.tsx +183 -24
- package/components/notification-center.tsx +345 -63
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reference-feed.tsx +469 -0
- package/components/reference-review-card.tsx +90 -76
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +47 -0
- package/components/review-rating-grid.tsx +164 -0
- package/components/topic-steering-workspace.tsx +32 -8
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/category-repository.ts +4 -1
- package/lib/local-preview-feed-fixture.ts +43 -0
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +29 -2
- package/lib/monitoring-overview.ts +52 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-organizer-nav.ts +1 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-feed-cache.ts +133 -0
- package/lib/reference-feed-ranking.ts +191 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-client.ts +38 -1
- package/lib/review-drafts.ts +231 -0
- package/lib/review-fixture.ts +36 -2
- package/lib/review-requests.ts +64 -24
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +14 -1
- package/routes.manifest.json +1 -1
|
@@ -2,21 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
import { generateClient } from "aws-amplify/data";
|
|
4
4
|
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
5
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
6
|
import { ProposalsView } from "./proposals-view";
|
|
7
7
|
import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
|
|
8
8
|
import {
|
|
9
|
-
EDIT_PROPOSAL,
|
|
10
9
|
LIST_PROPOSALS,
|
|
11
|
-
|
|
10
|
+
filterProposals,
|
|
12
11
|
proposalReceiptMessage,
|
|
13
12
|
type ProposalFilter,
|
|
14
13
|
type ProposalMove,
|
|
15
|
-
type ProposalReceipt,
|
|
16
14
|
type ProposalRow,
|
|
17
15
|
} from "../lib/proposals-admin";
|
|
16
|
+
import { performProposalMove } from "../lib/proposals-client";
|
|
18
17
|
|
|
19
|
-
type GraphResult = { data?: Record<string, { items?: ProposalRow[]; nextToken?: string | null } |
|
|
18
|
+
type GraphResult = { data?: Record<string, { items?: ProposalRow[]; nextToken?: string | null } | null>; errors?: { message?: string }[] };
|
|
20
19
|
const MAXIMUM_PAGES = 20;
|
|
21
20
|
|
|
22
21
|
async function loadProposals(): Promise<ProposalRow[]> {
|
|
@@ -34,24 +33,6 @@ async function loadProposals(): Promise<ProposalRow[]> {
|
|
|
34
33
|
return rows;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
async function run(query: string, field: string, variables: Record<string, string | undefined>): Promise<ProposalReceipt> {
|
|
38
|
-
const client = generateClient({ authMode: "userPool" });
|
|
39
|
-
const result = (await client.graphql({ query, variables })) as GraphResult;
|
|
40
|
-
if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
|
|
41
|
-
return (result.data?.[field] as ProposalReceipt | null) ?? { status: "refused", message: "The action did not answer." };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** One button: a path of states is one `transitionProposal` call per state, stopping at the first refusal; pin and hold are `editProposal`. */
|
|
45
|
-
async function perform(proposalId: string, move: ProposalMove, reason: string): Promise<ProposalReceipt> {
|
|
46
|
-
if (move.edit) return run(EDIT_PROPOSAL, "editProposal", { proposalId, action: move.edit });
|
|
47
|
-
let receipt: ProposalReceipt = { status: "refused", message: "Nothing to do." };
|
|
48
|
-
for (const state of move.path ?? []) {
|
|
49
|
-
receipt = await run(TRANSITION_PROPOSAL, "transitionProposal", { proposalId, state, ...(reason ? { reason } : {}) });
|
|
50
|
-
if (receipt.status !== "ok") break;
|
|
51
|
-
}
|
|
52
|
-
return receipt;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
36
|
/** Core to every site: it does not depend on the reader-steering plugin. */
|
|
56
37
|
export function ProposalsPanel() {
|
|
57
38
|
const [rows, setRows] = useState<ProposalRow[]>([]);
|
|
@@ -59,6 +40,8 @@ export function ProposalsPanel() {
|
|
|
59
40
|
const [status, setStatus] = useState("Loading...");
|
|
60
41
|
const [busy, setBusy] = useState(false);
|
|
61
42
|
const [reason, setReason] = useState("");
|
|
43
|
+
const [focusId, setFocusId] = useState<string | null>(null);
|
|
44
|
+
const focused = useRef(false);
|
|
62
45
|
const readers = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null, []);
|
|
63
46
|
|
|
64
47
|
const refresh = useCallback(async (quiet = false) => {
|
|
@@ -77,12 +60,25 @@ export function ProposalsPanel() {
|
|
|
77
60
|
void refresh();
|
|
78
61
|
}, [refresh]);
|
|
79
62
|
|
|
63
|
+
// Sent here by a link such as `?proposal=<id>`: show the view that holds it and scroll to it.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
setFocusId(new URLSearchParams(window.location.search).get("proposal"));
|
|
66
|
+
}, []);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!focusId || focused.current) return;
|
|
69
|
+
const row = rows.find((candidate) => candidate.id === focusId);
|
|
70
|
+
if (!row) return;
|
|
71
|
+
focused.current = true;
|
|
72
|
+
setFilter(filterProposals([row], "open").length ? "open" : "decided");
|
|
73
|
+
window.requestAnimationFrame(() => document.getElementById(`proposal-${focusId}`)?.scrollIntoView({ block: "start" }));
|
|
74
|
+
}, [focusId, rows]);
|
|
75
|
+
|
|
80
76
|
const onMove = useCallback(
|
|
81
77
|
async (proposalId: string, move: ProposalMove) => {
|
|
82
78
|
setBusy(true);
|
|
83
79
|
setStatus("Working...");
|
|
84
80
|
try {
|
|
85
|
-
const receipt = await
|
|
81
|
+
const receipt = await performProposalMove(proposalId, move, reason.trim());
|
|
86
82
|
setStatus(proposalReceiptMessage(receipt));
|
|
87
83
|
if (receipt.status === "ok") setReason("");
|
|
88
84
|
} catch (error) {
|
|
@@ -105,6 +101,7 @@ export function ProposalsPanel() {
|
|
|
105
101
|
onReason={setReason}
|
|
106
102
|
onMove={(proposalId, move) => void onMove(proposalId, move)}
|
|
107
103
|
onRefresh={() => void refresh()}
|
|
104
|
+
focusId={focusId}
|
|
108
105
|
/>
|
|
109
106
|
);
|
|
110
107
|
}
|
|
@@ -32,6 +32,8 @@ export type ProposalsViewProps = {
|
|
|
32
32
|
onReason: (reason: string) => void;
|
|
33
33
|
onMove: (proposalId: string, move: ProposalMove) => void;
|
|
34
34
|
onRefresh: () => void;
|
|
35
|
+
/** A proposal the editor was sent to (from the Notification Center): its draft opens and it is marked. */
|
|
36
|
+
focusId?: string | null;
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
export function ProposalsView(props: ProposalsViewProps) {
|
|
@@ -62,7 +64,7 @@ export function ProposalsView(props: ProposalsViewProps) {
|
|
|
62
64
|
) : (
|
|
63
65
|
<ul>
|
|
64
66
|
{shown.map((row) => (
|
|
65
|
-
<li key={row.id} data-proposal-row={row.id}>
|
|
67
|
+
<li key={row.id} id={`proposal-${row.id}`} data-proposal-row={row.id} data-proposal-focused={props.focusId === row.id || undefined}>
|
|
66
68
|
<article aria-label={row.title}>
|
|
67
69
|
<p>
|
|
68
70
|
<strong>{kindLabel(row.kind)}</strong>: {row.title} ({stateLabel(row.state, props.readers)}
|
|
@@ -70,7 +72,7 @@ export function ProposalsView(props: ProposalsViewProps) {
|
|
|
70
72
|
</p>
|
|
71
73
|
{row.summary ? <p>{row.summary}</p> : null}
|
|
72
74
|
{row.body ? (
|
|
73
|
-
<details>
|
|
75
|
+
<details open={props.focusId === row.id || undefined}>
|
|
74
76
|
<summary>Read the draft</summary>
|
|
75
77
|
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.body}</pre>
|
|
76
78
|
</details>
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { InfoIcon, Loader2Icon } from "lucide-react";
|
|
4
|
+
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
5
|
+
import type { ReferenceRecord } from "../lib/category-repository";
|
|
6
|
+
import { REFERENCE_REJECTION_REASON_CODES, normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
7
|
+
import {
|
|
8
|
+
referenceDisplayTime,
|
|
9
|
+
groupFeedItems,
|
|
10
|
+
EARLIER_AFTER_TAUS,
|
|
11
|
+
MINIMUM_FRESHNESS_EXAMPLES,
|
|
12
|
+
type FeedGroupKey,
|
|
13
|
+
type FeedItem,
|
|
14
|
+
type FeedFreshness,
|
|
15
|
+
type ReferenceFeedPartition,
|
|
16
|
+
type ReferenceFeedTabKey,
|
|
17
|
+
} from "../lib/reference-feed-ranking";
|
|
18
|
+
import { dekAddsInformation, isCardStyleImage, referenceDek, referenceSourceHost, formatFeedAge, type ReferencePreview } from "../lib/reference-preview";
|
|
19
|
+
import { referenceDisplayTitle } from "../lib/newsroom-references";
|
|
20
|
+
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
21
|
+
import { cn } from "../lib/utils";
|
|
22
|
+
import { predictionFromDecision, type ReviewChoice } from "./reference-review-card";
|
|
23
|
+
import { RatingGrid } from "./review-rating-grid";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Flat surfaces, no borders: the page tone, a card tone one step away from it, a footer tone, and a control tone for chips and
|
|
27
|
+
* pills. The tones (`--feed-card`, `--feed-footer`, `--feed-control`) are mixed from the publication theme's own paper and ink in
|
|
28
|
+
* newsroom-organizer.css, per light or dark mode, so they follow the brand.
|
|
29
|
+
*/
|
|
30
|
+
export const FEED_SURFACE_STYLE = {} as CSSProperties;
|
|
31
|
+
|
|
32
|
+
/** Spread on every feed surface: the tone variables, and the hook the newsroom stylesheet uses to allow round corners here. */
|
|
33
|
+
export const FEED_SURFACE_PROPS = { "data-feed-surface": "", style: FEED_SURFACE_STYLE } as const;
|
|
34
|
+
|
|
35
|
+
const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
|
|
36
|
+
out_of_scope: "Out of scope",
|
|
37
|
+
policy_exclusion: "Policy exclusion",
|
|
38
|
+
duplicate: "Duplicate",
|
|
39
|
+
low_quality: "Low quality",
|
|
40
|
+
unavailable: "Unavailable",
|
|
41
|
+
provenance: "Provenance",
|
|
42
|
+
other: "Other",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const TAB_LABELS: Record<ReferenceFeedTabKey, string> = { feed: "Feed", rated: "Rated", earlier: "Earlier" };
|
|
46
|
+
const GROUP_HEADINGS: Record<FeedGroupKey, string> = { top: "Top predictions", more: "More for you", least: "Least likely" };
|
|
47
|
+
|
|
48
|
+
const PILL = "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-full px-3.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
|
|
49
|
+
|
|
50
|
+
export type ReferenceFeedProps = {
|
|
51
|
+
/** Read-only reason shown in words when answering is not possible yet (for example while the session is confirmed). */
|
|
52
|
+
disabledReason?: string | null;
|
|
53
|
+
/** True while the first read is in flight: cards are skeletons. */
|
|
54
|
+
loading?: boolean;
|
|
55
|
+
now: number;
|
|
56
|
+
onOpen: (lineageId: string) => void;
|
|
57
|
+
onRate: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
|
|
58
|
+
onVisitRated: (lineageId: string) => void;
|
|
59
|
+
onTabChange: (tab: ReferenceFeedTabKey) => void;
|
|
60
|
+
/** The ranked tabs, from `partitionReferenceFeed`. */
|
|
61
|
+
partition: ReferenceFeedPartition;
|
|
62
|
+
predictionLoading?: boolean;
|
|
63
|
+
previews: ReadonlyMap<string, ReferencePreview>;
|
|
64
|
+
selectedLineageId?: string;
|
|
65
|
+
/** True when the cards are a saved copy that is being refreshed. */
|
|
66
|
+
stale?: boolean;
|
|
67
|
+
tab: ReferenceFeedTabKey;
|
|
68
|
+
visitRated: ReadonlySet<string>;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const GRID = "grid gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4";
|
|
72
|
+
|
|
73
|
+
/** Pulsing placeholder cards: the page shell is on screen at once, with no "checking" screen in front of it. */
|
|
74
|
+
export function ReferenceFeedSkeleton({ count = 6 }: { count?: number }) {
|
|
75
|
+
return (
|
|
76
|
+
<div aria-busy="true" aria-label="Loading references" className={GRID} data-reference-feed-skeleton role="status" {...FEED_SURFACE_PROPS}>
|
|
77
|
+
{Array.from({ length: count }, (_, index) => (
|
|
78
|
+
<div className={cn("overflow-hidden rounded-[20px] bg-[var(--feed-card)]", index === 0 && "sm:col-span-2")} key={index}>
|
|
79
|
+
<div className={cn("animate-pulse bg-[var(--feed-control)]", index === 0 ? "aspect-[2/1]" : "aspect-[16/10]")} />
|
|
80
|
+
<div className="space-y-3 p-5">
|
|
81
|
+
<div className="h-3 w-1/3 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
82
|
+
<div className="h-5 w-11/12 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
83
|
+
<div className="h-5 w-2/3 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
84
|
+
<div className="h-3 w-1/4 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
85
|
+
</div>
|
|
86
|
+
<div className="flex gap-2 bg-[var(--feed-footer)] p-3">
|
|
87
|
+
<div className="h-10 flex-1 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
88
|
+
<div className="h-10 flex-1 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hueOf(name: string): number {
|
|
97
|
+
let hash = 5381;
|
|
98
|
+
for (const character of name) hash = ((hash << 5) + hash + character.charCodeAt(0)) | 0;
|
|
99
|
+
return Math.abs(hash) % 360;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A small mark for a source: its own favicon on a light tile when it loads, else its first letter. */
|
|
103
|
+
function SourceMark({ host, size = "sm" }: { host: string | null; size?: "sm" | "lg" }) {
|
|
104
|
+
const [failed, setFailed] = useState(false);
|
|
105
|
+
const box = size === "lg" ? "size-16 rounded-2xl text-3xl" : "size-5 rounded-md text-[0.65rem]";
|
|
106
|
+
const letter = (host ?? "?")[0];
|
|
107
|
+
return (
|
|
108
|
+
<span aria-hidden="true" className={cn("inline-flex shrink-0 items-center justify-center overflow-hidden bg-white font-bold uppercase text-neutral-700", box)} data-feed-source-mark>
|
|
109
|
+
{host && !failed ? (
|
|
110
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
111
|
+
<img alt="" className={size === "lg" ? "size-10 object-contain" : "size-3.5 object-contain"} loading="lazy" onError={() => setFailed(true)} referrerPolicy="no-referrer" src={`https://${host}/favicon.ico`} />
|
|
112
|
+
) : letter}
|
|
113
|
+
</span>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** For a page with no usable preview picture: a soft gradient from the source's name, its mark large and centred, its name beneath. */
|
|
118
|
+
function FallbackTile({ host, name }: { host: string | null; name: string }) {
|
|
119
|
+
const hue = hueOf(host ?? name);
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
className="flex size-full flex-col items-center justify-center gap-3 p-4 text-white"
|
|
123
|
+
data-feed-image-fallback
|
|
124
|
+
style={{ background: `linear-gradient(135deg, oklch(0.6 0.1 ${hue}), oklch(0.42 0.11 ${(hue + 45) % 360}))` }}
|
|
125
|
+
>
|
|
126
|
+
<SourceMark host={host} size="lg" />
|
|
127
|
+
<span className="max-w-full truncate text-sm font-semibold tracking-wide opacity-90">{name}</span>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Full-bleed lazy image in a fixed frame: a soft pulse until it loads, the fallback tile when it never does.
|
|
134
|
+
* Photos fill the frame (cover). A text card (a repository social card, or a 2:1 picture) is shown whole instead: the frame takes
|
|
135
|
+
* the picture's own ratio and the picture is contained, so no words are cut off at the edges.
|
|
136
|
+
*/
|
|
137
|
+
function FeedImage({ host, lead, name, src }: { host: string | null; lead: boolean; name: string; src: string | null }) {
|
|
138
|
+
const [state, setState] = useState<"loading" | "loaded" | "failed">(src ? "loading" : "failed");
|
|
139
|
+
const [ratio, setRatio] = useState<number | null>(null);
|
|
140
|
+
const ref = useRef<HTMLImageElement | null>(null);
|
|
141
|
+
const measure = (element: HTMLImageElement) => { if (element.naturalWidth > 0 && element.naturalHeight > 0) setRatio(element.naturalWidth / element.naturalHeight); };
|
|
142
|
+
useEffect(() => { setState(src ? "loading" : "failed"); setRatio(null); }, [src]);
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
// A cached image can finish before React attaches onLoad.
|
|
145
|
+
const element = ref.current;
|
|
146
|
+
if (element?.complete && element.naturalWidth > 0) { setState("loaded"); measure(element); }
|
|
147
|
+
}, [src]);
|
|
148
|
+
const card = isCardStyleImage(src, ratio);
|
|
149
|
+
return (
|
|
150
|
+
<div
|
|
151
|
+
className={cn("relative w-full overflow-hidden bg-[var(--feed-control)]", !card && (lead ? "aspect-[16/10] sm:aspect-[2/1] xl:aspect-[21/9]" : "aspect-[16/10]"))}
|
|
152
|
+
data-feed-image
|
|
153
|
+
data-feed-image-fit={card ? "contain" : "cover"}
|
|
154
|
+
data-feed-image-state={state}
|
|
155
|
+
style={card ? { aspectRatio: String(ratio ?? 2) } : undefined}
|
|
156
|
+
>
|
|
157
|
+
{state === "loading" ? <div aria-hidden="true" className="absolute inset-0 animate-pulse bg-[var(--feed-footer)] blur-sm" data-feed-image-placeholder /> : null}
|
|
158
|
+
{src && state !== "failed" ? (
|
|
159
|
+
// The source site chose this picture; an <img> keeps it out of our image optimizer and out of its referrer log.
|
|
160
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
161
|
+
<img
|
|
162
|
+
alt=""
|
|
163
|
+
className={cn("absolute inset-0 size-full transition-opacity duration-500", card ? "object-contain" : "object-cover", state === "loaded" ? "opacity-100" : "opacity-0")}
|
|
164
|
+
decoding="async"
|
|
165
|
+
loading="lazy"
|
|
166
|
+
onError={() => setState("failed")}
|
|
167
|
+
onLoad={(event) => { setState("loaded"); measure(event.currentTarget); }}
|
|
168
|
+
ref={ref}
|
|
169
|
+
referrerPolicy="no-referrer"
|
|
170
|
+
src={src}
|
|
171
|
+
/>
|
|
172
|
+
) : null}
|
|
173
|
+
{state === "failed" ? <FallbackTile host={host} name={name} /> : null}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
type RateState = { phase: "idle" | "saving" | "saved" | "error" | "reason"; message?: string };
|
|
179
|
+
|
|
180
|
+
function FeedCard({
|
|
181
|
+
decision,
|
|
182
|
+
disabledReason,
|
|
183
|
+
item,
|
|
184
|
+
lead,
|
|
185
|
+
now,
|
|
186
|
+
onOpen,
|
|
187
|
+
onRate,
|
|
188
|
+
predictionLoading,
|
|
189
|
+
preview,
|
|
190
|
+
selected,
|
|
191
|
+
visitRated,
|
|
192
|
+
}: {
|
|
193
|
+
decision: RelevanceDecisionView | undefined;
|
|
194
|
+
disabledReason: string | null;
|
|
195
|
+
item: FeedItem;
|
|
196
|
+
lead: boolean;
|
|
197
|
+
now: number;
|
|
198
|
+
onOpen: () => void;
|
|
199
|
+
onRate: ReferenceFeedProps["onRate"];
|
|
200
|
+
predictionLoading: boolean;
|
|
201
|
+
preview: ReferencePreview | undefined;
|
|
202
|
+
selected: boolean;
|
|
203
|
+
visitRated: boolean;
|
|
204
|
+
}) {
|
|
205
|
+
const { reference } = item;
|
|
206
|
+
const titleId = useId();
|
|
207
|
+
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
208
|
+
const answer: ReviewChoice | null = status === "accepted" ? "accept" : status === "rejected" ? "reject" : null;
|
|
209
|
+
const [rate, setRate] = useState<RateState>({ phase: "idle" });
|
|
210
|
+
const inFlight = useRef(false);
|
|
211
|
+
const host = referenceSourceHost(reference);
|
|
212
|
+
const site = preview?.siteName ?? host ?? "Unknown source";
|
|
213
|
+
const title = referenceDisplayTitle(reference);
|
|
214
|
+
const dek = referenceDek(reference, preview);
|
|
215
|
+
const showDek = dekAddsInformation(title, dek, preview?.imageUrl ?? null);
|
|
216
|
+
const time = referenceDisplayTime(reference);
|
|
217
|
+
const age = formatFeedAge(time, now);
|
|
218
|
+
|
|
219
|
+
async function save(choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) {
|
|
220
|
+
if (disabledReason || inFlight.current) return;
|
|
221
|
+
inFlight.current = true;
|
|
222
|
+
setRate({ phase: "saving" });
|
|
223
|
+
try {
|
|
224
|
+
await onRate(reference, choice, reasonCode);
|
|
225
|
+
setRate({ phase: "saved" });
|
|
226
|
+
} catch (failure) {
|
|
227
|
+
setRate({ phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." });
|
|
228
|
+
} finally {
|
|
229
|
+
inFlight.current = false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function choose(choice: ReviewChoice) {
|
|
234
|
+
if (disabledReason) return;
|
|
235
|
+
if (answer === choice) return; // the chosen answer never unselects itself; the other choice is the undo
|
|
236
|
+
if (choice === "reject") { setRate({ phase: "reason" }); return; }
|
|
237
|
+
void save("accept", null);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<article
|
|
242
|
+
aria-labelledby={titleId}
|
|
243
|
+
className={cn(
|
|
244
|
+
"flex flex-col overflow-hidden rounded-[20px] text-card-foreground",
|
|
245
|
+
selected ? "bg-[var(--feed-control)]" : "bg-[var(--feed-card)]",
|
|
246
|
+
lead && "sm:col-span-2",
|
|
247
|
+
)}
|
|
248
|
+
data-feed-card={item.lineageId}
|
|
249
|
+
data-feed-lead={lead || undefined}
|
|
250
|
+
data-feed-rated={answer ?? undefined}
|
|
251
|
+
data-feed-visit-rated={visitRated || undefined}
|
|
252
|
+
data-newsroom-card-id={item.lineageId}
|
|
253
|
+
>
|
|
254
|
+
<button
|
|
255
|
+
aria-label={`Open: ${title}`}
|
|
256
|
+
className="flex flex-1 flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
257
|
+
data-feed-open
|
|
258
|
+
onClick={onOpen}
|
|
259
|
+
type="button"
|
|
260
|
+
>
|
|
261
|
+
<FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
|
|
262
|
+
<span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 sm:p-7" : "p-5")}>
|
|
263
|
+
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
|
|
264
|
+
<SourceMark host={host} />
|
|
265
|
+
<span className="truncate">{site}</span>
|
|
266
|
+
</span>
|
|
267
|
+
<span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl sm:text-3xl xl:text-4xl" : "line-clamp-3 text-xl")} data-feed-title id={titleId}>
|
|
268
|
+
{title}
|
|
269
|
+
</span>
|
|
270
|
+
{showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
|
|
271
|
+
<span className="mt-auto pt-1 text-xs text-muted-foreground">
|
|
272
|
+
{age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
|
|
273
|
+
{age && host ? <span aria-hidden="true"> · </span> : null}
|
|
274
|
+
{host}
|
|
275
|
+
</span>
|
|
276
|
+
</span>
|
|
277
|
+
</button>
|
|
278
|
+
|
|
279
|
+
<div className="space-y-2.5 bg-[var(--feed-footer)] p-3" data-feed-actions>
|
|
280
|
+
<RatingGrid
|
|
281
|
+
answer={answer}
|
|
282
|
+
disabled={Boolean(disabledReason)}
|
|
283
|
+
disabledReason={disabledReason}
|
|
284
|
+
feedback={rate.phase === "saving" ? { phase: "saving" } : rate.phase === "error" ? { phase: "error", message: rate.message } : rate.phase === "saved" || (visitRated && rate.phase === "idle" && answer) ? { phase: "saved" } : { phase: "idle" }}
|
|
285
|
+
onChoose={choose}
|
|
286
|
+
prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
|
|
287
|
+
/>
|
|
288
|
+
{rate.phase === "reason" ? (
|
|
289
|
+
<div className="space-y-2" data-review-reasons>
|
|
290
|
+
<p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
|
|
291
|
+
<div className="flex flex-wrap gap-1.5">
|
|
292
|
+
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
293
|
+
<button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => void save("reject", code)} type="button">
|
|
294
|
+
{REASON_LABELS[code]}
|
|
295
|
+
</button>
|
|
296
|
+
))}
|
|
297
|
+
<button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => setRate({ phase: "idle" })} type="button">Cancel</button>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
) : null}
|
|
301
|
+
</div>
|
|
302
|
+
</article>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function plainDays(days: number): string {
|
|
307
|
+
if (days < 1) return `${Math.max(1, Math.round(days * 24))} hours`;
|
|
308
|
+
return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The plain-words recipe, with this site's learned numbers in it. */
|
|
312
|
+
function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
|
|
313
|
+
const [open, setOpen] = useState(false);
|
|
314
|
+
const panelId = useId();
|
|
315
|
+
const root = useRef<HTMLDivElement | null>(null);
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
if (!open) return;
|
|
318
|
+
const close = (event: MouseEvent | KeyboardEvent) => {
|
|
319
|
+
if (event instanceof KeyboardEvent) { if (event.key === "Escape") setOpen(false); return; }
|
|
320
|
+
if (root.current && !root.current.contains(event.target as Node)) setOpen(false);
|
|
321
|
+
};
|
|
322
|
+
document.addEventListener("mousedown", close);
|
|
323
|
+
document.addEventListener("keydown", close);
|
|
324
|
+
return () => { document.removeEventListener("mousedown", close); document.removeEventListener("keydown", close); };
|
|
325
|
+
}, [open]);
|
|
326
|
+
return (
|
|
327
|
+
<div className="relative" ref={root}>
|
|
328
|
+
<button
|
|
329
|
+
aria-controls={panelId}
|
|
330
|
+
aria-expanded={open}
|
|
331
|
+
aria-label="How this feed is ranked"
|
|
332
|
+
className={cn(PILL, "text-muted-foreground hover:text-foreground", open && "bg-[var(--feed-card)] text-foreground")}
|
|
333
|
+
data-feed-ranking-info
|
|
334
|
+
onClick={() => setOpen((current) => !current)}
|
|
335
|
+
type="button"
|
|
336
|
+
>
|
|
337
|
+
<InfoIcon aria-hidden="true" className="size-4" />
|
|
338
|
+
<span className="hidden sm:inline">How this feed is ranked</span>
|
|
339
|
+
<span className="sr-only sm:hidden">Ranking</span>
|
|
340
|
+
</button>
|
|
341
|
+
{open ? (
|
|
342
|
+
<div
|
|
343
|
+
aria-label="How this feed is ranked"
|
|
344
|
+
className="fixed inset-x-4 bottom-24 z-30 max-h-[70dvh] space-y-3 overflow-y-auto rounded-3xl bg-popover p-6 text-sm text-popover-foreground shadow-2xl sm:absolute sm:inset-x-auto sm:bottom-auto sm:right-0 sm:mt-2 sm:w-[24rem]"
|
|
345
|
+
data-feed-ranking-panel
|
|
346
|
+
id={panelId}
|
|
347
|
+
role="dialog"
|
|
348
|
+
>
|
|
349
|
+
<p className="text-base font-bold">A feed run by algorithms you control</p>
|
|
350
|
+
<ol className="list-decimal space-y-2.5 pl-5">
|
|
351
|
+
<li><strong>Best bets first.</strong> The Cyclotron predicts Accept or Reject and how sure it is. The items it expects you to Accept most surely come first. The ones it expects you to Reject most surely come last. Items with no prediction sit in the middle.</li>
|
|
352
|
+
<li><strong>Confidence counts.</strong> An Accept at 90% ranks above an Accept at 60%.</li>
|
|
353
|
+
<li><strong>Age fades a prediction toward “not sure”.</strong> Older items drift toward the middle, never to the bottom. {freshness.tauDays !== null && freshness.medianAgeDays !== null
|
|
354
|
+
? <>A prediction loses half its pull every {plainDays(freshness.medianAgeDays)}, the typical age of the {freshness.examples} items you accepted.</>
|
|
355
|
+
: <>Age does not change the order yet. The pace is learned from the items you accept, and so far there are {freshness.examples} of the {MINIMUM_FRESHNESS_EXAMPLES} needed.</>}</li>
|
|
356
|
+
<li><strong>Earlier.</strong> {freshness.tauDays !== null
|
|
357
|
+
? <>Unrated items older than {plainDays(freshness.tauDays * EARLIER_AFTER_TAUS)} move to Earlier at the bottom. You can still rate them. Nothing is deleted.</>
|
|
358
|
+
: <>Once the pace is learned, unrated items past three times that pace move to Earlier at the bottom. Nothing is deleted.</>}</li>
|
|
359
|
+
<li><strong>Your answers stay put.</strong> A card you rate stays where it is for this visit. Next time it is under Rated.</li>
|
|
360
|
+
</ol>
|
|
361
|
+
</div>
|
|
362
|
+
) : null}
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
|
|
368
|
+
return <h3 className="mb-5 mt-10 text-3xl font-bold tracking-tight text-primary first:mt-2 sm:mt-12 sm:text-4xl" id={id}>{children}</h3>;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function ReferenceFeed({
|
|
372
|
+
disabledReason = null,
|
|
373
|
+
loading = false,
|
|
374
|
+
now,
|
|
375
|
+
onOpen,
|
|
376
|
+
onRate,
|
|
377
|
+
onTabChange,
|
|
378
|
+
onVisitRated,
|
|
379
|
+
partition,
|
|
380
|
+
predictionLoading = false,
|
|
381
|
+
previews,
|
|
382
|
+
selectedLineageId = "",
|
|
383
|
+
stale = false,
|
|
384
|
+
tab,
|
|
385
|
+
visitRated,
|
|
386
|
+
}: ReferenceFeedProps) {
|
|
387
|
+
const counts: Record<ReferenceFeedTabKey, number> = { feed: partition.feed.length, rated: partition.rated.length, earlier: partition.earlier.length };
|
|
388
|
+
|
|
389
|
+
const renderCard = (item: FeedItem, lead: boolean) => (
|
|
390
|
+
<FeedCard
|
|
391
|
+
decision={item.decision}
|
|
392
|
+
disabledReason={disabledReason}
|
|
393
|
+
item={item}
|
|
394
|
+
key={item.lineageId}
|
|
395
|
+
lead={lead}
|
|
396
|
+
now={now}
|
|
397
|
+
onOpen={() => onOpen(item.lineageId)}
|
|
398
|
+
onRate={async (reference, choice, reasonCode) => { onVisitRated(item.lineageId); await onRate(reference, choice, reasonCode); }}
|
|
399
|
+
predictionLoading={predictionLoading}
|
|
400
|
+
preview={previews.get(item.lineageId)}
|
|
401
|
+
selected={selectedLineageId === item.lineageId}
|
|
402
|
+
visitRated={visitRated.has(item.lineageId)}
|
|
403
|
+
/>
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const empty = (label: string) => (
|
|
407
|
+
<div className="rounded-[20px] bg-[var(--feed-card)] p-10 text-center text-sm text-muted-foreground" data-feed-empty>{label}</div>
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const groups = groupFeedItems(partition.feed);
|
|
411
|
+
const headings = groups.length > 1;
|
|
412
|
+
const leadId = groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<div className="space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
|
|
416
|
+
<div className="flex items-center justify-between gap-2">
|
|
417
|
+
<div aria-label="Feed sections" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" role="tablist">
|
|
418
|
+
{(Object.keys(TAB_LABELS) as ReferenceFeedTabKey[]).map((key) => (
|
|
419
|
+
<button
|
|
420
|
+
aria-selected={tab === key}
|
|
421
|
+
className={cn(PILL, "min-h-9 gap-1.5 px-3.5", tab === key ? "bg-[var(--feed-control)] text-foreground" : "text-muted-foreground hover:text-foreground")}
|
|
422
|
+
data-feed-tab-button={key}
|
|
423
|
+
key={key}
|
|
424
|
+
onClick={() => onTabChange(key)}
|
|
425
|
+
role="tab"
|
|
426
|
+
type="button"
|
|
427
|
+
>
|
|
428
|
+
{TAB_LABELS[key]}
|
|
429
|
+
<span className="text-xs tabular-nums opacity-70" data-feed-tab-count>{counts[key]}</span>
|
|
430
|
+
</button>
|
|
431
|
+
))}
|
|
432
|
+
</div>
|
|
433
|
+
<div className="flex items-center gap-2">
|
|
434
|
+
{stale ? (
|
|
435
|
+
<span className="hidden items-center gap-1 text-xs text-muted-foreground sm:inline-flex" data-feed-stale role="status">
|
|
436
|
+
<Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Showing your last view while it updates
|
|
437
|
+
</span>
|
|
438
|
+
) : null}
|
|
439
|
+
<RankingPopover freshness={partition.freshness} />
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
{stale ? <p className="text-xs text-muted-foreground sm:hidden" role="status"><Loader2Icon aria-hidden="true" className="mr-1 inline size-3.5 animate-spin" />Showing your last view while it updates</p> : null}
|
|
443
|
+
|
|
444
|
+
{loading ? <ReferenceFeedSkeleton /> : (
|
|
445
|
+
<div role="tabpanel">
|
|
446
|
+
{tab === "feed" ? (
|
|
447
|
+
<div>
|
|
448
|
+
{groups.length ? groups.map((group) => (
|
|
449
|
+
<section aria-labelledby={headings ? `feed-group-${group.key}` : undefined} data-feed-group={group.key} key={group.key}>
|
|
450
|
+
{headings ? <SectionHeading id={`feed-group-${group.key}`}>{GROUP_HEADINGS[group.key]}</SectionHeading> : null}
|
|
451
|
+
<div className={GRID} data-feed-grid>{group.items.map((item) => renderCard(item, item.lineageId === leadId))}</div>
|
|
452
|
+
</section>
|
|
453
|
+
)) : empty(counts.rated || counts.earlier ? "You are all caught up. Check Earlier and Rated." : "No references yet.")}
|
|
454
|
+
{partition.earlier.length ? (
|
|
455
|
+
<section aria-labelledby="feed-earlier-heading" data-feed-earlier>
|
|
456
|
+
<SectionHeading id="feed-earlier-heading">Earlier</SectionHeading>
|
|
457
|
+
<p className="-mt-3 mb-5 text-sm text-muted-foreground">Unrated and past their freshness. Still yours to rate.</p>
|
|
458
|
+
<div className={GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div>
|
|
459
|
+
</section>
|
|
460
|
+
) : null}
|
|
461
|
+
</div>
|
|
462
|
+
) : null}
|
|
463
|
+
{tab === "rated" ? (partition.rated.length ? <div className={GRID} data-feed-grid>{partition.rated.map((item) => renderCard(item, false))}</div> : empty("Nothing rated yet. Your Accepts and Rejects collect here, newest first.")) : null}
|
|
464
|
+
{tab === "earlier" ? (partition.earlier.length ? <div className={GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div> : empty("Nothing has aged out. Unrated items that fall past their freshness collect here.")) : null}
|
|
465
|
+
</div>
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
);
|
|
469
|
+
}
|