@anthusai/papyrus 5.43.0-next.4 → 5.43.0-next.41
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 +16 -8
- package/amplify/functions/content-actions/handler.py +8 -0
- package/amplify/functions/content-actions/requirements.txt +2 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -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/app/api/newsroom/source-frameable/route.ts +12 -0
- package/app/api/newsroom/source-icon/route.ts +20 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
- package/app/newsroom/[section]/page.tsx +1 -0
- package/bin/papyrus-app.mjs +12 -1
- package/bin/papyrus-infra.mjs +21 -1
- package/components/auto-accepted-badge.tsx +21 -0
- package/components/conversation-context.tsx +52 -0
- package/components/conversation-provider.tsx +20 -0
- package/components/conversation-thread.tsx +174 -0
- package/components/decision-control.tsx +306 -0
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/markdown-context.tsx +412 -0
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/needs-me-bodies.tsx +302 -0
- package/components/needs-me-detail.tsx +24 -0
- package/components/needs-me-feed.tsx +594 -0
- package/components/needs-me-reference-detail.tsx +79 -0
- package/components/needs-me-shared.tsx +48 -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 +253 -31
- package/components/newsroom-access-gate.tsx +13 -20
- package/components/newsroom-board-skeleton.tsx +20 -0
- package/components/newsroom-board-view.tsx +2 -6
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +88 -73
- package/components/newsroom-ops-shell.tsx +8 -28
- package/components/newsroom-organizer.css +127 -3
- package/components/newsroom-references-view.tsx +334 -158
- package/components/notification-cards.tsx +370 -0
- package/components/notification-center.tsx +376 -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/reader-auth-control.tsx +3 -1
- package/components/reference-capture-client.ts +138 -0
- package/components/reference-capture-panel.tsx +268 -0
- package/components/reference-conversation.tsx +43 -0
- package/components/reference-feed.tsx +514 -0
- package/components/reference-review-card.tsx +62 -197
- package/components/reference-source-preview.tsx +27 -5
- package/components/reference-stories.tsx +34 -0
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/story-reporting-trail-client.ts +45 -8
- package/components/story-reporting-trail.tsx +24 -13
- package/components/topic-steering-workspace.tsx +50 -439
- package/components/ui/auto-grow-textarea.tsx +18 -0
- package/components/use-conversation.ts +84 -0
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +189 -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 +20 -0
- package/lib/conversation.ts +154 -0
- package/lib/local-preview-feed-fixture.ts +63 -0
- package/lib/local-preview-seed.ts +24 -2
- 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-all-pages.ts +21 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-index-filters.ts +18 -0
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/newsroom-references.ts +25 -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 +294 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +236 -0
- package/lib/reference-policy.ts +90 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/relevance-decisions.ts +6 -2
- package/lib/review-client.ts +71 -5
- package/lib/review-drafts.ts +277 -0
- package/lib/review-fixture.ts +56 -2
- package/lib/review-requests.ts +89 -24
- package/lib/site-brand.ts +2 -0
- package/lib/source-frameable.ts +77 -0
- package/lib/source-icon.ts +104 -0
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/lib/story-reporting-trail.ts +33 -2
- package/package.json +45 -2
- package/routes.manifest.json +23 -1
- package/components/newsroom-overview-skeleton.tsx +0 -25
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useRef, type TextareaHTMLAttributes } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A textarea as tall as its text. It never scrolls inside itself, so on a phone a swipe over it scrolls the page instead of
|
|
7
|
+
* getting caught in the box. (CSS `field-sizing: content` does the same where supported; the height is set here for the rest.)
|
|
8
|
+
*/
|
|
9
|
+
export function AutoGrowTextarea({ className, style, value, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
|
10
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
11
|
+
useLayoutEffect(() => {
|
|
12
|
+
const element = ref.current;
|
|
13
|
+
if (!element) return;
|
|
14
|
+
element.style.height = "auto";
|
|
15
|
+
element.style.height = `${element.scrollHeight}px`;
|
|
16
|
+
}, [value]);
|
|
17
|
+
return <textarea {...props} className={className} ref={ref} rows={props.rows ?? 2} style={{ ...style, overflow: "hidden", resize: "none" }} value={value} />;
|
|
18
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { ConversationAnchor, ConversationMessage } from "../lib/conversation";
|
|
5
|
+
import { useConversationStore } from "./conversation-context";
|
|
6
|
+
|
|
7
|
+
export type ConversationState = "loading" | "ready" | "error";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One conversation's Messages. Posting shows the Message at once (marked pending) and swaps in the saved one; a failed save removes
|
|
11
|
+
* it again and rejects, so the caller can give the typed text back.
|
|
12
|
+
*/
|
|
13
|
+
export function useConversation(anchor: ConversationAnchor) {
|
|
14
|
+
const store = useConversationStore();
|
|
15
|
+
const [messages, setMessages] = useState<ConversationMessage[]>([]);
|
|
16
|
+
const [state, setState] = useState<ConversationState>("loading");
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [attempt, setAttempt] = useState(0);
|
|
19
|
+
const pendingCount = useRef(0);
|
|
20
|
+
const anchorRef = useRef(anchor);
|
|
21
|
+
anchorRef.current = anchor;
|
|
22
|
+
const anchorKey = `${anchor.kind}#${anchor.lineageId ?? ""}`;
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!store) return;
|
|
26
|
+
let active = true;
|
|
27
|
+
setState("loading");
|
|
28
|
+
setError(null);
|
|
29
|
+
setMessages([]);
|
|
30
|
+
store.load(anchorRef.current).then((loaded) => {
|
|
31
|
+
if (!active) return;
|
|
32
|
+
setMessages((current) => [...loaded, ...current.filter((message) => message.pending)]);
|
|
33
|
+
setState("ready");
|
|
34
|
+
}).catch((failure) => {
|
|
35
|
+
if (!active) return;
|
|
36
|
+
setError(failure instanceof Error ? failure.message : "The conversation could not be loaded.");
|
|
37
|
+
setState("error");
|
|
38
|
+
});
|
|
39
|
+
return () => { active = false; };
|
|
40
|
+
}, [store, anchorKey, attempt]);
|
|
41
|
+
|
|
42
|
+
const retry = useCallback(() => setAttempt((current) => current + 1), []);
|
|
43
|
+
|
|
44
|
+
const post = useCallback(async (body: string, parentMessageId: string | null) => {
|
|
45
|
+
if (!store) throw new Error("Messages are not available here.");
|
|
46
|
+
pendingCount.current += 1;
|
|
47
|
+
const pendingId = `pending-${pendingCount.current}`;
|
|
48
|
+
const pending: ConversationMessage = {
|
|
49
|
+
id: pendingId,
|
|
50
|
+
parentMessageId,
|
|
51
|
+
authorLabel: store.authorLabel,
|
|
52
|
+
content: body.trim(),
|
|
53
|
+
createdAt: new Date().toISOString(),
|
|
54
|
+
sequenceNumber: Number.MAX_SAFE_INTEGER - 1000 + pendingCount.current,
|
|
55
|
+
pending: true,
|
|
56
|
+
};
|
|
57
|
+
setMessages((current) => [...current, pending]);
|
|
58
|
+
try {
|
|
59
|
+
const saved = await store.append({ anchor: anchorRef.current, body, parentMessageId });
|
|
60
|
+
setMessages((current) => current.map((message) => (message.id === pendingId ? saved : message)));
|
|
61
|
+
} catch (failure) {
|
|
62
|
+
setMessages((current) => current.filter((message) => message.id !== pendingId));
|
|
63
|
+
throw failure instanceof Error ? failure : new Error("Your message could not be saved.");
|
|
64
|
+
}
|
|
65
|
+
}, [store]);
|
|
66
|
+
|
|
67
|
+
return { available: Boolean(store), messages, state, error, retry, post };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Message counts per Reference lineage, for the feed cards. A failed read just shows no counts. */
|
|
71
|
+
export function useReferenceMessageCounts(enabled: boolean) {
|
|
72
|
+
const store = useConversationStore();
|
|
73
|
+
const [counts, setCounts] = useState<ReadonlyMap<string, number>>(() => new Map());
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!store || !enabled) return;
|
|
76
|
+
let active = true;
|
|
77
|
+
store.counts().then((loaded) => { if (active) setCounts((current) => new Map([...loaded, ...current])); }).catch(() => undefined);
|
|
78
|
+
return () => { active = false; };
|
|
79
|
+
}, [store, enabled]);
|
|
80
|
+
const setCount = useCallback((lineageId: string, count: number) => {
|
|
81
|
+
setCounts((current) => (current.get(lineageId) === count || (count === 0 && !current.has(lineageId)) ? current : new Map(current).set(lineageId, count)));
|
|
82
|
+
}, []);
|
|
83
|
+
return { counts, setCount };
|
|
84
|
+
}
|
|
@@ -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,189 @@
|
|
|
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 "./decision-control";
|
|
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 (the backend refuses one without): words or a reason code, whichever the surface asks for. With neither it
|
|
42
|
+
* waits as `pending` until one arrives; a typed reason is 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, sticky, saveNote }: {
|
|
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
|
+
/**
|
|
51
|
+
* True for an item whose decision, once made, cannot be sent again (an AI draft moves its Proposal, a review request closes, a held email is
|
|
52
|
+
* queued). A reason typed after such a decision goes to `saveNote` instead, onto the decision that exists.
|
|
53
|
+
*/
|
|
54
|
+
sticky?: (id: string) => boolean;
|
|
55
|
+
/** Saves the reason of an item that is already decided and sticky. Throws a sentence when it cannot. */
|
|
56
|
+
saveNote?: (id: string, note: string) => Promise<void>;
|
|
57
|
+
}): RatingController {
|
|
58
|
+
const [items, setItems] = useState<Record<string, ItemRating>>({});
|
|
59
|
+
const itemsRef = useRef(items);
|
|
60
|
+
const latest = useRef({ save, saved, seed, needsReason, sticky, saveNote });
|
|
61
|
+
latest.current = { save, saved, seed, needsReason, sticky, saveNote };
|
|
62
|
+
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
63
|
+
const inFlight = useRef<Set<string>>(new Set());
|
|
64
|
+
const dirty = useRef<Set<string>>(new Set());
|
|
65
|
+
const decidedHere = useRef<Set<string>>(new Set());
|
|
66
|
+
const noteInFlight = useRef<Set<string>>(new Set());
|
|
67
|
+
|
|
68
|
+
const patch = useCallback((id: string, change: Partial<ItemRating>) => {
|
|
69
|
+
const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
|
|
70
|
+
itemsRef.current = next;
|
|
71
|
+
setItems(next);
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
// What a reader of the store sees: the touched value, else what is saved. (A saved reason therefore shows on the card too.)
|
|
75
|
+
const get = useCallback((id: string): ItemRating => {
|
|
76
|
+
const item = items[id] ?? EMPTY;
|
|
77
|
+
const seeded = latest.current.seed(id);
|
|
78
|
+
return { ...item, note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
79
|
+
}, [items]);
|
|
80
|
+
|
|
81
|
+
const values = (id: string) => {
|
|
82
|
+
const item = itemsRef.current[id] ?? EMPTY;
|
|
83
|
+
const seeded = latest.current.seed(id);
|
|
84
|
+
return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** True once a sticky item has its decision: from then on only its reason can change. */
|
|
88
|
+
const finalDecision = (id: string) => Boolean(latest.current.sticky?.(id) && latest.current.saveNote && (decidedHere.current.has(id) || latest.current.saved(id)));
|
|
89
|
+
|
|
90
|
+
/** A reason typed after the decision: saved onto it. The text stays in the store until it is saved, and a failed save never drops it. */
|
|
91
|
+
const saveNoteOnly = useCallback(async (id: string): Promise<void> => {
|
|
92
|
+
if (noteInFlight.current.has(id)) { dirty.current.add(`note:${id}`); return; }
|
|
93
|
+
noteInFlight.current.add(id);
|
|
94
|
+
dirty.current.delete(`note:${id}`);
|
|
95
|
+
const started = itemsRef.current[id]?.note;
|
|
96
|
+
const { note } = values(id);
|
|
97
|
+
patch(id, { feedback: { phase: "saving" } });
|
|
98
|
+
try {
|
|
99
|
+
await latest.current.saveNote!(id, note.trim());
|
|
100
|
+
const now = itemsRef.current[id];
|
|
101
|
+
patch(id, { feedback: { phase: "saved" }, ...(now?.note === started ? { note: undefined } : {}) });
|
|
102
|
+
} catch (failure) {
|
|
103
|
+
dirty.current.add(`note:${id}`); // still unsaved: tapping away, or the next pause, tries again
|
|
104
|
+
patch(id, { feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your note could not be saved. It is kept here; keep typing or tap away to try again." } });
|
|
105
|
+
} finally {
|
|
106
|
+
noteInFlight.current.delete(id);
|
|
107
|
+
}
|
|
108
|
+
if (itemsRef.current[id]?.feedback.phase !== "error" && dirty.current.delete(`note:${id}`)) void saveNoteOnly(id);
|
|
109
|
+
}, [patch]);
|
|
110
|
+
|
|
111
|
+
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
112
|
+
if (finalDecision(id)) { void saveNoteOnly(id); return; }
|
|
113
|
+
const { note, reasonCode } = values(id);
|
|
114
|
+
// A typed reason is a reason, so a Reject with words goes at once rather than waiting. It carries no
|
|
115
|
+
// code the editor did not pick: the backend reads the words for the code they name, and files words
|
|
116
|
+
// that name none under "other". Only a Reject with neither words nor a code waits for one.
|
|
117
|
+
if (choice === "reject" && !reasonCode && !note.trim() && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
118
|
+
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
119
|
+
inFlight.current.add(id);
|
|
120
|
+
dirty.current.delete(id);
|
|
121
|
+
const started = itemsRef.current[id] ?? EMPTY;
|
|
122
|
+
patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
|
|
123
|
+
try {
|
|
124
|
+
await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
|
|
125
|
+
if (latest.current.sticky?.(id)) decidedHere.current.add(id);
|
|
126
|
+
// What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept. While another
|
|
127
|
+
// save is due (changed mid-save), the store keeps everything: the record may not show this save yet, and the next one sends it.
|
|
128
|
+
const now = itemsRef.current[id] ?? EMPTY;
|
|
129
|
+
const follow = !dirty.current.has(id);
|
|
130
|
+
patch(id, { pending: null, feedback: { phase: "saved" }, ...(follow && now.note === started.note ? { note: undefined } : {}), ...(follow && now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
|
|
131
|
+
} catch (failure) {
|
|
132
|
+
// The typed reason stays in the store; only the vote is undone.
|
|
133
|
+
patch(id, { pending: null, feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." } });
|
|
134
|
+
} finally {
|
|
135
|
+
inFlight.current.delete(id);
|
|
136
|
+
}
|
|
137
|
+
// Changed while saving (a reason chip tapped mid-save): save again. The record may not show the vote yet, so fall back to it.
|
|
138
|
+
if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id) ?? choice; if (again) void commit(id, again); }
|
|
139
|
+
}, [patch, saveNoteOnly]);
|
|
140
|
+
|
|
141
|
+
const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
|
|
142
|
+
|
|
143
|
+
const choose = useCallback((id: string, choice: ReviewChoice) => {
|
|
144
|
+
if (finalDecision(id)) return; // a decision that cannot be sent again is only ever given a reason, never a second vote
|
|
145
|
+
if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
|
|
146
|
+
void commit(id, choice);
|
|
147
|
+
}, [commit]);
|
|
148
|
+
|
|
149
|
+
const setReason = useCallback((id: string, code: ReferenceRejectionReasonCode) => {
|
|
150
|
+
patch(id, { reasonCode: code });
|
|
151
|
+
// Vote then reason: onto the existing (or waiting) decision. Reason then vote: stored, and the vote carries it.
|
|
152
|
+
if (effective(id) === "reject") void commit(id, "reject");
|
|
153
|
+
}, [commit, patch]);
|
|
154
|
+
|
|
155
|
+
const setNote = useCallback((id: string, text: string) => {
|
|
156
|
+
patch(id, { note: text, feedback: { phase: "idle" } });
|
|
157
|
+
dirty.current.add(`note:${id}`);
|
|
158
|
+
clearTimeout(timers.current[id]);
|
|
159
|
+
timers.current[id] = setTimeout(() => {
|
|
160
|
+
const choice = effective(id);
|
|
161
|
+
if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
|
|
162
|
+
}, NOTE_AUTOSAVE_MS);
|
|
163
|
+
}, [commit, patch]);
|
|
164
|
+
|
|
165
|
+
const commitNote = useCallback((id: string) => {
|
|
166
|
+
clearTimeout(timers.current[id]);
|
|
167
|
+
const choice = effective(id);
|
|
168
|
+
if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
|
|
169
|
+
}, [commit]);
|
|
170
|
+
|
|
171
|
+
const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
|
|
172
|
+
|
|
173
|
+
// Leaving the page (or closing the view) saves what was typed and not yet saved, rather than dropping it.
|
|
174
|
+
const leaving = useRef<() => void>(() => undefined);
|
|
175
|
+
leaving.current = () => {
|
|
176
|
+
for (const key of [...dirty.current]) {
|
|
177
|
+
if (!key.startsWith("note:")) continue;
|
|
178
|
+
const id = key.slice(5);
|
|
179
|
+
const choice = effective(id);
|
|
180
|
+
if (choice && dirty.current.delete(key)) void commit(id, choice);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
useEffect(() => () => {
|
|
184
|
+
for (const timer of Object.values(timers.current)) clearTimeout(timer);
|
|
185
|
+
leaving.current();
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
return { get, choose, setReason, setNote, commitNote, cancelReason };
|
|
189
|
+
}
|
|
@@ -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,
|