@anthusai/papyrus 5.43.0-next.2 → 5.43.0-next.21
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/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/content-actions/handler.py +8 -0
- 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-app.mjs +12 -1
- 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/needs-me-bodies.tsx +290 -0
- package/components/needs-me-detail.tsx +23 -0
- package/components/needs-me-feed.tsx +561 -0
- package/components/needs-me-reference-detail.tsx +77 -0
- package/components/needs-me-shared.tsx +39 -0
- 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 +64 -55
- package/components/newsroom-ops-shell.tsx +4 -2
- package/components/newsroom-organizer.css +60 -2
- package/components/newsroom-references-view.tsx +301 -138
- package/components/notification-cards.tsx +369 -0
- package/components/notification-center.tsx +324 -213
- 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-capture-client.ts +110 -0
- package/components/reference-capture-panel.tsx +180 -0
- package/components/reference-feed.tsx +503 -0
- package/components/reference-review-card.tsx +110 -141
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/review-rating-grid.tsx +180 -0
- package/components/topic-steering-workspace.tsx +44 -9
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +134 -0
- 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 +6 -1
- package/lib/content-actions-client.ts +18 -0
- package/lib/local-preview-feed-fixture.ts +63 -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/needs-me-items.ts +289 -0
- package/lib/needs-me-ranking.ts +315 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/notification-feed.ts +212 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-capture.ts +293 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +235 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-client.ts +55 -5
- package/lib/review-drafts.ts +252 -0
- package/lib/review-fixture.ts +56 -2
- package/lib/review-requests.ts +64 -24
- package/lib/site-brand.ts +2 -0
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +29 -1
- package/routes.manifest.json +1 -1
|
@@ -94,6 +94,10 @@ import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
|
|
|
94
94
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
95
95
|
import type { ReaderAuthSnapshot } from "./reader-auth-state";
|
|
96
96
|
import { NewsDeskAccessGate } from "./newsroom-access-gate";
|
|
97
|
+
import { correctReferenceCapture } from "../lib/content-actions-client";
|
|
98
|
+
import { loadMessagesWithHeadStart, loadReferencesWithHeadStart, loadRelationsWithHeadStart } from "./references-prefetch";
|
|
99
|
+
import { loadFeedSnapshot, saveFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
|
|
100
|
+
import { referencePreviewsByLineage } from "../lib/reference-preview";
|
|
97
101
|
import { NewsroomOpsOverview } from "./newsroom-ops-overview";
|
|
98
102
|
import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
|
|
99
103
|
import { NewsroomReferencesView } from "./newsroom-references-view";
|
|
@@ -696,7 +700,7 @@ export function NewsDeskWorkspace({
|
|
|
696
700
|
}
|
|
697
701
|
|
|
698
702
|
if (dashboard?.isPublicSkeleton && session?.shell.phase !== "ready") {
|
|
699
|
-
return <NewsDeskAccessGate shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
|
|
703
|
+
return <NewsDeskAccessGate initialTab={initialTab} shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
|
|
700
704
|
}
|
|
701
705
|
|
|
702
706
|
if (dashboard) {
|
|
@@ -716,7 +720,7 @@ export function NewsDeskWorkspace({
|
|
|
716
720
|
);
|
|
717
721
|
}
|
|
718
722
|
|
|
719
|
-
return <NewsDeskAccessGate shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
|
|
723
|
+
return <NewsDeskAccessGate initialTab={initialTab} shell={session?.shell ?? null} showSectionTabs={showSectionTabs} />;
|
|
720
724
|
}
|
|
721
725
|
|
|
722
726
|
function NewsDeskDashboard({
|
|
@@ -777,9 +781,16 @@ function NewsDeskDashboard({
|
|
|
777
781
|
const [referenceAttachments, setReferenceAttachments] = useState(dashboard.referenceAttachments);
|
|
778
782
|
const [messages, setMessages] = useState(dashboard.messages);
|
|
779
783
|
const [semanticRelations, setSemanticRelations] = useState(dashboard.semanticRelations);
|
|
784
|
+
const [savedFeedCopy, setSavedFeedCopy] = useState<Pick<FeedSnapshot, "references" | "previews" | "decisions"> | null>(null);
|
|
785
|
+
useEffect(() => {
|
|
786
|
+
// Display only: the last view this browser saved, shown until the live read arrives.
|
|
787
|
+
if (dashboard.isDemo) return;
|
|
788
|
+
const snapshot = loadFeedSnapshot(SITE_BRAND.id);
|
|
789
|
+
if (snapshot) setSavedFeedCopy(snapshot);
|
|
790
|
+
}, [dashboard.isDemo]);
|
|
780
791
|
const cyclotronPreviewFixtures = useMemo(
|
|
781
|
-
() => (isLocalPreviewEnabled() ? createLocalPreviewCyclotronFixtures(references) : null),
|
|
782
|
-
[references],
|
|
792
|
+
() => (isLocalPreviewEnabled() && !semanticRelations.some((relation) => relation.predicate === "relevance_decision_is") ? createLocalPreviewCyclotronFixtures(references) : null),
|
|
793
|
+
[references, semanticRelations],
|
|
783
794
|
);
|
|
784
795
|
const relevanceDecisions = useMemo(
|
|
785
796
|
() => currentRelevanceDecisions(cyclotronPreviewFixtures ? [...semanticRelations, ...cyclotronPreviewFixtures.relations] : semanticRelations),
|
|
@@ -1215,7 +1226,7 @@ function NewsDeskDashboard({
|
|
|
1215
1226
|
useEffect(() => {
|
|
1216
1227
|
if (!canLoadPrivateCorpus || hasHydratedReferences) return;
|
|
1217
1228
|
let active = true;
|
|
1218
|
-
void
|
|
1229
|
+
void loadReferencesWithHeadStart()
|
|
1219
1230
|
.then(({ references: nextReferences, referenceAttachments }) => {
|
|
1220
1231
|
if (!active) return;
|
|
1221
1232
|
setReferences(nextReferences);
|
|
@@ -1322,12 +1333,12 @@ function NewsDeskDashboard({
|
|
|
1322
1333
|
refreshNewsroomSections,
|
|
1323
1334
|
]);
|
|
1324
1335
|
|
|
1325
|
-
const needsReferenceReviewData = activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
|
|
1336
|
+
const needsReferenceReviewData = activeTab === "overview" || activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
|
|
1326
1337
|
const selectedReviewLineageId = pathname ? pathnameReferenceLineageId ?? "" : initialSelection.reference ?? "";
|
|
1327
1338
|
const referenceReviewData = useReferenceReviewData({
|
|
1328
1339
|
enabled: canLoadPrivateCorpus && needsReferenceReviewData && !(activeTab === "references" && selectedReviewLineageId),
|
|
1329
|
-
loadRelations:
|
|
1330
|
-
loadMessages:
|
|
1340
|
+
loadRelations: loadRelationsWithHeadStart,
|
|
1341
|
+
loadMessages: loadMessagesWithHeadStart,
|
|
1331
1342
|
onRelations: setSemanticRelations,
|
|
1332
1343
|
onMessages: setMessages,
|
|
1333
1344
|
});
|
|
@@ -1351,6 +1362,18 @@ function NewsDeskDashboard({
|
|
|
1351
1362
|
? selectedReviewRead.lineageId === selectedReviewLineageId ? selectedReviewRead.state : "loading"
|
|
1352
1363
|
: referenceReviewData.state;
|
|
1353
1364
|
const predictionError = selectedReviewIsCurrent ? selectedReviewRead.error : referenceReviewData.error;
|
|
1365
|
+
useEffect(() => {
|
|
1366
|
+
// Keep a display copy for the next visit once everything the feed shows has been read.
|
|
1367
|
+
if (dashboard.isDemo || (activeTab !== "references" && activeTab !== "overview") || !hasHydratedReferences || predictionState !== "ready" || !references.length) return;
|
|
1368
|
+
const timer = window.setTimeout(() => {
|
|
1369
|
+
saveFeedSnapshot(SITE_BRAND.id, {
|
|
1370
|
+
references: selectCanonicalReferenceRecords(references),
|
|
1371
|
+
previews: referencePreviewsByLineage(referenceAttachments),
|
|
1372
|
+
decisions: relevanceDecisions,
|
|
1373
|
+
});
|
|
1374
|
+
}, 800);
|
|
1375
|
+
return () => window.clearTimeout(timer);
|
|
1376
|
+
}, [activeTab, dashboard.isDemo, hasHydratedReferences, predictionState, referenceAttachments, references, relevanceDecisions]);
|
|
1354
1377
|
const retryReferenceReviewData = () => { referenceReviewData.retry(); setSelectedReviewAttempt((current) => current + 1); };
|
|
1355
1378
|
|
|
1356
1379
|
const applyReferenceRecord = useCallback((nextReference: ReferenceRecord) => {
|
|
@@ -3035,7 +3058,7 @@ function NewsDeskDashboard({
|
|
|
3035
3058
|
headerActions={(
|
|
3036
3059
|
<>
|
|
3037
3060
|
{dashboard.isDemo ? (
|
|
3038
|
-
<Badge variant="outline">Demo</Badge>
|
|
3061
|
+
dashboard.realDataPreview ? null : <Badge variant="outline">Demo</Badge>
|
|
3039
3062
|
) : (
|
|
3040
3063
|
<Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
|
|
3041
3064
|
)}
|
|
@@ -3081,9 +3104,18 @@ function NewsDeskDashboard({
|
|
|
3081
3104
|
assignments={assignments}
|
|
3082
3105
|
decisions={relevanceDecisions}
|
|
3083
3106
|
demo={Boolean(dashboard.isDemo)}
|
|
3107
|
+
disabledReason={controlsDisabled ? "Editor access is required to change this reference." : null}
|
|
3084
3108
|
humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
|
|
3109
|
+
loading={!dashboard.isDemo && !hasHydratedReferences}
|
|
3085
3110
|
newsroomSections={newsroomSections}
|
|
3111
|
+
notificationFixture={dashboard.notificationFixture ?? null}
|
|
3112
|
+
onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
|
|
3113
|
+
onReview={(reference, action, options) => runReferenceCurationAction(reference, action, options?.note, options?.reasonCode,
|
|
3114
|
+
options?.decisionRelationId ? { decisionRelationId: options.decisionRelationId, shareable: options.shareable === true } : undefined)}
|
|
3115
|
+
predictionState={referenceReviewData.state}
|
|
3116
|
+
referenceAttachments={referenceAttachments}
|
|
3086
3117
|
references={references}
|
|
3118
|
+
savedCopy={savedFeedCopy}
|
|
3087
3119
|
/>
|
|
3088
3120
|
) : null}
|
|
3089
3121
|
{activeTab === "search" ? (
|
|
@@ -3182,6 +3214,9 @@ function NewsDeskDashboard({
|
|
|
3182
3214
|
options?.sectionId)}
|
|
3183
3215
|
referenceAttachments={referenceAttachments}
|
|
3184
3216
|
references={references}
|
|
3217
|
+
loading={!dashboard.isDemo && !hasHydratedReferences}
|
|
3218
|
+
onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
|
|
3219
|
+
savedCopy={savedFeedCopy}
|
|
3185
3220
|
/>
|
|
3186
3221
|
</NewsroomCurationBootstrap>
|
|
3187
3222
|
) : null}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/** True while the media query matches. False on the server and before the first effect, so the first paint is the wide layout. */
|
|
6
|
+
export function useMediaQuery(query: string): boolean {
|
|
7
|
+
const [matches, setMatches] = useState(false);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const mediaQuery = window.matchMedia(query);
|
|
10
|
+
const update = () => setMatches(mediaQuery.matches);
|
|
11
|
+
update();
|
|
12
|
+
mediaQuery.addEventListener("change", update);
|
|
13
|
+
return () => mediaQuery.removeEventListener("change", update);
|
|
14
|
+
}, [query]);
|
|
15
|
+
return matches;
|
|
16
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
|
|
5
|
+
import { referenceLineageId } from "../lib/newsroom-references";
|
|
6
|
+
import { loadReferenceAttachmentsForLineageId, loadStoragePathUrl } from "./news-desk-taxonomy-client";
|
|
7
|
+
|
|
8
|
+
function normalizeReferenceDetailHttpUri(value: string | null | undefined): string | null {
|
|
9
|
+
const trimmed = value?.trim();
|
|
10
|
+
if (!trimmed) return null;
|
|
11
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The attachments of one Reference (seeded, else read), with stored files turned into links. Shared by the References detail pane and the Needs me detail pane. */
|
|
16
|
+
export function useReferencePreviewAttachments(
|
|
17
|
+
reference: ReferenceRecord | null,
|
|
18
|
+
referenceAttachments: ReferenceAttachmentRecord[],
|
|
19
|
+
) {
|
|
20
|
+
const lineageId = reference ? referenceLineageId(reference) : "";
|
|
21
|
+
const seededAttachments = useMemo(
|
|
22
|
+
() => referenceAttachments.filter((attachment) => attachment.referenceLineageId === lineageId),
|
|
23
|
+
[lineageId, referenceAttachments],
|
|
24
|
+
);
|
|
25
|
+
const [attachments, setAttachments] = useState<ReferenceAttachmentRecord[]>(seededAttachments);
|
|
26
|
+
const [attachmentLinksById, setAttachmentLinksById] = useState<Record<string, string>>({});
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setAttachments(seededAttachments);
|
|
30
|
+
}, [seededAttachments]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!lineageId) return;
|
|
34
|
+
if (seededAttachments.length > 0) return;
|
|
35
|
+
let active = true;
|
|
36
|
+
void loadReferenceAttachmentsForLineageId(lineageId)
|
|
37
|
+
.then((loaded) => {
|
|
38
|
+
if (!active || !loaded.length) return;
|
|
39
|
+
setAttachments(loaded);
|
|
40
|
+
})
|
|
41
|
+
.catch(() => undefined);
|
|
42
|
+
return () => {
|
|
43
|
+
active = false;
|
|
44
|
+
};
|
|
45
|
+
}, [lineageId, seededAttachments.length]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!attachments.length) {
|
|
49
|
+
setAttachmentLinksById({});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
let active = true;
|
|
53
|
+
const staticLinks = new Map<string, string>();
|
|
54
|
+
const storageLookups: Array<{ id: string; storagePath: string }> = [];
|
|
55
|
+
|
|
56
|
+
for (const attachment of attachments) {
|
|
57
|
+
if (attachment.storagePath) {
|
|
58
|
+
storageLookups.push({ id: attachment.id, storagePath: attachment.storagePath });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const sourceHref = normalizeReferenceDetailHttpUri(attachment.sourceUri);
|
|
62
|
+
if (sourceHref) staticLinks.set(attachment.id, sourceHref);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!storageLookups.length) {
|
|
66
|
+
setAttachmentLinksById(Object.fromEntries(staticLinks.entries()));
|
|
67
|
+
return () => {
|
|
68
|
+
active = false;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setAttachmentLinksById(Object.fromEntries(staticLinks.entries()));
|
|
73
|
+
void Promise.all(storageLookups.map(async (lookup) => {
|
|
74
|
+
const result = await loadStoragePathUrl(lookup.storagePath);
|
|
75
|
+
return { id: lookup.id, url: result.url };
|
|
76
|
+
}))
|
|
77
|
+
.then((resolved) => {
|
|
78
|
+
if (!active) return;
|
|
79
|
+
const nextLinks = Object.fromEntries(staticLinks.entries());
|
|
80
|
+
for (const entry of resolved) {
|
|
81
|
+
if (entry.url) nextLinks[entry.id] = entry.url;
|
|
82
|
+
}
|
|
83
|
+
setAttachmentLinksById(nextLinks);
|
|
84
|
+
})
|
|
85
|
+
.catch(() => undefined);
|
|
86
|
+
|
|
87
|
+
return () => {
|
|
88
|
+
active = false;
|
|
89
|
+
};
|
|
90
|
+
}, [attachments]);
|
|
91
|
+
|
|
92
|
+
const previewAttachments = useMemo(
|
|
93
|
+
() => attachments.map((attachment) => ({
|
|
94
|
+
...attachment,
|
|
95
|
+
sourceUri: attachmentLinksById[attachment.id] ?? attachment.sourceUri,
|
|
96
|
+
})),
|
|
97
|
+
[attachmentLinksById, attachments],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return previewAttachments;
|
|
101
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
5
|
+
import type { ReviewChoice } from "./reference-review-card";
|
|
6
|
+
import type { RatingFeedback } from "./review-rating-grid";
|
|
7
|
+
|
|
8
|
+
/** How long after the last keystroke a typed reason is saved on its own. */
|
|
9
|
+
export const NOTE_AUTOSAVE_MS = 1200;
|
|
10
|
+
|
|
11
|
+
export type ItemRating = {
|
|
12
|
+
/** The reason as typed, else as saved. In the store itself it stays undefined until touched, so an untouched field follows the record. */
|
|
13
|
+
note?: string;
|
|
14
|
+
reasonCode?: ReferenceRejectionReasonCode | null;
|
|
15
|
+
/** A vote that is waiting for a reason (Reject needs one) or is still saving. */
|
|
16
|
+
pending: ReviewChoice | null;
|
|
17
|
+
/** True while a Reject is waiting for its reason. */
|
|
18
|
+
askReason: boolean;
|
|
19
|
+
feedback: RatingFeedback;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const EMPTY: ItemRating = { pending: null, askReason: false, feedback: { phase: "idle" } };
|
|
23
|
+
|
|
24
|
+
export type RatingSave = (id: string, choice: ReviewChoice, detail: { note: string; reasonCode: ReferenceRejectionReasonCode | null }) => Promise<void> | void;
|
|
25
|
+
|
|
26
|
+
export type RatingController = {
|
|
27
|
+
get: (id: string) => ItemRating;
|
|
28
|
+
choose: (id: string, choice: ReviewChoice) => void;
|
|
29
|
+
setReason: (id: string, code: ReferenceRejectionReasonCode) => void;
|
|
30
|
+
setNote: (id: string, text: string) => void;
|
|
31
|
+
/** Save a typed reason now (on blur). */
|
|
32
|
+
commitNote: (id: string) => void;
|
|
33
|
+
cancelReason: (id: string) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One store for the rating state of every item, shared by the grid card and the detail pane so a vote or a reason typed in either
|
|
38
|
+
* shows in both at once. `saved(id)` is the saved human answer; `seed(id)` the saved note and reason code.
|
|
39
|
+
* vote, then reason -> the reason is saved onto the existing decision (on blur, or after a short pause)
|
|
40
|
+
* reason, then vote -> the vote carries the reason
|
|
41
|
+
* A Reject needs a reason code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
|
|
42
|
+
* kept in the store until it is saved and is never dropped by a failed save.
|
|
43
|
+
*/
|
|
44
|
+
export function useRatingStore({ save, saved, seed, needsReason }: {
|
|
45
|
+
save: RatingSave;
|
|
46
|
+
saved: (id: string) => ReviewChoice | null;
|
|
47
|
+
seed: (id: string) => { note: string; reasonCode: ReferenceRejectionReasonCode | null };
|
|
48
|
+
/** Whether a Reject on this item must wait for a reason code. Default yes (a Reference); an AI draft or an email asks nothing. */
|
|
49
|
+
needsReason?: (id: string) => boolean;
|
|
50
|
+
}): RatingController {
|
|
51
|
+
const [items, setItems] = useState<Record<string, ItemRating>>({});
|
|
52
|
+
const itemsRef = useRef(items);
|
|
53
|
+
const latest = useRef({ save, saved, seed, needsReason });
|
|
54
|
+
latest.current = { save, saved, seed, needsReason };
|
|
55
|
+
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
56
|
+
const inFlight = useRef<Set<string>>(new Set());
|
|
57
|
+
const dirty = useRef<Set<string>>(new Set());
|
|
58
|
+
|
|
59
|
+
const patch = useCallback((id: string, change: Partial<ItemRating>) => {
|
|
60
|
+
const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
|
|
61
|
+
itemsRef.current = next;
|
|
62
|
+
setItems(next);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
// What a reader of the store sees: the touched value, else what is saved. (A saved reason therefore shows on the card too.)
|
|
66
|
+
const get = useCallback((id: string): ItemRating => {
|
|
67
|
+
const item = items[id] ?? EMPTY;
|
|
68
|
+
const seeded = latest.current.seed(id);
|
|
69
|
+
return { ...item, note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
70
|
+
}, [items]);
|
|
71
|
+
|
|
72
|
+
const values = (id: string) => {
|
|
73
|
+
const item = itemsRef.current[id] ?? EMPTY;
|
|
74
|
+
const seeded = latest.current.seed(id);
|
|
75
|
+
return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
79
|
+
const { note, reasonCode } = values(id);
|
|
80
|
+
if (choice === "reject" && !reasonCode && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
81
|
+
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
82
|
+
inFlight.current.add(id);
|
|
83
|
+
dirty.current.delete(id);
|
|
84
|
+
const started = itemsRef.current[id] ?? EMPTY;
|
|
85
|
+
patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
|
|
86
|
+
try {
|
|
87
|
+
await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
|
|
88
|
+
// What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
|
|
89
|
+
const now = itemsRef.current[id] ?? EMPTY;
|
|
90
|
+
patch(id, { pending: null, feedback: { phase: "saved" }, ...(now.note === started.note ? { note: undefined } : {}), ...(now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
|
|
91
|
+
} catch (failure) {
|
|
92
|
+
// The typed reason stays in the store; only the vote is undone.
|
|
93
|
+
patch(id, { pending: null, feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." } });
|
|
94
|
+
} finally {
|
|
95
|
+
inFlight.current.delete(id);
|
|
96
|
+
}
|
|
97
|
+
if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id); if (again) void commit(id, again); }
|
|
98
|
+
}, [patch]);
|
|
99
|
+
|
|
100
|
+
const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
|
|
101
|
+
|
|
102
|
+
const choose = useCallback((id: string, choice: ReviewChoice) => {
|
|
103
|
+
if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
|
|
104
|
+
void commit(id, choice);
|
|
105
|
+
}, [commit]);
|
|
106
|
+
|
|
107
|
+
const setReason = useCallback((id: string, code: ReferenceRejectionReasonCode) => {
|
|
108
|
+
patch(id, { reasonCode: code });
|
|
109
|
+
// Vote then reason: onto the existing (or waiting) decision. Reason then vote: stored, and the vote carries it.
|
|
110
|
+
if (effective(id) === "reject") void commit(id, "reject");
|
|
111
|
+
}, [commit, patch]);
|
|
112
|
+
|
|
113
|
+
const setNote = useCallback((id: string, text: string) => {
|
|
114
|
+
patch(id, { note: text, feedback: { phase: "idle" } });
|
|
115
|
+
dirty.current.add(`note:${id}`);
|
|
116
|
+
clearTimeout(timers.current[id]);
|
|
117
|
+
timers.current[id] = setTimeout(() => {
|
|
118
|
+
const choice = effective(id);
|
|
119
|
+
if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
|
|
120
|
+
}, NOTE_AUTOSAVE_MS);
|
|
121
|
+
}, [commit, patch]);
|
|
122
|
+
|
|
123
|
+
const commitNote = useCallback((id: string) => {
|
|
124
|
+
clearTimeout(timers.current[id]);
|
|
125
|
+
const choice = effective(id);
|
|
126
|
+
if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
|
|
127
|
+
}, [commit]);
|
|
128
|
+
|
|
129
|
+
const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
|
|
130
|
+
|
|
131
|
+
useEffect(() => () => { for (const timer of Object.values(timers.current)) clearTimeout(timer); }, []);
|
|
132
|
+
|
|
133
|
+
return { get, choose, setReason, setNote, commitNote, cancelReason };
|
|
134
|
+
}
|
|
@@ -6,10 +6,26 @@ import { addCloudEmail } from "./cloud-email.js";
|
|
|
6
6
|
import { parseCloudEmail } from "./cloud-email-config.js";
|
|
7
7
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider.js";
|
|
8
8
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
9
|
-
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
9
|
+
import { PROSPECTUS_PAPYRUS_SITE_ROUTES, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveChatStackName, chatExportName, CHAT_EXPORTED_RESOURCES, CHAT_ENVIRONMENT_NAMES, CHAT_PRIVATE_ASSISTANT_ID, CHAT_PRIVATE_ASSISTANT_NAME, resolveStoragePreviewPrefix } from "./site-config.js";
|
|
10
10
|
function environmentVariables(variables) {
|
|
11
11
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The production branch's private chat environment. Only a site with an enabled `chat` block gets the flag and the
|
|
15
|
+
* resource names (imported from the chat data stack's exports, so that stack must be deployed first); every other
|
|
16
|
+
* site keeps the flag "false" and no chat variables, and its newsroom serves neither the chat route nor the UI.
|
|
17
|
+
*/
|
|
18
|
+
function chatEnvironment(config) {
|
|
19
|
+
const chatStackName = resolveChatStackName(config);
|
|
20
|
+
if (!chatStackName)
|
|
21
|
+
return { PAPYRUS_PRIVATE_CHAT_ENABLED: "false" };
|
|
22
|
+
return {
|
|
23
|
+
PAPYRUS_PRIVATE_CHAT_ENABLED: "true",
|
|
24
|
+
...Object.fromEntries(CHAT_EXPORTED_RESOURCES.map((resource) => [CHAT_ENVIRONMENT_NAMES[resource], Fn.importValue(chatExportName(chatStackName, resource))])),
|
|
25
|
+
PAPYRUS_CHAT_PRIVATE_BOT_ID: CHAT_PRIVATE_ASSISTANT_ID,
|
|
26
|
+
PAPYRUS_CHAT_PRIVATE_BOT_NAME: CHAT_PRIVATE_ASSISTANT_NAME,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
13
29
|
function readerCustomHeaders(rules) {
|
|
14
30
|
if (!rules)
|
|
15
31
|
return undefined;
|
|
@@ -199,7 +215,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
199
215
|
environmentVariables: environmentVariables({
|
|
200
216
|
...config.cms.environment,
|
|
201
217
|
...emailEnvironment,
|
|
202
|
-
PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
|
|
218
|
+
...(config.chat?.enabled ? chatEnvironment(config) : { PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false" }),
|
|
203
219
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
204
220
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
205
221
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|
|
@@ -7,12 +7,28 @@ import { addCloudEmail } from "./cloud-email";
|
|
|
7
7
|
import { parseCloudEmail } from "./cloud-email-config";
|
|
8
8
|
import { GITHUB_OIDC_PROVIDER_HOST } from "./github-oidc-provider";
|
|
9
9
|
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs";
|
|
10
|
-
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveStoragePreviewPrefix } from "./site-config";
|
|
10
|
+
import { AmplifyAppShellSiteConfig, PROSPECTUS_PAPYRUS_SITE_ROUTES, ReaderCustomHeaderRule, hasCmsApp, isStagingEnabled, resolveRedirectSubDomainPrefix, resolveCmsHostName, resolveSiteOrigin, githubOidcSubjects, resolveGithubRepository, resolveProspectusAccountsEndpoint, resolveProspectusSessionSealingParameterName, resolveReaderPollSecretParameterName, resolveRevalidateSecretParameterName, resolveStackName, resolveStagingDomainName, resolveChatStackName, chatExportName, CHAT_EXPORTED_RESOURCES, CHAT_ENVIRONMENT_NAMES, CHAT_PRIVATE_ASSISTANT_ID, CHAT_PRIVATE_ASSISTANT_NAME, resolveStoragePreviewPrefix } from "./site-config";
|
|
11
11
|
|
|
12
12
|
function environmentVariables(variables: Record<string, string>): amplify.CfnBranch.EnvironmentVariableProperty[] {
|
|
13
13
|
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The production branch's private chat environment. Only a site with an enabled `chat` block gets the flag and the
|
|
18
|
+
* resource names (imported from the chat data stack's exports, so that stack must be deployed first); every other
|
|
19
|
+
* site keeps the flag "false" and no chat variables, and its newsroom serves neither the chat route nor the UI.
|
|
20
|
+
*/
|
|
21
|
+
function chatEnvironment(config: AmplifyAppShellSiteConfig): Record<string, string> {
|
|
22
|
+
const chatStackName = resolveChatStackName(config);
|
|
23
|
+
if (!chatStackName) return { PAPYRUS_PRIVATE_CHAT_ENABLED: "false" };
|
|
24
|
+
return {
|
|
25
|
+
PAPYRUS_PRIVATE_CHAT_ENABLED: "true",
|
|
26
|
+
...Object.fromEntries(CHAT_EXPORTED_RESOURCES.map((resource) => [CHAT_ENVIRONMENT_NAMES[resource], Fn.importValue(chatExportName(chatStackName, resource))])),
|
|
27
|
+
PAPYRUS_CHAT_PRIVATE_BOT_ID: CHAT_PRIVATE_ASSISTANT_ID,
|
|
28
|
+
PAPYRUS_CHAT_PRIVATE_BOT_NAME: CHAT_PRIVATE_ASSISTANT_NAME,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
function readerCustomHeaders(rules: ReaderCustomHeaderRule[] | undefined): string | undefined {
|
|
17
33
|
if (!rules) return undefined;
|
|
18
34
|
const lines = ["customHeaders:"];
|
|
@@ -221,7 +237,7 @@ export class AmplifyAppShellStack extends Stack {
|
|
|
221
237
|
environmentVariables: environmentVariables({
|
|
222
238
|
...config.cms.environment,
|
|
223
239
|
...emailEnvironment,
|
|
224
|
-
PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false",
|
|
240
|
+
...(config.chat?.enabled ? chatEnvironment(config) : { PAPYRUS_PRIVATE_CHAT_ENABLED: config.cms.environment.PAPYRUS_PRIVATE_CHAT_ENABLED ?? "false" }),
|
|
225
241
|
PAPYRUS_COGNITO_DOMAIN_PREFIX: config.cms.cognitoDomainPrefix,
|
|
226
242
|
...(config.cms.applyCognitoDomainPrefix ? { PAPYRUS_APPLY_COGNITO_DOMAIN_PREFIX: "true" } : {}),
|
|
227
243
|
PAPYRUS_OAUTH_REDIRECT_URLS: oauthRedirectUrls,
|