@anthusai/papyrus 5.39.0 → 5.41.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/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/content-actions/handler.py +10 -1
- package/amplify/functions/monitoring-view/schema.js +12 -0
- package/amplify/functions/monitoring-view/schema.ts +12 -0
- package/amplify/functions/monitoring-view/wire.js +25 -0
- package/amplify/functions/monitoring-view/wire.ts +32 -0
- package/amplify/site-backend.js +2 -1
- package/amplify/site-backend.ts +2 -1
- package/app/api/newsroom/chat/[...path]/route.ts +17 -0
- package/app/layout.tsx +5 -1
- package/components/monitoring-overview-view.tsx +182 -9
- package/components/monitoring-panel.tsx +23 -3
- package/components/notification-center.tsx +12 -8
- package/components/papyrus-console-conversation.css +14 -0
- package/components/papyrus-console-conversation.tsx +55 -0
- package/components/papyrus-console-shell.tsx +62 -420
- package/components/papyrus-private-chat-panel.tsx +134 -0
- package/infra/amplify-app-shell.js +2 -0
- package/infra/amplify-app-shell.ts +2 -0
- package/infra/site-config.js +4 -0
- package/infra/site-config.ts +4 -0
- package/lib/monitoring-client.ts +69 -15
- package/lib/monitoring-overview-fixture.ts +1 -1
- package/lib/monitoring-overview.ts +48 -5
- package/lib/papyrus-chat-admission.ts +65 -0
- package/lib/papyrus-hosted-chat.ts +217 -0
- package/lib/papyrus-private-chat-client.ts +81 -0
- package/lib/prospectus-session.ts +15 -0
- package/lib/prospectus-sign-in-routes.ts +2 -12
- package/lib/review-client.ts +6 -3
- package/lib/review-requests.ts +8 -6
- package/lib/shared-chat-admission.ts +182 -0
- package/lib/site-origin-value.ts +50 -0
- package/lib/site-origin.ts +3 -62
- package/package.json +10 -1
- package/routes.manifest.json +13 -1
- package/src/with-papyrus.mjs +1 -1
package/amplify/data/resource.js
CHANGED
|
@@ -597,6 +597,12 @@ const schema = a.schema({
|
|
|
597
597
|
.returns(a.json())
|
|
598
598
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
599
599
|
.handler(functionHandler("contentActions", contentActions)),
|
|
600
|
+
intakeEmailSubmissionReferences: a
|
|
601
|
+
.mutation()
|
|
602
|
+
.arguments({ input: a.json().required() })
|
|
603
|
+
.returns(a.json())
|
|
604
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
605
|
+
.handler(functionHandler("contentActions", contentActions)),
|
|
600
606
|
saveItemDraft: a
|
|
601
607
|
.mutation()
|
|
602
608
|
.arguments({ input: a.json().required() })
|
package/amplify/data/resource.ts
CHANGED
|
@@ -650,6 +650,13 @@ const schema = a.schema({
|
|
|
650
650
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
651
651
|
.handler(functionHandler("contentActions", contentActions)),
|
|
652
652
|
|
|
653
|
+
intakeEmailSubmissionReferences: a
|
|
654
|
+
.mutation()
|
|
655
|
+
.arguments({ input: a.json().required() })
|
|
656
|
+
.returns(a.json())
|
|
657
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
658
|
+
.handler(functionHandler("contentActions", contentActions)),
|
|
659
|
+
|
|
653
660
|
saveItemDraft: a
|
|
654
661
|
.mutation()
|
|
655
662
|
.arguments({ input: a.json().required() })
|
package/amplify/data/schema.js
CHANGED
|
@@ -523,6 +523,12 @@ function buildBaseDefinitions(includeEditions) {
|
|
|
523
523
|
.returns(a.json())
|
|
524
524
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
525
525
|
.handler(functionHandler("contentActions", contentActions)),
|
|
526
|
+
intakeEmailSubmissionReferences: a
|
|
527
|
+
.mutation()
|
|
528
|
+
.arguments({ input: a.json().required() })
|
|
529
|
+
.returns(a.json())
|
|
530
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
531
|
+
.handler(functionHandler("contentActions", contentActions)),
|
|
526
532
|
saveItemDraft: a
|
|
527
533
|
.mutation()
|
|
528
534
|
.arguments({ input: a.json().required() })
|
package/amplify/data/schema.ts
CHANGED
|
@@ -574,6 +574,13 @@ const definitions = {
|
|
|
574
574
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
575
575
|
.handler(functionHandler("contentActions", contentActions)),
|
|
576
576
|
|
|
577
|
+
intakeEmailSubmissionReferences: a
|
|
578
|
+
.mutation()
|
|
579
|
+
.arguments({ input: a.json().required() })
|
|
580
|
+
.returns(a.json())
|
|
581
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
582
|
+
.handler(functionHandler("contentActions", contentActions)),
|
|
583
|
+
|
|
577
584
|
saveItemDraft: a
|
|
578
585
|
.mutation()
|
|
579
586
|
.arguments({ input: a.json().required() })
|
|
@@ -27,7 +27,7 @@ from papyrus_content.rebuild_trigger import (
|
|
|
27
27
|
from papyrus_content.story_reporting import save_story_version
|
|
28
28
|
from papyrus_content.publication import ensure_publication_from_environment
|
|
29
29
|
from papyrus_content.editorial_checks import GraphQLEditorialStore, ensure_default_policies
|
|
30
|
-
from papyrus_content.reference_intake import IntakeError, intake_references
|
|
30
|
+
from papyrus_content.reference_intake import IntakeError, intake_email_submission_references, intake_references
|
|
31
31
|
from papyrus_content.publish_alerts import PUBLISHED_TASK_KEY, announce_published
|
|
32
32
|
from papyrus_content.publishing import (
|
|
33
33
|
ItemFields,
|
|
@@ -356,6 +356,15 @@ def handler(event, context):
|
|
|
356
356
|
return intake_references(client, arguments, event.get("identity") or {})
|
|
357
357
|
except IntakeError as error:
|
|
358
358
|
return {"ok": False, "errors": [{"code": "intake-rejected", "message": str(error)}]}
|
|
359
|
+
if field == "intakeEmailSubmissionReferences":
|
|
360
|
+
client, _claims = create_authoring_client()
|
|
361
|
+
try:
|
|
362
|
+
from papyrus_content.monitoring_view import default_store_factory
|
|
363
|
+
receipt_store = default_store_factory(os.environ)()
|
|
364
|
+
return intake_email_submission_references(
|
|
365
|
+
client, arguments, event.get("identity") or {}, receipt_store=receipt_store)
|
|
366
|
+
except IntakeError as error:
|
|
367
|
+
return {"ok": False, "errors": [{"code": "intake-rejected", "message": str(error)}]}
|
|
359
368
|
if field == "deriveMarkus":
|
|
360
369
|
return derive_markus_action(arguments)
|
|
361
370
|
if field == "saveItemDraft":
|
|
@@ -14,4 +14,16 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
14
14
|
.returns(a.json())
|
|
15
15
|
.authorization((allow) => [allow.groups(editors)])
|
|
16
16
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
17
|
+
monitoringEmailSubmission: a
|
|
18
|
+
.query()
|
|
19
|
+
.arguments({ receiptId: a.string().required() })
|
|
20
|
+
.returns(a.json())
|
|
21
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
22
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
23
|
+
monitoringResolveEmailArticleLink: a
|
|
24
|
+
.mutation()
|
|
25
|
+
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
26
|
+
.returns(a.json())
|
|
27
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
28
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
17
29
|
};
|
|
@@ -16,4 +16,16 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
16
16
|
.returns(a.json())
|
|
17
17
|
.authorization((allow) => [allow.groups(editors)])
|
|
18
18
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
19
|
+
monitoringEmailSubmission: a
|
|
20
|
+
.query()
|
|
21
|
+
.arguments({ receiptId: a.string().required() })
|
|
22
|
+
.returns(a.json())
|
|
23
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
24
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
25
|
+
monitoringResolveEmailArticleLink: a
|
|
26
|
+
.mutation()
|
|
27
|
+
.arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
|
|
28
|
+
.returns(a.json())
|
|
29
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
30
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
19
31
|
};
|
|
@@ -34,4 +34,29 @@ export function wireMonitoringView(backend) {
|
|
|
34
34
|
actions: MONITORING_VIEW_TABLE_ACTIONS,
|
|
35
35
|
resources: monitoringViewTableArns(stack.region, stack.account),
|
|
36
36
|
}));
|
|
37
|
+
// Resolving a selected newsletter article records its destination in the
|
|
38
|
+
// same receipt partition. The editor-facing resolver validates the receipt
|
|
39
|
+
// and candidate before this single-key write.
|
|
40
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
41
|
+
effect: Effect.ALLOW,
|
|
42
|
+
actions: ["dynamodb:PutItem"],
|
|
43
|
+
resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
|
|
44
|
+
conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": ["MONITOR#hn"] } },
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
/** Lets the authenticated Reference-intake action verify a selected email receipt. */
|
|
48
|
+
export function wireEmailReferenceIntake(lambda) {
|
|
49
|
+
const stack = Stack.of(lambda);
|
|
50
|
+
lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
|
|
51
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
52
|
+
effect: Effect.ALLOW,
|
|
53
|
+
actions: ["ssm:GetParameter"],
|
|
54
|
+
resources: [monitoringViewParameterArn(stack.region, stack.account)],
|
|
55
|
+
}));
|
|
56
|
+
lambda.addToRolePolicy(new PolicyStatement({
|
|
57
|
+
effect: Effect.ALLOW,
|
|
58
|
+
actions: MONITORING_VIEW_TABLE_ACTIONS,
|
|
59
|
+
resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
|
|
60
|
+
conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": ["MONITOR#hn"] } },
|
|
61
|
+
}));
|
|
37
62
|
}
|
|
@@ -44,4 +44,36 @@ export function wireMonitoringView(backend: any): void {
|
|
|
44
44
|
resources: monitoringViewTableArns(stack.region, stack.account),
|
|
45
45
|
}),
|
|
46
46
|
);
|
|
47
|
+
// Resolving a selected newsletter article records its destination in the
|
|
48
|
+
// same receipt partition. The editor-facing resolver validates the receipt
|
|
49
|
+
// and candidate before this single-key write.
|
|
50
|
+
lambda.addToRolePolicy(
|
|
51
|
+
new PolicyStatement({
|
|
52
|
+
effect: Effect.ALLOW,
|
|
53
|
+
actions: ["dynamodb:PutItem"],
|
|
54
|
+
resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
|
|
55
|
+
conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": ["MONITOR#hn"] } },
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Lets the authenticated Reference-intake action verify a selected email receipt. */
|
|
61
|
+
export function wireEmailReferenceIntake(lambda: LambdaFunction): void {
|
|
62
|
+
const stack = Stack.of(lambda);
|
|
63
|
+
lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
|
|
64
|
+
lambda.addToRolePolicy(
|
|
65
|
+
new PolicyStatement({
|
|
66
|
+
effect: Effect.ALLOW,
|
|
67
|
+
actions: ["ssm:GetParameter"],
|
|
68
|
+
resources: [monitoringViewParameterArn(stack.region, stack.account)],
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
lambda.addToRolePolicy(
|
|
72
|
+
new PolicyStatement({
|
|
73
|
+
effect: Effect.ALLOW,
|
|
74
|
+
actions: MONITORING_VIEW_TABLE_ACTIONS,
|
|
75
|
+
resources: monitoringViewTableArns(stack.region, stack.account).slice(0, 1),
|
|
76
|
+
conditions: { "ForAllValues:StringEquals": { "dynamodb:LeadingKeys": ["MONITOR#hn"] } },
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
47
79
|
}
|
package/amplify/site-backend.js
CHANGED
|
@@ -25,7 +25,7 @@ import { editorialChecks } from "./functions/editorial-checks/resource.js";
|
|
|
25
25
|
import { automationView } from "./functions/automation-view/resource.js";
|
|
26
26
|
import { wireAutomationView } from "./functions/automation-view/wire.js";
|
|
27
27
|
import { monitoringView } from "./functions/monitoring-view/resource.js";
|
|
28
|
-
import { wireMonitoringView } from "./functions/monitoring-view/wire.js";
|
|
28
|
+
import { wireEmailReferenceIntake, wireMonitoringView } from "./functions/monitoring-view/wire.js";
|
|
29
29
|
import { wireEditorialChecks } from "./functions/editorial-checks/wire.js";
|
|
30
30
|
import { resolvePlugins } from "./plugins/registry.js";
|
|
31
31
|
import { wirePlugins } from "./plugins/wire.js";
|
|
@@ -336,6 +336,7 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
336
336
|
// and publication to this deployment's pool and existing API endpoint.
|
|
337
337
|
contentActionsLambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
|
|
338
338
|
contentActionsLambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
|
|
339
|
+
wireEmailReferenceIntake(contentActionsLambda);
|
|
339
340
|
contentActionsLambda.addToRolePolicy(new PolicyStatement({
|
|
340
341
|
actions: ["appsync:GraphQL"],
|
|
341
342
|
resources: ["*"],
|
package/amplify/site-backend.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { editorialChecks } from "./functions/editorial-checks/resource";
|
|
|
32
32
|
import { automationView } from "./functions/automation-view/resource";
|
|
33
33
|
import { wireAutomationView } from "./functions/automation-view/wire";
|
|
34
34
|
import { monitoringView } from "./functions/monitoring-view/resource";
|
|
35
|
-
import { wireMonitoringView } from "./functions/monitoring-view/wire";
|
|
35
|
+
import { wireEmailReferenceIntake, wireMonitoringView } from "./functions/monitoring-view/wire";
|
|
36
36
|
import { wireEditorialChecks } from "./functions/editorial-checks/wire";
|
|
37
37
|
import { resolvePlugins } from "./plugins/registry";
|
|
38
38
|
import { wirePlugins } from "./plugins/wire";
|
|
@@ -429,6 +429,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
429
429
|
"PAPYRUS_GRAPHQL_ENDPOINT",
|
|
430
430
|
backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl,
|
|
431
431
|
);
|
|
432
|
+
wireEmailReferenceIntake(contentActionsLambda);
|
|
432
433
|
contentActionsLambda.addToRolePolicy(
|
|
433
434
|
new PolicyStatement({
|
|
434
435
|
actions: ["appsync:GraphQL"],
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
import { handlePapyrusHostedChatRequest } from "../../../../../lib/papyrus-hosted-chat";
|
|
3
|
+
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
export const runtime = "nodejs";
|
|
6
|
+
|
|
7
|
+
type RouteContext = { params: Promise<{ path: string[] }> };
|
|
8
|
+
|
|
9
|
+
export async function GET(request: NextRequest, context: RouteContext) {
|
|
10
|
+
const { path } = await context.params;
|
|
11
|
+
return handlePapyrusHostedChatRequest(request, path);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function POST(request: NextRequest, context: RouteContext) {
|
|
15
|
+
const { path } = await context.params;
|
|
16
|
+
return handlePapyrusHostedChatRequest(request, path);
|
|
17
|
+
}
|
package/app/layout.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import "chatticus/chat.css";
|
|
2
|
+
import "../components/papyrus-console-conversation.css";
|
|
1
3
|
import type { Metadata } from "next";
|
|
2
4
|
import { Playfair_Display, Plus_Jakarta_Sans } from "next/font/google";
|
|
3
5
|
import Script from "next/script";
|
|
@@ -152,7 +154,9 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
|
|
|
152
154
|
{showStagingBanner() ? <StagingBanner /> : null}
|
|
153
155
|
<AmplifyClientProvider>
|
|
154
156
|
<TooltipProvider>
|
|
155
|
-
<PapyrusConsoleShell
|
|
157
|
+
<PapyrusConsoleShell privateChatEnabled={process.env.PAPYRUS_PRIVATE_CHAT_ENABLED === "true"}>
|
|
158
|
+
{children}
|
|
159
|
+
</PapyrusConsoleShell>
|
|
156
160
|
</TooltipProvider>
|
|
157
161
|
</AmplifyClientProvider>
|
|
158
162
|
</body>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
1
2
|
import Link from "next/link";
|
|
3
|
+
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
2
4
|
import {
|
|
3
5
|
confidenceText,
|
|
4
6
|
describeDelivery,
|
|
@@ -7,8 +9,15 @@ import {
|
|
|
7
9
|
formatUtc,
|
|
8
10
|
referenceHref,
|
|
9
11
|
routingLabel,
|
|
10
|
-
|
|
12
|
+
beginEmailSubmissionAction,
|
|
13
|
+
endEmailSubmissionAction,
|
|
14
|
+
isCurrentEmailSubmissionAction,
|
|
11
15
|
type MonitoringItem,
|
|
16
|
+
type MonitoringEmailDetails,
|
|
17
|
+
type MonitoringEmailArticleResolution,
|
|
18
|
+
type EmailReferenceImportItem,
|
|
19
|
+
type EmailReferenceImportOutcome,
|
|
20
|
+
type MonitoringEmailSummary,
|
|
12
21
|
type MonitoringOverview,
|
|
13
22
|
type MonitoringRelevance,
|
|
14
23
|
type MonitoringResult,
|
|
@@ -88,8 +97,160 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
|
|
|
88
97
|
);
|
|
89
98
|
}
|
|
90
99
|
|
|
91
|
-
function
|
|
92
|
-
|
|
100
|
+
function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
|
|
101
|
+
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen: (receiptId: string) => void;
|
|
102
|
+
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
103
|
+
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
104
|
+
}) {
|
|
105
|
+
const [selectedByReceipt, setSelectedByReceipt] = useState<Record<string, Record<number, boolean>>>({});
|
|
106
|
+
const [resolvedByReceipt, setResolvedByReceipt] = useState<Record<string, Record<number, { url: string; title: string }>>>({});
|
|
107
|
+
const [busyIndexByReceipt, setBusyIndexByReceipt] = useState<Record<string, number | null>>({});
|
|
108
|
+
const [busyImportByReceipt, setBusyImportByReceipt] = useState<Record<string, boolean>>({});
|
|
109
|
+
const [noticeByReceipt, setNoticeByReceipt] = useState<Record<string, string>>({});
|
|
110
|
+
const [importedByReceipt, setImportedByReceipt] = useState<Record<string, EmailReferenceImportOutcome["references"]>>({});
|
|
111
|
+
const receiptId = detail && "submission" in detail ? detail.submission.receiptId : null;
|
|
112
|
+
const receiptKey = receiptId ?? "";
|
|
113
|
+
const selected = selectedByReceipt[receiptKey] ?? {};
|
|
114
|
+
const resolved = resolvedByReceipt[receiptKey] ?? {};
|
|
115
|
+
const busyIndex = busyIndexByReceipt[receiptKey] ?? null;
|
|
116
|
+
const busyImport = busyImportByReceipt[receiptKey] ?? false;
|
|
117
|
+
const notice = noticeByReceipt[receiptKey] ?? "";
|
|
118
|
+
const importedReferences = importedByReceipt[receiptKey] ?? [];
|
|
119
|
+
const activeReceiptId = useRef<string | null>(receiptId);
|
|
120
|
+
activeReceiptId.current = receiptId;
|
|
121
|
+
const inFlightActions = useRef(new Set<string>());
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!detail || !("submission" in detail)) return;
|
|
124
|
+
setSelectedByReceipt((current) => current[receiptKey] ? current : {
|
|
125
|
+
...current,
|
|
126
|
+
[receiptKey]: Object.fromEntries(detail.candidateLinks.map((link, index) => [index, link.resolutionStatus !== "unresolved"])),
|
|
127
|
+
});
|
|
128
|
+
}, [receiptKey, detail]);
|
|
129
|
+
|
|
130
|
+
const resolveLink = async (index: number) => {
|
|
131
|
+
if (!receiptId || !onResolveArticleLink) return;
|
|
132
|
+
const requestReceiptId = receiptId;
|
|
133
|
+
const actionKey = `resolve:${requestReceiptId}:${index}`;
|
|
134
|
+
if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
|
|
135
|
+
setBusyIndexByReceipt((current) => ({ ...current, [requestReceiptId]: index }));
|
|
136
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
|
|
137
|
+
try {
|
|
138
|
+
const result = await onResolveArticleLink(requestReceiptId, index);
|
|
139
|
+
if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
|
|
140
|
+
if (!("url" in result)) {
|
|
141
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved safely. The original link remains private and unchanged." }));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const candidate = detail && "submission" in detail ? detail.candidateLinks[index] : null;
|
|
145
|
+
setResolvedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: { url: result.url, title: result.title || candidate?.title || "Article" } } }));
|
|
146
|
+
setSelectedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: true } }));
|
|
147
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Resolved. This request may be recorded as a click by the newsletter sender." }));
|
|
148
|
+
} catch {
|
|
149
|
+
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
150
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved. Try again later." }));
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
endEmailSubmissionAction(inFlightActions.current, actionKey);
|
|
154
|
+
setBusyIndexByReceipt((current) => ({ ...current, [requestReceiptId]: null }));
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const importSelected = async () => {
|
|
159
|
+
if (!receiptId || !detail || !("submission" in detail) || !onImportCandidates) return;
|
|
160
|
+
const requestReceiptId = receiptId;
|
|
161
|
+
const actionKey = `import:${requestReceiptId}`;
|
|
162
|
+
if (inFlightActions.current.has(actionKey)) return;
|
|
163
|
+
const candidates = detail.candidateLinks.flatMap((link, index) => {
|
|
164
|
+
if (!selected[index]) return [];
|
|
165
|
+
const resolvedLink = resolved[index];
|
|
166
|
+
if (!resolvedLink && link.resolutionStatus === "unresolved") return [];
|
|
167
|
+
const url = resolvedLink?.url ?? link.url;
|
|
168
|
+
return [{ candidateIndex: index, source_uri: url }];
|
|
169
|
+
});
|
|
170
|
+
if (!candidates.length) {
|
|
171
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Select at least one available candidate link. Resolve tracking links before importing them." }));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
|
|
175
|
+
setBusyImportByReceipt((current) => ({ ...current, [requestReceiptId]: true }));
|
|
176
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
|
|
177
|
+
try {
|
|
178
|
+
const result = await onImportCandidates(requestReceiptId, candidates);
|
|
179
|
+
if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
|
|
180
|
+
setImportedByReceipt((current) => ({ ...current, [requestReceiptId]: result.references }));
|
|
181
|
+
if (result.error) {
|
|
182
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} of ${candidates.length} selected candidates were registered or matched. ${result.error}` }));
|
|
183
|
+
} else {
|
|
184
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Import does not approve or publish them.` }));
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
188
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The import stopped. Any completed references are safe to retry; no review decision was made." }));
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
endEmailSubmissionAction(inFlightActions.current, actionKey);
|
|
192
|
+
setBusyImportByReceipt((current) => ({ ...current, [requestReceiptId]: false }));
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
|
|
198
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Email submissions</h3><span>Untrusted source material held privately for editor review</span></div></div>
|
|
199
|
+
{items.length ? <ul className="news-desk-monitoring__list">
|
|
200
|
+
{items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
|
|
201
|
+
<strong>{item.subject || "(no subject)"}</strong>
|
|
202
|
+
<span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.reviewRequired
|
|
203
|
+
? `${item.linkCount} await editor import` : `${item.processedCount} queued`}</span>
|
|
204
|
+
{item.reviewRequired ? <span>Authentication warnings: private review required; nothing was routed automatically.</span> : null}
|
|
205
|
+
{item.unresolvedLinkCount ? <span>{item.unresolvedLinkCount} tracking links remain unresolved and were not opened.</span> : null}
|
|
206
|
+
<span>Envelope sender: {item.envelopeSender || "unknown"} · MIME From (unverified): {item.headerFrom || "unknown"}</span>
|
|
207
|
+
<span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
|
|
208
|
+
{item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
|
|
209
|
+
{item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
|
|
210
|
+
<button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button>
|
|
211
|
+
</li>)}
|
|
212
|
+
</ul> : <p>No email submissions are available for this publication.</p>}
|
|
213
|
+
{detail && "submission" in detail ? (
|
|
214
|
+
<section aria-label="Email submission details" data-email-submission-details={detail.submission.receiptId}>
|
|
215
|
+
<h4>{detail.submission.subject || "Submitted email"}</h4>
|
|
216
|
+
<p>Original content is shown as inert text. Sender and message content are untrusted; no instructions are executed.</p>
|
|
217
|
+
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
|
|
218
|
+
<h5>Candidate references ({detail.candidateLinks.length})</h5>
|
|
219
|
+
<ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
|
|
220
|
+
{link.resolutionStatus === "unresolved" && !resolved[index]
|
|
221
|
+
? <span>{link.title || "Newsletter article link"} (tracking redirect not opened)</span>
|
|
222
|
+
: <a href={(resolved[index]?.url ?? link.url)} target="_blank" rel="noreferrer noopener">{resolved[index]?.title ?? (link.title || "Source link")}</a>}
|
|
223
|
+
<label>
|
|
224
|
+
<input type="checkbox" checked={selected[index] ?? false}
|
|
225
|
+
disabled={link.resolutionStatus === "unresolved" && !resolved[index]}
|
|
226
|
+
onChange={(event) => setSelectedByReceipt((current) => ({ ...current, [receiptKey]: { ...current[receiptKey], [index]: event.target.checked } }))} />
|
|
227
|
+
Add as pending Reference
|
|
228
|
+
</label>
|
|
229
|
+
{link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
230
|
+
<button type="button" disabled={busyIndex !== null || !onResolveArticleLink} onClick={() => void resolveLink(index)}>
|
|
231
|
+
{busyIndex === index ? "Resolving…" : "Resolve article redirect (may register a click)"}
|
|
232
|
+
</button>
|
|
233
|
+
) : null}
|
|
234
|
+
</li>)}</ul>
|
|
235
|
+
<p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
|
|
236
|
+
<button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
|
|
237
|
+
onClick={() => void importSelected()}>
|
|
238
|
+
{busyImport ? "Adding References…" : "Add selected links to References"}
|
|
239
|
+
</button>
|
|
240
|
+
{notice ? <p role="status" data-email-import-result>{notice}</p> : null}
|
|
241
|
+
{importedReferences.length ? <p><Link href={getNewsroomNavHref("/newsroom/references", false)}>Review these References</Link></p> : null}
|
|
242
|
+
</section>
|
|
243
|
+
) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
|
|
244
|
+
: detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
|
|
245
|
+
</article>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
|
|
250
|
+
data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
251
|
+
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
252
|
+
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
253
|
+
}) {
|
|
93
254
|
const router = data.router;
|
|
94
255
|
return (
|
|
95
256
|
<>
|
|
@@ -97,6 +258,10 @@ function Ready({ data, demo }: { data: MonitoringOverview; demo: boolean }) {
|
|
|
97
258
|
<p className="news-desk-delegation-note" data-monitoring-notice>This publication is not one of the router's outputs, so no relevance is shown for it here.</p>
|
|
98
259
|
) : null}
|
|
99
260
|
|
|
261
|
+
<EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
|
|
262
|
+
onResolveArticleLink={demo ? undefined : onResolveArticleLink}
|
|
263
|
+
onImportCandidates={demo ? undefined : onImportCandidates} />
|
|
264
|
+
|
|
100
265
|
<article className="news-desk-doctrine-card" data-monitoring-section="router">
|
|
101
266
|
<div className="news-desk-doctrine-card__header"><div><h3>Router</h3><span>Sends collected items to the shared Cyclotron and delivers relevant ones to References</span></div></div>
|
|
102
267
|
{router ? (
|
|
@@ -110,15 +275,14 @@ function Ready({ data, demo }: { data: MonitoringOverview; demo: boolean }) {
|
|
|
110
275
|
</article>
|
|
111
276
|
|
|
112
277
|
<article className="news-desk-doctrine-card" data-monitoring-section="usage">
|
|
113
|
-
<div className="news-desk-doctrine-card__header"><div><h3>Usage</h3><span>
|
|
278
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Usage</h3><span>Observed router decisions, not a Cyclotron quota (UTC days)</span></div></div>
|
|
114
279
|
{data.usage ? (
|
|
115
280
|
<>
|
|
116
281
|
<dl className="news-desk-monitoring__usage">
|
|
117
|
-
<div><dt>Decisions today ({data.usage.day})</dt><dd>{
|
|
118
|
-
<div><dt>Decisions in
|
|
119
|
-
{ceilings?.items !== undefined ? <div><dt>Items judged (ceiling)</dt><dd>{usageText(data.usage.decisionsTotal, ceilings.items)}</dd></div> : null}
|
|
282
|
+
<div><dt>Decisions today ({data.usage.day})</dt><dd>{data.usage.historyComplete ? data.usage.decisionsToday : `At least ${data.usage.decisionsToday}`}</dd></div>
|
|
283
|
+
<div><dt>Decisions in inspected history</dt><dd>{data.usage.historyComplete ? data.usage.decisionsTotal : `At least ${data.usage.decisionsTotal}`}</dd></div>
|
|
120
284
|
</dl>
|
|
121
|
-
{
|
|
285
|
+
{!data.usage.historyComplete ? <p className="news-desk-delegation-note">Counts are lower bounds from a bounded history read.</p> : null}
|
|
122
286
|
</>
|
|
123
287
|
) : <p>No usage recorded yet.</p>}
|
|
124
288
|
</article>
|
|
@@ -192,12 +356,20 @@ export function MonitoringOverviewView({
|
|
|
192
356
|
mode,
|
|
193
357
|
onModeChange,
|
|
194
358
|
demo = false,
|
|
359
|
+
emailDetail = null,
|
|
360
|
+
onEmailOpen = () => {},
|
|
361
|
+
onResolveArticleLink,
|
|
362
|
+
onImportCandidates,
|
|
195
363
|
}: {
|
|
196
364
|
result: MonitoringResult | null;
|
|
197
365
|
canViewAll: boolean;
|
|
198
366
|
mode: MonitoringMode;
|
|
199
367
|
onModeChange: (mode: MonitoringMode) => void;
|
|
200
368
|
demo?: boolean;
|
|
369
|
+
emailDetail?: MonitoringEmailDetails | null;
|
|
370
|
+
onEmailOpen?: (receiptId: string) => void;
|
|
371
|
+
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
372
|
+
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
201
373
|
}) {
|
|
202
374
|
let body: React.ReactNode;
|
|
203
375
|
if (result === null) {
|
|
@@ -213,7 +385,8 @@ export function MonitoringOverviewView({
|
|
|
213
385
|
} else if (result.state === "empty") {
|
|
214
386
|
body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector's first run.</Message>;
|
|
215
387
|
} else {
|
|
216
|
-
body = <Ready data={result} demo={demo}
|
|
388
|
+
body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
|
|
389
|
+
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} />;
|
|
217
390
|
}
|
|
218
391
|
const scopeLine = result && "scope" in result
|
|
219
392
|
? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
importMonitoringEmailCandidates,
|
|
6
|
+
loadMonitoringEmailSubmission,
|
|
7
|
+
loadMonitoringOverview,
|
|
8
|
+
resolveMonitoringEmailArticleLink,
|
|
9
|
+
} from "../lib/monitoring-client";
|
|
5
10
|
import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
|
|
6
|
-
import type { MonitoringResult } from "../lib/monitoring-overview";
|
|
11
|
+
import type { EmailReferenceImportItem, MonitoringEmailDetails, MonitoringResult } from "../lib/monitoring-overview";
|
|
7
12
|
import { MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
|
|
8
13
|
|
|
9
14
|
const REFRESH_MILLISECONDS = 60_000;
|
|
@@ -12,6 +17,7 @@ const REFRESH_MILLISECONDS = 60_000;
|
|
|
12
17
|
export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
|
|
13
18
|
const [mode, setMode] = useState<MonitoringMode>("publication");
|
|
14
19
|
const [result, setResult] = useState<MonitoringResult | null>(null);
|
|
20
|
+
const [emailDetail, setEmailDetail] = useState<MonitoringEmailDetails | null>(null);
|
|
15
21
|
|
|
16
22
|
useEffect(() => {
|
|
17
23
|
if (isDemo) {
|
|
@@ -36,5 +42,19 @@ export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo:
|
|
|
36
42
|
};
|
|
37
43
|
}, [mode, isDemo]);
|
|
38
44
|
|
|
39
|
-
|
|
45
|
+
const openEmail = async (receiptId: string) => {
|
|
46
|
+
setEmailDetail({ state: "unavailable" });
|
|
47
|
+
const next = await loadMonitoringEmailSubmission(receiptId);
|
|
48
|
+
setEmailDetail(next);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const resolveEmailArticleLink = (receiptId: string, candidateIndex: number) =>
|
|
52
|
+
resolveMonitoringEmailArticleLink(receiptId, candidateIndex);
|
|
53
|
+
|
|
54
|
+
const importEmailCandidates = (receiptId: string, candidates: EmailReferenceImportItem[]) =>
|
|
55
|
+
importMonitoringEmailCandidates(receiptId, candidates);
|
|
56
|
+
|
|
57
|
+
return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
|
|
58
|
+
emailDetail={emailDetail} onEmailOpen={isDemo ? () => {} : openEmail} onResolveArticleLink={isDemo ? undefined : resolveEmailArticleLink}
|
|
59
|
+
onImportCandidates={importEmailCandidates} />;
|
|
40
60
|
}
|
|
@@ -23,6 +23,7 @@ type ViewProps = {
|
|
|
23
23
|
held: CyclotronEntry[];
|
|
24
24
|
autoDecided: CyclotronEntry[];
|
|
25
25
|
storyTitles?: Readonly<Record<string, string>>;
|
|
26
|
+
deletedStories?: ReadonlySet<string>;
|
|
26
27
|
demo?: boolean;
|
|
27
28
|
busyId?: string | null;
|
|
28
29
|
error?: string | null;
|
|
@@ -31,8 +32,8 @@ type ViewProps = {
|
|
|
31
32
|
onMarkAllRead?: () => void;
|
|
32
33
|
};
|
|
33
34
|
|
|
34
|
-
function Entry({ request, unread, demo, busy, titles, onAnswer, onMarkRead }: {
|
|
35
|
-
request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean; titles: Readonly<Record<string, string>>;
|
|
35
|
+
function Entry({ request, unread, demo, busy, titles, deleted, onAnswer, onMarkRead }: {
|
|
36
|
+
request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean; titles: Readonly<Record<string, string>>; deleted: ReadonlySet<string>;
|
|
36
37
|
onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"];
|
|
37
38
|
}) {
|
|
38
39
|
const [note, setNote] = useState("");
|
|
@@ -49,7 +50,7 @@ function Entry({ request, unread, demo, busy, titles, onAnswer, onMarkRead }: {
|
|
|
49
50
|
</div>
|
|
50
51
|
<p className="text-xs text-muted-foreground">
|
|
51
52
|
Raised by {request.raisedBy} on {formatWhen(request.raisedAt)}
|
|
52
|
-
{" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{subjectLabel(request.subject, titles)}</Link> : <span>{subjectLabel(request.subject, titles)}</span>}
|
|
53
|
+
{" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{subjectLabel(request.subject, titles, deleted)}</Link> : <span>{subjectLabel(request.subject, titles, deleted)}</span>}
|
|
53
54
|
</p>
|
|
54
55
|
{request.ask ? <p className="text-sm">{request.ask}</p> : null}
|
|
55
56
|
{request.links.length ? (
|
|
@@ -78,7 +79,7 @@ function Entry({ request, unread, demo, busy, titles, onAnswer, onMarkRead }: {
|
|
|
78
79
|
</label>
|
|
79
80
|
<div className="flex flex-wrap gap-2">
|
|
80
81
|
{request.mode === "flag" ? (
|
|
81
|
-
<Button disabled={busy} onClick={() => act({ decision: "
|
|
82
|
+
<Button disabled={busy} onClick={() => act({ decision: "acknowledge" })} size="sm" variant="outline">Acknowledge</Button>
|
|
82
83
|
) : (
|
|
83
84
|
<>
|
|
84
85
|
<Button disabled={busy} onClick={() => act({ decision: "approve" })} size="sm" variant="outline">Approve</Button>
|
|
@@ -125,12 +126,12 @@ function Section({ id, title, count, children, empty }: { id: string; title: str
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
/** The CMS console home: what needs a person, what will proceed on its own, and what the system decided. Ignore-safe by design. */
|
|
128
|
-
export function NotificationCenterView({ loadState, requests, readIds, held, autoDecided, demo = false, storyTitles = {}, busyId = null, error = null, onAnswer, onMarkRead, onMarkAllRead }: ViewProps) {
|
|
129
|
+
export function NotificationCenterView({ loadState, requests, readIds, held, autoDecided, demo = false, storyTitles = {}, deletedStories = new Set<string>(), busyId = null, error = null, onAnswer, onMarkRead, onMarkAllRead }: ViewProps) {
|
|
129
130
|
const sections = useMemo(() => sectionReviews(requests), [requests]);
|
|
130
131
|
const unreadCount = requests.filter((request) => isUnread(request, readIds)).length;
|
|
131
132
|
const summary = summarize(sections, held, autoDecided, unreadCount);
|
|
132
133
|
const entry = (request: ReviewRequest) => (
|
|
133
|
-
<Entry busy={busyId === request.id} demo={demo} key={request.id} titles={storyTitles} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
|
|
134
|
+
<Entry busy={busyId === request.id} demo={demo} key={request.id} titles={storyTitles} deleted={deletedStories} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
|
|
134
135
|
);
|
|
135
136
|
if (loadState === "loading") return (
|
|
136
137
|
<section aria-label="Loading Notification Center" className="space-y-4" data-notification-state="loading" role="status">
|
|
@@ -206,6 +207,7 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
206
207
|
const [busyId, setBusyId] = useState<string | null>(null);
|
|
207
208
|
const [error, setError] = useState<string | null>(null);
|
|
208
209
|
const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
|
|
210
|
+
const [deletedStories, setDeletedStories] = useState<ReadonlySet<string>>(new Set());
|
|
209
211
|
|
|
210
212
|
const refresh = useCallback(async () => {
|
|
211
213
|
if (demo) {
|
|
@@ -226,7 +228,9 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
226
228
|
setUserKey(loaded.userKey);
|
|
227
229
|
setLoadState("ready");
|
|
228
230
|
const { loadStoryTitles } = await import("../lib/review-client");
|
|
229
|
-
|
|
231
|
+
const stories = await loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({ titles: {}, deleted: [] as string[] }));
|
|
232
|
+
setStoryTitles(stories.titles);
|
|
233
|
+
setDeletedStories(new Set(stories.deleted));
|
|
230
234
|
} catch {
|
|
231
235
|
setLoadState("unavailable");
|
|
232
236
|
}
|
|
@@ -270,7 +274,7 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
270
274
|
|
|
271
275
|
return (
|
|
272
276
|
<NotificationCenterView
|
|
273
|
-
autoDecided={autoDecided} busyId={busyId} storyTitles={storyTitles} demo={demo} error={error} held={held} loadState={loadState}
|
|
277
|
+
autoDecided={autoDecided} busyId={busyId} storyTitles={storyTitles} deletedStories={deletedStories} demo={demo} error={error} held={held} loadState={loadState}
|
|
274
278
|
onAnswer={answer} onMarkAllRead={markAllRead} onMarkRead={markRead} readIds={readIds} requests={requests}
|
|
275
279
|
/>
|
|
276
280
|
);
|