@anthusai/papyrus 5.58.0 → 5.59.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/message-actions.js +68 -0
- package/amplify/data/message-actions.ts +70 -0
- package/amplify/data/resolvers/delete-own-message.js +30 -0
- package/amplify/data/resolvers/edit-message.js +33 -0
- package/amplify/data/resolvers/moderate-message.js +32 -0
- package/amplify/data/resolvers/post-message.js +68 -0
- package/amplify/data/resolvers/rename-thread.js +28 -0
- package/amplify/data/resource.js +8 -5
- package/amplify/data/resource.ts +8 -5
- package/amplify/data/schema.js +14 -7
- package/amplify/data/schema.ts +14 -7
- package/components/conversation-context.tsx +29 -2
- package/components/conversation-provider.tsx +5 -1
- package/components/conversation-thread.tsx +133 -19
- package/components/conversations-memory-source.ts +43 -6
- package/components/conversations-network-source.ts +13 -1
- package/components/conversations-source.ts +15 -3
- package/components/conversations-view.tsx +437 -76
- package/components/list-detail-layout.tsx +44 -0
- package/components/needs-me-bodies.tsx +14 -14
- package/components/needs-me-feed.tsx +8 -18
- package/components/news-desk-taxonomy-client.ts +142 -57
- package/components/newsroom-article-editor.tsx +10 -4
- package/components/newsroom-organizer.css +10 -1
- package/components/newsroom-references-view.tsx +41 -100
- package/components/notification-cards.tsx +4 -4
- package/components/notification-center.tsx +5 -7
- package/components/proposals-view.tsx +4 -4
- package/components/reference-conversation.tsx +5 -0
- package/components/reference-feed.tsx +69 -85
- package/components/reference-review-card.tsx +1 -1
- package/components/references-page-loading.tsx +12 -6
- package/components/topic-steering-workspace.tsx +4 -4
- package/components/use-conversation.ts +36 -2
- package/components/use-feed-preferences.ts +33 -0
- package/lib/category-repository.ts +7 -38
- package/lib/console-chat-client.ts +8 -178
- package/lib/conversation.ts +73 -2
- package/lib/needs-me-items.ts +4 -4
- package/lib/needs-me-ranking.ts +9 -22
- package/lib/newsroom-board.ts +10 -1
- package/lib/newsroom-demo-dashboard.ts +0 -7
- package/lib/newsroom-organizer-nav.ts +2 -2
- package/lib/post-message.ts +54 -0
- package/lib/proposals-admin.ts +2 -2
- package/lib/reference-feed-ranking.ts +77 -160
- package/lib/review-drafts.ts +2 -2
- package/lib/review-fixture.ts +1 -1
- package/lib/review-requests.ts +4 -4
- package/lib/semantic-graph.ts +1 -1
- package/package.json +4 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowLeftIcon } from "lucide-react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The newsroom's list-and-detail layout, one rule for every page that has it (References, Messages). Below `lg` (1024px) the open
|
|
9
|
+
* item's detail replaces the list at the full width with a Back button; from `lg` the detail is a pane beside the list, 24rem wide and
|
|
10
|
+
* 28rem from `xl`. It is chosen by the viewport, never by the selection.
|
|
11
|
+
*/
|
|
12
|
+
export const DETAIL_PANE_WIDTH = "lg:w-[min(100%,24rem)] xl:w-[min(100%,28rem)]";
|
|
13
|
+
export const LIST_HIDDEN_WHEN_DETAIL_OPEN = "max-lg:hidden";
|
|
14
|
+
|
|
15
|
+
export function ListDetailLayout({ backLabel, detail, list, onBack, open }: {
|
|
16
|
+
backLabel: string;
|
|
17
|
+
detail: ReactNode;
|
|
18
|
+
list: ReactNode;
|
|
19
|
+
onBack: () => void;
|
|
20
|
+
open: boolean;
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row lg:items-start" data-list-detail-layout={open ? "detail" : "list"}>
|
|
24
|
+
<div className={cn("min-w-0 flex-1", open && LIST_HIDDEN_WHEN_DETAIL_OPEN)} data-list-detail-list>{list}</div>
|
|
25
|
+
{open ? (
|
|
26
|
+
<div
|
|
27
|
+
className={cn("flex w-full shrink-0 flex-col gap-3 rounded-[20px] bg-[var(--feed-card)] p-4 lg:sticky lg:top-4 lg:max-h-[calc(100dvh-2rem)] lg:overflow-y-auto", DETAIL_PANE_WIDTH)}
|
|
28
|
+
data-list-detail-pane
|
|
29
|
+
>
|
|
30
|
+
<button
|
|
31
|
+
className="-ml-2 inline-flex min-h-9 items-center gap-1.5 self-start rounded-[var(--feed-radius-md,16px)] px-2 text-sm font-medium text-muted-foreground outline-none transition-colors hover:bg-[var(--feed-control)]/70 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring lg:hidden"
|
|
32
|
+
data-detail-back
|
|
33
|
+
onClick={onBack}
|
|
34
|
+
type="button"
|
|
35
|
+
>
|
|
36
|
+
<ArrowLeftIcon aria-hidden="true" className="size-4" />
|
|
37
|
+
{backLabel}
|
|
38
|
+
</button>
|
|
39
|
+
{detail}
|
|
40
|
+
</div>
|
|
41
|
+
) : null}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -96,16 +96,6 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
96
96
|
|
|
97
97
|
return (
|
|
98
98
|
<section aria-label="Your decision" className="space-y-3 rounded-[20px] bg-[var(--feed-footer)] p-3" data-needs-me-decision {...(locked ? {} : { "data-review-controls": "" })}>
|
|
99
|
-
<AutoGrowTextarea
|
|
100
|
-
aria-label="Why? (optional)"
|
|
101
|
-
className="block min-h-20 w-full rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
102
|
-
data-needs-me-why
|
|
103
|
-
onBlur={() => { if (!disabledReason) ratings.commitNote(item.id); }}
|
|
104
|
-
onChange={(event) => ratings.setNote(item.id, event.target.value)}
|
|
105
|
-
placeholder={flag ? "Why? Optional. Tell the step what to do differently." : "Why? Optional. Your note teaches the AI."}
|
|
106
|
-
value={note}
|
|
107
|
-
/>
|
|
108
|
-
{locked ? <ReasonStatus feedback={rating.feedback} hasReason={Boolean(recorded.why)} unsaved={unsaved} /> : null}
|
|
109
99
|
{locked ? (
|
|
110
100
|
<>
|
|
111
101
|
{item.decisionState === "acknowledged"
|
|
@@ -149,6 +139,16 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
149
139
|
)}
|
|
150
140
|
</>
|
|
151
141
|
)}
|
|
142
|
+
<AutoGrowTextarea
|
|
143
|
+
aria-label="Why? (optional)"
|
|
144
|
+
className="block min-h-20 w-full rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
145
|
+
data-needs-me-why
|
|
146
|
+
onBlur={() => { if (!disabledReason) ratings.commitNote(item.id); }}
|
|
147
|
+
onChange={(event) => ratings.setNote(item.id, event.target.value)}
|
|
148
|
+
placeholder={flag ? "Why? Optional. Tell the step what to do differently." : "Why? Optional. Your note teaches the AI."}
|
|
149
|
+
value={note}
|
|
150
|
+
/>
|
|
151
|
+
{locked ? <ReasonStatus feedback={rating.feedback} hasReason={Boolean(recorded.why)} unsaved={unsaved} /> : null}
|
|
152
152
|
</section>
|
|
153
153
|
);
|
|
154
154
|
}
|
|
@@ -164,7 +164,7 @@ export function DraftBody(props: DetailProps & { payload: Extract<NeedsMeEntry["
|
|
|
164
164
|
const { item: draft } = payload;
|
|
165
165
|
const view = draft.view;
|
|
166
166
|
if (!view) {
|
|
167
|
-
return <p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading the
|
|
167
|
+
return <p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading the Proposed Story…</p>;
|
|
168
168
|
}
|
|
169
169
|
return (
|
|
170
170
|
<div className="space-y-4" data-draft-detail={draft.draftId}>
|
|
@@ -196,16 +196,16 @@ export function DraftBody(props: DetailProps & { payload: Extract<NeedsMeEntry["
|
|
|
196
196
|
</li>
|
|
197
197
|
);
|
|
198
198
|
})}
|
|
199
|
-
{view.moreSources ? <li className="self-center text-xs text-muted-foreground">and {view.moreSources} more in the full
|
|
199
|
+
{view.moreSources ? <li className="self-center text-xs text-muted-foreground">and {view.moreSources} more in the full Proposed Story</li> : null}
|
|
200
200
|
</ul>
|
|
201
201
|
</section>
|
|
202
202
|
) : null}
|
|
203
203
|
<p className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
204
204
|
<span data-draft-caption>{requestCaption(draft.request)}</span>
|
|
205
|
-
<Link className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline" data-draft-open href={view.href}>Open full
|
|
205
|
+
<Link className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline" data-draft-open href={view.href}>Open full Proposed Story<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" /></Link>
|
|
206
206
|
</p>
|
|
207
207
|
<DefaultOutcome item={item} />
|
|
208
|
-
<DecisionPanel {...props} lockedNote={<>To change it, <Link className="text-primary underline underline-offset-4" href={view.href}>open the
|
|
208
|
+
<DecisionPanel {...props} lockedNote={<>To change it, <Link className="text-primary underline underline-offset-4" href={view.href}>open the Proposed Story in Proposals</Link>.</>} />
|
|
209
209
|
</div>
|
|
210
210
|
);
|
|
211
211
|
}
|
|
@@ -23,7 +23,6 @@ import { referenceReasonCode, REFERENCE_REJECTION_REASON_CODES, normalizeReferen
|
|
|
23
23
|
import { formatFeedAge } from "../lib/reference-preview";
|
|
24
24
|
import { buildNeedsMeItemPath, parseNeedsMeItemIdFromNewsroomPathname } from "../lib/newsroom-index-filters";
|
|
25
25
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
26
|
-
import type { FeedFreshness } from "../lib/reference-feed-ranking";
|
|
27
26
|
import type { RecentDecision } from "../lib/notification-feed";
|
|
28
27
|
import { getKanbusTheme, SITE_BRAND } from "../lib/site-brand";
|
|
29
28
|
import { NeedsMeDetail, type DecisionExtras } from "./needs-me-detail";
|
|
@@ -218,22 +217,14 @@ export function ShapingStrip({ stats }: { stats: ShapingStats }) {
|
|
|
218
217
|
);
|
|
219
218
|
}
|
|
220
219
|
|
|
221
|
-
function plainDays(days: number): string {
|
|
222
|
-
if (days < 1) return `${Math.max(1, Math.round(days * 24))} hours`;
|
|
223
|
-
return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
220
|
/** The plain-words recipe, with this site's learned numbers in it. */
|
|
227
|
-
export function NeedsMeRecipe({
|
|
221
|
+
export function NeedsMeRecipe({ threshold }: { threshold: ConfidenceThreshold }) {
|
|
228
222
|
return (
|
|
229
223
|
<div className="space-y-3 rounded-[20px] bg-[var(--feed-card)] p-5 text-sm" data-needs-me-recipe>
|
|
230
224
|
<p className="text-base font-bold">A feed run by algorithms you control</p>
|
|
231
225
|
<ol className="list-decimal space-y-2.5 pl-5">
|
|
232
226
|
<li><strong>Hard gates first.</strong> Nothing continues until you decide one.</li>
|
|
233
|
-
<li><strong>Then where your answer matters most.</strong> Each item is scored by how unsure the Cyclotron is, times how much it matters
|
|
234
|
-
<li><strong>Age fades the score.</strong> {freshness.tauDays !== null && freshness.medianAgeDays !== null
|
|
235
|
-
? <>It loses half its pull every {plainDays(freshness.medianAgeDays)}, the typical age of the {freshness.examples} References you accepted.</>
|
|
236
|
-
: <>Age does not change the order yet. The pace is learned from the References you accept, and so far there are {freshness.examples} of the 10 needed.</>}</li>
|
|
227
|
+
<li><strong>Then where your answer matters most.</strong> Each item is scored by how unsure the Cyclotron is, times how much it matters. Age does not change it. A coin toss scores highest; a sure thing scores lowest; no prediction counts as halfway.</li>
|
|
237
228
|
<li><strong>A mix, not a run.</strong> Never more than two of one kind in a row while something else is waiting.</li>
|
|
238
229
|
<li><strong>Confident items do not ask for you.</strong> {threshold.threshold !== null
|
|
239
230
|
? <>Items the Cyclotron is surer about than {Math.round((1 - threshold.threshold) * 100)}% (the median of the {threshold.examples} predictions you confirmed) wait under Decided automatically. You can overrule any of them.</>
|
|
@@ -253,7 +244,6 @@ const NO_ATTACHMENTS: ReferenceAttachmentRecord[] = [];
|
|
|
253
244
|
export type NeedsMeViewProps = {
|
|
254
245
|
loadState: NeedsMeLoadState;
|
|
255
246
|
entries: NeedsMeEntry[];
|
|
256
|
-
freshness: FeedFreshness;
|
|
257
247
|
threshold: ConfidenceThreshold;
|
|
258
248
|
now: number;
|
|
259
249
|
shaping: ShapingStats;
|
|
@@ -291,7 +281,7 @@ function Note({ children, ...rest }: { children: ReactNode } & Record<string, un
|
|
|
291
281
|
|
|
292
282
|
/** The CMS console home: one feed that mixes everything that may need a person, with the References feed's cards and detail pane. */
|
|
293
283
|
export function NeedsMeView({
|
|
294
|
-
loadState, entries,
|
|
284
|
+
loadState, entries, threshold, now, shaping, recent, legacyAuto = [], settled, demo = false, stale = false, error = null, referencesLoading = false, predictionLoading = false,
|
|
295
285
|
referenceDisabledReason = null, draftsFailed = false, missingDraftCount = 0, initialFilter = "all", referenceAttachments = NO_ATTACHMENTS, captureTeaserLine, onCorrectCapture, onDecide, onEditReason, onRetryDrafts, onOpened, loadEmail, below,
|
|
296
286
|
}: NeedsMeViewProps) {
|
|
297
287
|
const themeMode = useResolvedPapyrusTheme();
|
|
@@ -312,7 +302,7 @@ export function NeedsMeView({
|
|
|
312
302
|
|
|
313
303
|
// The order is taken ONCE per visit, when every source has been read, and then left alone: selecting, deciding, a refresh or a cache
|
|
314
304
|
// revalidation changes what the cards say, never where they are. Items that arrive later wait behind a "N new items: show" pill.
|
|
315
|
-
const live = useMemo(() => rankNeedsMe(entries, {
|
|
305
|
+
const live = useMemo(() => rankNeedsMe(entries, { threshold }), [entries, threshold]);
|
|
316
306
|
const snapshot = useRef<NeedsMeSnapshot | null>(null);
|
|
317
307
|
if (!snapshot.current && settled && entries.length > 0) snapshot.current = takeNeedsMeSnapshot(live);
|
|
318
308
|
const placed = snapshot.current ? applyNeedsMeSnapshot(snapshot.current, live) : { feed: live.feed, auto: live.auto, newCount: 0 };
|
|
@@ -459,7 +449,7 @@ export function NeedsMeView({
|
|
|
459
449
|
<InfoIcon aria-hidden="true" className="size-4" />How this is ordered
|
|
460
450
|
</button>
|
|
461
451
|
</div>
|
|
462
|
-
{recipeOpen ? <NeedsMeRecipe
|
|
452
|
+
{recipeOpen ? <NeedsMeRecipe threshold={threshold} /> : null}
|
|
463
453
|
<p className="max-w-3xl text-sm text-muted-foreground" data-notification-ignore>
|
|
464
454
|
Decide what you like and leave the rest. Each item says what happens if you do nothing, and only a hard gate waits for you.
|
|
465
455
|
</p>
|
|
@@ -486,17 +476,17 @@ export function NeedsMeView({
|
|
|
486
476
|
<div className="flex flex-col items-center gap-2 rounded-[20px] bg-[var(--feed-card)] px-6 py-12 text-center" data-notification-caught-up>
|
|
487
477
|
<CircleCheckBigIcon aria-hidden="true" className="size-10 text-primary" />
|
|
488
478
|
<h2 className="text-2xl font-bold tracking-tight">All caught up</h2>
|
|
489
|
-
<p className="max-w-md text-sm text-muted-foreground">Nothing needs you right now. New
|
|
479
|
+
<p className="max-w-md text-sm text-muted-foreground">Nothing needs you right now. New Proposed Stories, references, emails and reviews will appear here as they arrive.</p>
|
|
490
480
|
</div>
|
|
491
481
|
) : null}
|
|
492
482
|
|
|
493
483
|
{draftsFailed ? (
|
|
494
484
|
<Note role="alert">
|
|
495
|
-
<p>The
|
|
485
|
+
<p>The Proposed Stories could not be loaded right now.</p>
|
|
496
486
|
<button className={cn(PILL, "mt-3 bg-[var(--feed-control)] text-foreground")} onClick={onRetryDrafts} type="button">Try again</button>
|
|
497
487
|
</Note>
|
|
498
488
|
) : null}
|
|
499
|
-
{missingDraftCount ? <p className="text-sm text-muted-foreground" data-needs-me-missing-drafts>{missingDraftCount} of these
|
|
489
|
+
{missingDraftCount ? <p className="text-sm text-muted-foreground" data-needs-me-missing-drafts>{missingDraftCount} of these Proposed Stories {missingDraftCount === 1 ? "is" : "are"} not in Proposals on this site, so they cannot be opened here.</p> : null}
|
|
500
490
|
|
|
501
491
|
{referencesLoading && !placed.feed.length ? (
|
|
502
492
|
<NotificationSkeleton count={3} bare />
|
|
@@ -52,6 +52,8 @@ import {
|
|
|
52
52
|
GENERAL_CONVERSATION_KIND,
|
|
53
53
|
REFERENCE_CONVERSATION_KIND,
|
|
54
54
|
conversationAnchorKey,
|
|
55
|
+
conversationPreviewLine,
|
|
56
|
+
threadMetadataWithLastAuthor,
|
|
55
57
|
conversationMessageId,
|
|
56
58
|
conversationThreadId,
|
|
57
59
|
conversationThreadKind,
|
|
@@ -59,12 +61,15 @@ import {
|
|
|
59
61
|
findOrCreateThread,
|
|
60
62
|
type ConversationAnchor,
|
|
61
63
|
type ConversationMessage,
|
|
64
|
+
type ConversationViewer,
|
|
62
65
|
} from "../lib/conversation";
|
|
66
|
+
import { postMessageArguments, postMessageMutation } from "../lib/post-message";
|
|
63
67
|
import { legacyStageKeysToLoad, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
|
|
64
68
|
import { getContentItemStageDefinitions } from "../lib/site-brand";
|
|
65
69
|
import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
|
|
66
70
|
import { configureAmplifyClient } from "./amplify-client-provider";
|
|
67
71
|
import { isUnauthenticatedError, loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
|
|
72
|
+
import { resolveReaderSettings } from "./reader-settings";
|
|
68
73
|
|
|
69
74
|
const USER_POOL_AUTH_MODE = "userPool";
|
|
70
75
|
const USER_POOL_LIST_LIMIT = 500;
|
|
@@ -78,7 +83,7 @@ const REFERENCE_STATUS_KEYS = ["pending", "accepted", "rejected", "archived"] as
|
|
|
78
83
|
const NEWSROOM_MESSAGE_FEED_QUERY = `
|
|
79
84
|
query ListMessagesByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelMessageFilterInput) {
|
|
80
85
|
listMessagesByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
81
|
-
items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
|
|
86
|
+
items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
|
|
82
87
|
nextToken
|
|
83
88
|
}
|
|
84
89
|
}
|
|
@@ -137,7 +142,7 @@ const CREATE_SEMANTIC_RELATION_MUTATION = `
|
|
|
137
142
|
const LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY = `
|
|
138
143
|
query ListMessagesByThreadAndSequence($threadId: ID!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
|
|
139
144
|
listMessagesByThreadAndSequence(threadId: $threadId, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
|
|
140
|
-
items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
|
|
145
|
+
items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
|
|
141
146
|
nextToken
|
|
142
147
|
}
|
|
143
148
|
}
|
|
@@ -146,7 +151,7 @@ const LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY = `
|
|
|
146
151
|
const LIST_MESSAGES_BY_KIND_AND_CREATED_AT_QUERY = `
|
|
147
152
|
query ListMessagesByKindAndCreatedAt($messageKind: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
|
|
148
153
|
listMessagesByKindAndCreatedAt(messageKind: $messageKind, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
|
|
149
|
-
items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
|
|
154
|
+
items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
|
|
150
155
|
nextToken
|
|
151
156
|
}
|
|
152
157
|
}
|
|
@@ -155,7 +160,7 @@ const LIST_MESSAGES_BY_KIND_AND_CREATED_AT_QUERY = `
|
|
|
155
160
|
const GET_MESSAGE_QUERY = `
|
|
156
161
|
query GetMessage($id: ID!) {
|
|
157
162
|
getMessage(id: $id) {
|
|
158
|
-
id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey
|
|
163
|
+
id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey
|
|
159
164
|
}
|
|
160
165
|
}
|
|
161
166
|
`;
|
|
@@ -220,40 +225,7 @@ const UPDATE_MESSAGE_THREAD_MUTATION = `
|
|
|
220
225
|
}
|
|
221
226
|
`;
|
|
222
227
|
|
|
223
|
-
const
|
|
224
|
-
mutation CreateMessage($input: CreateMessageInput!) {
|
|
225
|
-
createMessage(input: $input) {
|
|
226
|
-
id
|
|
227
|
-
messageKind
|
|
228
|
-
messageDomain
|
|
229
|
-
status
|
|
230
|
-
summary
|
|
231
|
-
source
|
|
232
|
-
importRunId
|
|
233
|
-
authorSub
|
|
234
|
-
authorUserProfileId
|
|
235
|
-
authorLabel
|
|
236
|
-
threadId
|
|
237
|
-
parentMessageId
|
|
238
|
-
sequenceNumber
|
|
239
|
-
role
|
|
240
|
-
messageType
|
|
241
|
-
content
|
|
242
|
-
semanticLayer
|
|
243
|
-
searchVisibility
|
|
244
|
-
responseTarget
|
|
245
|
-
responseStatus
|
|
246
|
-
responseOwner
|
|
247
|
-
responseStartedAt
|
|
248
|
-
responseCompletedAt
|
|
249
|
-
responseError
|
|
250
|
-
metadata
|
|
251
|
-
createdAt
|
|
252
|
-
updatedAt
|
|
253
|
-
newsroomFeedKey
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
`;
|
|
228
|
+
const POST_MESSAGE_SELECTION = "id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey";
|
|
257
229
|
|
|
258
230
|
const UPDATE_MESSAGE_MUTATION = `
|
|
259
231
|
mutation UpdateMessage($input: UpdateMessageInput!) {
|
|
@@ -268,6 +240,9 @@ const UPDATE_MESSAGE_MUTATION = `
|
|
|
268
240
|
authorSub
|
|
269
241
|
authorUserProfileId
|
|
270
242
|
authorLabel
|
|
243
|
+
editedAt
|
|
244
|
+
deletedAt
|
|
245
|
+
deletedBySub
|
|
271
246
|
threadId
|
|
272
247
|
parentMessageId
|
|
273
248
|
sequenceNumber
|
|
@@ -302,7 +277,7 @@ const NEWSROOM_ASSIGNMENT_FEED_QUERY = `
|
|
|
302
277
|
const NEWSROOM_REFERENCE_FEED_QUERY = `
|
|
303
278
|
query ListReferencesByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
|
|
304
279
|
listReferencesByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
305
|
-
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
|
|
280
|
+
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey discoveredByMonitorId updatedAt }
|
|
306
281
|
nextToken
|
|
307
282
|
}
|
|
308
283
|
}
|
|
@@ -311,7 +286,7 @@ const NEWSROOM_REFERENCE_FEED_QUERY = `
|
|
|
311
286
|
const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
|
|
312
287
|
query ListReferencesByNewsroomFeedAndImportedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
|
|
313
288
|
listReferencesByNewsroomFeedAndImportedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
314
|
-
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
|
|
289
|
+
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey discoveredByMonitorId updatedAt }
|
|
315
290
|
nextToken
|
|
316
291
|
}
|
|
317
292
|
}
|
|
@@ -320,7 +295,7 @@ const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
|
|
|
320
295
|
const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
|
|
321
296
|
query ListReferencesByCurationStatusAndUpdatedAt($curationStatus: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
|
|
322
297
|
listReferencesByCurationStatusAndUpdatedAt(curationStatus: $curationStatus, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
323
|
-
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
|
|
298
|
+
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey discoveredByMonitorId updatedAt }
|
|
324
299
|
nextToken
|
|
325
300
|
}
|
|
326
301
|
}
|
|
@@ -329,7 +304,7 @@ const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
|
|
|
329
304
|
const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
|
|
330
305
|
query ListReferencesByReviewedFeedAndUpdatedAt($reviewedFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
|
|
331
306
|
listReferencesByReviewedFeedAndUpdatedAt(reviewedFeedKey: $reviewedFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
|
|
332
|
-
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
|
|
307
|
+
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey discoveredByMonitorId updatedAt }
|
|
333
308
|
nextToken
|
|
334
309
|
}
|
|
335
310
|
}
|
|
@@ -338,7 +313,7 @@ const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
|
|
|
338
313
|
const LIST_REFERENCES_FALLBACK_QUERY = `
|
|
339
314
|
query ListReferencesFallback($limit: Int, $nextToken: String) {
|
|
340
315
|
listReferences(limit: $limit, nextToken: $nextToken) {
|
|
341
|
-
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt }
|
|
316
|
+
items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey discoveredByMonitorId updatedAt }
|
|
342
317
|
nextToken
|
|
343
318
|
}
|
|
344
319
|
}
|
|
@@ -471,7 +446,7 @@ const MODEL_ATTACHMENTS_BY_OWNER_QUERY = `
|
|
|
471
446
|
|
|
472
447
|
/** Matches Reference model fields in amplify/data/resource.ts (no metadata on Reference). */
|
|
473
448
|
const REFERENCE_RECORD_GRAPHQL_FIELDS =
|
|
474
|
-
"id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey updatedAt";
|
|
449
|
+
"id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason curationReasonCode curationActorKind curationSectionId curationShareable curationDelegation curationDecisionRelationId newsroomFeedKey reviewedFeedKey discoveredByMonitorId updatedAt";
|
|
475
450
|
|
|
476
451
|
const LIST_REFERENCES_BY_LINEAGE_AND_VERSION_QUERY = `
|
|
477
452
|
query ListReferencesByLineageAndVersion($lineageId: ID!, $limit: Int, $nextToken: String) {
|
|
@@ -1423,7 +1398,7 @@ export async function createSectionForumThreadRecord(input: {
|
|
|
1423
1398
|
* Appends one Message to a thread: the next sequence number, the Message, then the thread's count and last Message. The Message id
|
|
1424
1399
|
* derives from the sequence number, so a create that loses a race for the number collides and retries with the next one.
|
|
1425
1400
|
*/
|
|
1426
|
-
async function appendMessageToThread(thread: MessageThreadRecord, build: (sequenceNumber: number, now: string) => MessageRecord): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
|
|
1401
|
+
async function appendMessageToThread(thread: MessageThreadRecord, build: (sequenceNumber: number, now: string) => MessageRecord, threadFields: { summary?: string; metadata?: string } = {}): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
|
|
1427
1402
|
const threadId = thread.id;
|
|
1428
1403
|
let now = new Date().toISOString();
|
|
1429
1404
|
let built: MessageRecord | null = null;
|
|
@@ -1434,10 +1409,12 @@ async function appendMessageToThread(thread: MessageThreadRecord, build: (sequen
|
|
|
1434
1409
|
// `createdAt` is part of the composite sort key of messagesByResponseTargetStatusCreatedAt, so AppSync refuses a Message that
|
|
1435
1410
|
// sets it without `responseStatus`. A person's message waits on no responder: COMPLETED, as the Python message contract writes.
|
|
1436
1411
|
built = { responseStatus: "COMPLETED", ...build(sequenceNumber, now) };
|
|
1437
|
-
|
|
1412
|
+
// The backend stamps the author from the signed-in identity (postMessage); nothing here names one.
|
|
1413
|
+
const args = postMessageArguments(built as unknown as Record<string, unknown>);
|
|
1414
|
+
return runGraphql<{ postMessage?: MessageRecord | null }>(postMessageMutation(args, POST_MESSAGE_SELECTION), args);
|
|
1438
1415
|
},
|
|
1439
1416
|
});
|
|
1440
|
-
const createdMessage = normalizeMessageRecord(created.
|
|
1417
|
+
const createdMessage = normalizeMessageRecord(created.postMessage ?? built!);
|
|
1441
1418
|
const updated = await runGraphql<{ updateMessageThread?: MessageThreadRecord | null }>(
|
|
1442
1419
|
UPDATE_MESSAGE_THREAD_MUTATION,
|
|
1443
1420
|
{
|
|
@@ -1447,11 +1424,12 @@ async function appendMessageToThread(thread: MessageThreadRecord, build: (sequen
|
|
|
1447
1424
|
lastMessageId: createdMessage.id,
|
|
1448
1425
|
lastMessageAt: createdMessage.createdAt,
|
|
1449
1426
|
updatedAt: now,
|
|
1427
|
+
...threadFields,
|
|
1450
1428
|
},
|
|
1451
1429
|
},
|
|
1452
1430
|
);
|
|
1453
1431
|
return {
|
|
1454
|
-
thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, updatedAt: now }),
|
|
1432
|
+
thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, ...threadFields, updatedAt: now }),
|
|
1455
1433
|
message: createdMessage,
|
|
1456
1434
|
};
|
|
1457
1435
|
}
|
|
@@ -1493,7 +1471,7 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1493
1471
|
}));
|
|
1494
1472
|
}
|
|
1495
1473
|
|
|
1496
|
-
type ConversationAuthor = { sub: string | null; label: string };
|
|
1474
|
+
type ConversationAuthor = { sub: string | null; label: string; userProfileId: string | null; isAdmin: boolean };
|
|
1497
1475
|
|
|
1498
1476
|
async function currentConversationAuthor(): Promise<ConversationAuthor> {
|
|
1499
1477
|
const snapshot = await loadReaderSessionSnapshot();
|
|
@@ -1505,7 +1483,50 @@ async function currentConversationAuthor(): Promise<ConversationAuthor> {
|
|
|
1505
1483
|
} catch {
|
|
1506
1484
|
sub = null;
|
|
1507
1485
|
}
|
|
1508
|
-
|
|
1486
|
+
const profile = await currentUserProfile();
|
|
1487
|
+
// The author's name travels on the message (other editors cannot read profiles): their display name, else the session's label.
|
|
1488
|
+
const sessionLabel = snapshot.auth.status === "signedIn" ? snapshot.auth.label : "editor";
|
|
1489
|
+
return { sub, label: profile.displayName || sessionLabel, userProfileId: profile.id, isAdmin: snapshot.groups.some((group) => group.trim().toLowerCase() === "admin") };
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
let userProfileLookup: Promise<{ id: string | null; displayName: string | null }> | null = null;
|
|
1493
|
+
/** The signed-in person's profile id and display name (their own profile is readable by them), read once per page load. A failed read is not kept, and does not stop a post. */
|
|
1494
|
+
function currentUserProfile(): Promise<{ id: string | null; displayName: string | null }> {
|
|
1495
|
+
userProfileLookup ??= (async () => {
|
|
1496
|
+
const resolution = await resolveReaderSettings();
|
|
1497
|
+
const id = resolution.userProfileId ?? null;
|
|
1498
|
+
if (!id) return { id: null, displayName: null };
|
|
1499
|
+
const names = await loadUserProfileNames([id]);
|
|
1500
|
+
return { id, displayName: names.get(id) ?? null };
|
|
1501
|
+
})().catch(() => { userProfileLookup = null; return { id: null, displayName: null }; });
|
|
1502
|
+
return userProfileLookup;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/** Who is looking at a conversation: their sub, and whether they are an admin. Decided from the session, never from a Message. */
|
|
1506
|
+
export async function loadConversationViewer(): Promise<ConversationViewer> {
|
|
1507
|
+
const author = await currentConversationAuthor();
|
|
1508
|
+
return { sub: author.sub, isAdmin: author.isAdmin };
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** Display names by profile id, in one request. A profile this person may not read is left out and its Messages keep the saved label. */
|
|
1512
|
+
export async function loadUserProfileNames(profileIds: string[]): Promise<Map<string, string>> {
|
|
1513
|
+
const ids = [...new Set(profileIds.filter(Boolean))];
|
|
1514
|
+
const names = new Map<string, string>();
|
|
1515
|
+
if (!ids.length) return names;
|
|
1516
|
+
const query = `query UserProfileNames { ${ids.map((id, index) => `p${index}: getUserProfile(id: ${JSON.stringify(id)}) { id displayName }`).join(" ")} }`;
|
|
1517
|
+
configureAmplifyClient();
|
|
1518
|
+
const client = generateClient<Schema>() as unknown as { graphql: (options: Record<string, unknown>) => Promise<{ data?: Record<string, { id?: string; displayName?: string | null } | null> | null }> };
|
|
1519
|
+
let data: Record<string, { id?: string; displayName?: string | null } | null> | null | undefined;
|
|
1520
|
+
try {
|
|
1521
|
+
data = (await client.graphql({ query, authMode: USER_POOL_AUTH_MODE })).data;
|
|
1522
|
+
} catch (failure) {
|
|
1523
|
+
// Some of the profiles are refused: the rest of the answer is still good.
|
|
1524
|
+
data = (failure as { data?: typeof data } | null)?.data;
|
|
1525
|
+
}
|
|
1526
|
+
for (const profile of Object.values(data ?? {})) {
|
|
1527
|
+
if (profile?.id && profile.displayName?.trim()) names.set(profile.id, profile.displayName.trim());
|
|
1528
|
+
}
|
|
1529
|
+
return names;
|
|
1509
1530
|
}
|
|
1510
1531
|
|
|
1511
1532
|
async function findConversationThread(anchorKey: string): Promise<MessageThreadRecord | null> {
|
|
@@ -1539,6 +1560,7 @@ export async function ensureConversationThread(anchor: ConversationAnchor, autho
|
|
|
1539
1560
|
primaryAnchorLineageId: lineageId,
|
|
1540
1561
|
primaryAnchorKey: anchorKey,
|
|
1541
1562
|
createdBySub: author?.sub ?? null,
|
|
1563
|
+
createdByUserProfileId: author?.userProfileId ?? null,
|
|
1542
1564
|
createdByLabel: author?.label ?? "editor",
|
|
1543
1565
|
messageCount: 0,
|
|
1544
1566
|
lastMessageId: null,
|
|
@@ -1559,9 +1581,14 @@ function toConversationMessage(record: MessageRecord): ConversationMessage {
|
|
|
1559
1581
|
id: record.id,
|
|
1560
1582
|
parentMessageId: record.parentMessageId ?? null,
|
|
1561
1583
|
authorLabel: record.authorLabel ?? "",
|
|
1562
|
-
|
|
1584
|
+
authorSub: record.authorSub ?? null,
|
|
1585
|
+
authorUserProfileId: record.authorUserProfileId ?? null,
|
|
1586
|
+
content: record.status === "deleted" ? "" : record.content ?? record.body ?? "",
|
|
1563
1587
|
createdAt: record.createdAt,
|
|
1564
1588
|
sequenceNumber: Number(record.sequenceNumber ?? 0),
|
|
1589
|
+
editedAt: record.editedAt ?? null,
|
|
1590
|
+
deleted: record.status === "deleted",
|
|
1591
|
+
deletedAt: record.deletedAt ?? null,
|
|
1565
1592
|
};
|
|
1566
1593
|
}
|
|
1567
1594
|
|
|
@@ -1572,7 +1599,7 @@ export async function loadConversationMessages(anchor: ConversationAnchor): Prom
|
|
|
1572
1599
|
const thread = await findConversationThread(anchorKey);
|
|
1573
1600
|
if (!thread) return [];
|
|
1574
1601
|
const records = await listMessagesByThreadId(thread.id, 500);
|
|
1575
|
-
return records.
|
|
1602
|
+
return records.map(toConversationMessage);
|
|
1576
1603
|
}
|
|
1577
1604
|
|
|
1578
1605
|
/** How many Messages each Reference has, from the Reference conversation threads alone (no query per card). Keyed by lineage id. */
|
|
@@ -1608,7 +1635,6 @@ export async function appendConversationMessage(input: {
|
|
|
1608
1635
|
status: "active",
|
|
1609
1636
|
summary: content.slice(0, 120),
|
|
1610
1637
|
source: "newsroom.conversation",
|
|
1611
|
-
authorSub: author.sub,
|
|
1612
1638
|
authorLabel: author.label,
|
|
1613
1639
|
threadId: thread.id,
|
|
1614
1640
|
parentMessageId: input.parentMessageId?.trim() || null,
|
|
@@ -1620,7 +1646,7 @@ export async function appendConversationMessage(input: {
|
|
|
1620
1646
|
createdAt: now,
|
|
1621
1647
|
updatedAt: now,
|
|
1622
1648
|
newsroomFeedKey: CONVERSATION_FEED_KEY,
|
|
1623
|
-
}));
|
|
1649
|
+
}), { summary: conversationPreviewLine(content), metadata: threadMetadataWithLastAuthor(thread.metadata, author.label) });
|
|
1624
1650
|
let relationError: string | null = null;
|
|
1625
1651
|
if (input.anchor.kind !== "none") {
|
|
1626
1652
|
try {
|
|
@@ -1644,12 +1670,14 @@ export async function loadConversationThreadPage(threadKind: string, nextToken:
|
|
|
1644
1670
|
/** Every Message in a conversation thread, oldest first. */
|
|
1645
1671
|
export async function loadConversationThreadMessages(threadId: string): Promise<ConversationMessage[]> {
|
|
1646
1672
|
const records = await listMessagesByThreadId(threadId, 500);
|
|
1647
|
-
return records.
|
|
1673
|
+
return records.map(toConversationMessage);
|
|
1648
1674
|
}
|
|
1649
1675
|
|
|
1650
1676
|
/** Starts a conversation about nothing but itself: its own thread, and its first Message. */
|
|
1651
|
-
export async function startUnattachedConversation(body: string): Promise<{ message: ConversationMessage; thread: MessageThreadRecord }> {
|
|
1677
|
+
export async function startUnattachedConversation(title: string, body: string): Promise<{ message: ConversationMessage; thread: MessageThreadRecord }> {
|
|
1652
1678
|
const content = String(body || "").trim();
|
|
1679
|
+
const heading = String(title || "").trim();
|
|
1680
|
+
if (!heading) throw new Error("Give the conversation a title.");
|
|
1653
1681
|
if (!content) throw new Error("Write something to post.");
|
|
1654
1682
|
const author = await currentConversationAuthor();
|
|
1655
1683
|
const now = new Date().toISOString();
|
|
@@ -1658,8 +1686,9 @@ export async function startUnattachedConversation(body: string): Promise<{ messa
|
|
|
1658
1686
|
id,
|
|
1659
1687
|
threadKind: GENERAL_CONVERSATION_KIND,
|
|
1660
1688
|
status: "active",
|
|
1661
|
-
title:
|
|
1689
|
+
title: heading.slice(0, 120),
|
|
1662
1690
|
createdBySub: author.sub,
|
|
1691
|
+
createdByUserProfileId: author.userProfileId,
|
|
1663
1692
|
createdByLabel: author.label,
|
|
1664
1693
|
messageCount: 0,
|
|
1665
1694
|
lastMessageId: null,
|
|
@@ -1675,6 +1704,62 @@ export async function startUnattachedConversation(body: string): Promise<{ messa
|
|
|
1675
1704
|
return { message: result.message, thread: result.thread };
|
|
1676
1705
|
}
|
|
1677
1706
|
|
|
1707
|
+
const GET_MESSAGE_THREAD_QUERY = `
|
|
1708
|
+
query GetMessageThread($id: ID!) {
|
|
1709
|
+
getMessageThread(id: $id) {
|
|
1710
|
+
id threadKind status title summary primaryAnchorKind primaryAnchorId primaryAnchorLineageId primaryAnchorKey createdBySub createdByUserProfileId createdByLabel messageCount lastMessageId lastMessageAt contextDigest activeResponseMessageId responseLockOwner responseLockExpiresAt metadata createdAt updatedAt newsroomFeedKey
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
`;
|
|
1714
|
+
|
|
1715
|
+
const MESSAGE_ACTION_SELECTION = "id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel editedAt deletedAt deletedBySub threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey";
|
|
1716
|
+
|
|
1717
|
+
/** The author changes the text of their own Message. The backend (editMessage) checks the stored author against the signed-in user and records when. */
|
|
1718
|
+
export async function editConversationMessage(messageId: string, body: string): Promise<ConversationMessage> {
|
|
1719
|
+
const content = String(body || "").trim();
|
|
1720
|
+
if (!content) throw new Error("A message cannot be empty.");
|
|
1721
|
+
const updated = await runGraphql<{ editMessage?: MessageRecord | null }>(
|
|
1722
|
+
`mutation EditMessage($id: ID!, $content: String!, $summary: String) { editMessage(id: $id, content: $content, summary: $summary) { ${MESSAGE_ACTION_SELECTION} } }`,
|
|
1723
|
+
{ id: messageId, content, summary: content.slice(0, 120) },
|
|
1724
|
+
);
|
|
1725
|
+
if (!updated.editMessage) throw new Error("The message was not saved.");
|
|
1726
|
+
return toConversationMessage(normalizeMessageRecord(updated.editMessage));
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Deletes a Message softly: it stays (so its replies keep their place) marked deleted, with when and by whom. Nothing is removed.
|
|
1731
|
+
* An author deletes their own (deleteOwnMessage); an admin deleting someone else's moderates it (moderateMessage). The backend decides.
|
|
1732
|
+
*/
|
|
1733
|
+
export async function deleteConversationMessage(messageId: string, moderate = false): Promise<ConversationMessage> {
|
|
1734
|
+
const field = moderate ? "moderateMessage" : "deleteOwnMessage";
|
|
1735
|
+
const updated = await runGraphql<Record<string, MessageRecord | null | undefined>>(
|
|
1736
|
+
`mutation DeleteMessage($id: ID!) { ${field}(id: $id) { ${MESSAGE_ACTION_SELECTION} } }`,
|
|
1737
|
+
{ id: messageId },
|
|
1738
|
+
);
|
|
1739
|
+
const record = updated[field];
|
|
1740
|
+
if (!record) throw new Error("The message was not deleted.");
|
|
1741
|
+
return toConversationMessage(normalizeMessageRecord(record));
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/** One conversation thread by id (a linked address), or null when there is none or it was removed. */
|
|
1745
|
+
export async function loadConversationThread(threadId: string): Promise<MessageThreadRecord | null> {
|
|
1746
|
+
const response = await runGraphql<{ getMessageThread?: MessageThreadRecord | null }>(GET_MESSAGE_THREAD_QUERY, { id: threadId });
|
|
1747
|
+
const thread = response.getMessageThread ? normalizeThreadRecord(response.getMessageThread) : null;
|
|
1748
|
+
return thread && thread.status !== "deleted" ? thread : null;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/** Gives a conversation a new title. Nothing else about the thread changes. */
|
|
1752
|
+
export async function renameConversationThread(thread: MessageThreadRecord, title: string): Promise<MessageThreadRecord> {
|
|
1753
|
+
const next = String(title || "").trim();
|
|
1754
|
+
if (!next) throw new Error("A title cannot be empty.");
|
|
1755
|
+
// renameThread (the backend): only the person who started the conversation, or an admin.
|
|
1756
|
+
const updated = await runGraphql<{ renameThread?: MessageThreadRecord | null }>(
|
|
1757
|
+
`mutation RenameThread($id: ID!, $title: String!) { renameThread(id: $id, title: $title) { id threadKind status title summary primaryAnchorKind primaryAnchorId primaryAnchorLineageId primaryAnchorKey createdBySub createdByUserProfileId createdByLabel messageCount lastMessageId lastMessageAt contextDigest activeResponseMessageId responseLockOwner responseLockExpiresAt metadata createdAt updatedAt newsroomFeedKey } }`,
|
|
1758
|
+
{ id: thread.id, title: next.slice(0, 120) },
|
|
1759
|
+
);
|
|
1760
|
+
return normalizeThreadRecord(updated.renameThread ?? { ...thread, title: next.slice(0, 120) });
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1678
1763
|
/** The title and editorial stage of each Story, by lineage id. A Story that cannot be read has no entry. */
|
|
1679
1764
|
export async function loadStoryHeaders(lineageIds: string[]): Promise<Map<string, { title: string; stage: string | null }>> {
|
|
1680
1765
|
const headers = new Map<string, { title: string; stage: string | null }>();
|
|
@@ -513,10 +513,15 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
513
513
|
}
|
|
514
514
|
if (!editor) {
|
|
515
515
|
return (
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
<div className="
|
|
516
|
+
// The same blocks, in the same order and heights, as the loaded editor (toolbar, then fields, tabs and the preview card),
|
|
517
|
+
// so nothing moves when the article arrives. The heights live in newsroom-organizer.css (data-skeleton-*).
|
|
518
|
+
<div aria-label="Loading article" aria-busy="true" className="space-y-4" data-newsroom-article-skeleton role="status">
|
|
519
|
+
<div className="rounded-md bg-muted" data-skeleton-toolbar />
|
|
520
|
+
<div className="grid gap-6">
|
|
521
|
+
<div className="rounded-md bg-muted" data-skeleton-fields />
|
|
522
|
+
<div className="h-10 w-72 max-w-full rounded-md bg-muted" data-skeleton-tabs />
|
|
523
|
+
<div className="rounded-xl bg-muted" data-skeleton-preview />
|
|
524
|
+
</div>
|
|
520
525
|
</div>
|
|
521
526
|
);
|
|
522
527
|
}
|
|
@@ -568,6 +573,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
568
573
|
<Badge data-newsroom-article-version variant="outline">{`Version ${editor.versionNumber}`}</Badge>
|
|
569
574
|
) : null}
|
|
570
575
|
<Badge
|
|
576
|
+
className="min-w-[8.5rem] justify-center"
|
|
571
577
|
data-newsroom-article-validity={validation.status}
|
|
572
578
|
variant={validation.status === "valid" ? "secondary" : invalid ? "outline" : "muted"}
|
|
573
579
|
>
|