@anthusai/papyrus 5.56.0 → 5.57.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/components/conversation-context.tsx +52 -0
- package/components/conversation-provider.tsx +20 -0
- package/components/conversation-thread.tsx +173 -0
- package/components/needs-me-reference-detail.tsx +2 -0
- package/components/news-desk-taxonomy-client.ts +241 -25
- package/components/newsroom-references-view.tsx +9 -0
- package/components/reference-conversation.tsx +43 -0
- package/components/reference-feed.tsx +15 -1
- package/components/topic-steering-workspace.tsx +3 -2
- package/components/use-conversation.ts +84 -0
- package/lib/conversation.ts +154 -0
- package/package.json +7 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
4
|
+
import type { ConversationAnchor, ConversationMessage } from "../lib/conversation";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where conversations are read and written. The newsroom provides the network one; a demo or a test provides the in-memory one. A
|
|
8
|
+
* component that finds no store shows no conversation at all.
|
|
9
|
+
*/
|
|
10
|
+
export type ConversationStore = {
|
|
11
|
+
/** Who the optimistic Message says wrote it, until the saved one replaces it. */
|
|
12
|
+
authorLabel: string;
|
|
13
|
+
load: (anchor: ConversationAnchor) => Promise<ConversationMessage[]>;
|
|
14
|
+
append: (input: { anchor: ConversationAnchor; body: string; parentMessageId: string | null }) => Promise<ConversationMessage>;
|
|
15
|
+
/** Message counts by Reference lineage, for the feed cards. */
|
|
16
|
+
counts: () => Promise<Map<string, number>>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ConversationStoreContext = createContext<ConversationStore | null>(null);
|
|
20
|
+
|
|
21
|
+
export function ConversationStoreProvider({ store, children }: { store: ConversationStore; children: ReactNode }) {
|
|
22
|
+
return <ConversationStoreContext.Provider value={store}>{children}</ConversationStoreContext.Provider>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useConversationStore(): ConversationStore | null {
|
|
26
|
+
return useContext(ConversationStoreContext);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A store that keeps everything in memory: the demo newsroom, and the tests. */
|
|
30
|
+
export function createMemoryConversationStore(authorLabel: string, seed: Record<string, ConversationMessage[]> = {}): ConversationStore {
|
|
31
|
+
const byAnchor = new Map<string, ConversationMessage[]>(Object.entries(seed));
|
|
32
|
+
const keyOf = (anchor: ConversationAnchor) => `${anchor.kind}#${anchor.lineageId ?? ""}`;
|
|
33
|
+
return {
|
|
34
|
+
authorLabel,
|
|
35
|
+
load: async (anchor) => [...(byAnchor.get(keyOf(anchor)) ?? [])],
|
|
36
|
+
append: async ({ anchor, body, parentMessageId }) => {
|
|
37
|
+
const list = byAnchor.get(keyOf(anchor)) ?? [];
|
|
38
|
+
const sequenceNumber = Math.max(0, ...list.map((message) => message.sequenceNumber)) + 1;
|
|
39
|
+
const message: ConversationMessage = {
|
|
40
|
+
id: `message-memory-${sequenceNumber}-${list.length}`,
|
|
41
|
+
parentMessageId,
|
|
42
|
+
authorLabel,
|
|
43
|
+
content: body.trim(),
|
|
44
|
+
createdAt: new Date().toISOString(),
|
|
45
|
+
sequenceNumber,
|
|
46
|
+
};
|
|
47
|
+
byAnchor.set(keyOf(anchor), [...list, message]);
|
|
48
|
+
return message;
|
|
49
|
+
},
|
|
50
|
+
counts: async () => new Map([...byAnchor].filter(([key]) => key.startsWith("reference#")).map(([key, list]) => [key.slice("reference#".length), list.length])),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, type ReactNode } from "react";
|
|
4
|
+
import { ConversationStoreProvider, createMemoryConversationStore, type ConversationStore } from "./conversation-context";
|
|
5
|
+
import { appendConversationMessage, loadConversationMessages, loadReferenceConversationCounts } from "./news-desk-taxonomy-client";
|
|
6
|
+
|
|
7
|
+
function networkConversationStore(authorLabel: string): ConversationStore {
|
|
8
|
+
return {
|
|
9
|
+
authorLabel,
|
|
10
|
+
load: loadConversationMessages,
|
|
11
|
+
append: async ({ anchor, body, parentMessageId }) => (await appendConversationMessage({ anchor, body, parentMessageId })).message,
|
|
12
|
+
counts: loadReferenceConversationCounts,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Gives the newsroom its conversations: saved to the backend, or kept in memory in the demo. */
|
|
17
|
+
export function ConversationProvider({ authorLabel, demo, children }: { authorLabel: string; demo: boolean; children: ReactNode }) {
|
|
18
|
+
const store = useMemo(() => (demo ? createMemoryConversationStore(authorLabel) : networkConversationStore(authorLabel)), [authorLabel, demo]);
|
|
19
|
+
return <ConversationStoreProvider store={store}>{children}</ConversationStoreProvider>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Loader2Icon, MessageSquarePlusIcon, ReplyIcon, TriangleAlertIcon, CornerDownRightIcon } from "lucide-react";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
|
+
import { buildConversationTree, conversationAge, conversationAuthorName, type ConversationMessage } from "../lib/conversation";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
8
|
+
import type { ConversationState } from "./use-conversation";
|
|
9
|
+
|
|
10
|
+
/** Flat pill buttons, like the feed's: a tone, never an outline. */
|
|
11
|
+
const PILL = "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-full px-3.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50";
|
|
12
|
+
const PILL_PRIMARY = cn(PILL, "bg-[var(--primary)] text-[var(--primary-foreground)]");
|
|
13
|
+
const PILL_QUIET = cn(PILL, "text-muted-foreground hover:text-foreground");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A box for writing a Message. The text clears the moment you post, so the Message shows at once; if the save fails the text comes
|
|
17
|
+
* back (unless you have typed something new) with the reason, and nothing you wrote is lost.
|
|
18
|
+
*/
|
|
19
|
+
function Composer({ autoFocus = false, buttonLabel, disabledReason, icon, label, onCancel, onSubmit, placeholder }: {
|
|
20
|
+
autoFocus?: boolean;
|
|
21
|
+
buttonLabel: string;
|
|
22
|
+
disabledReason?: string | null;
|
|
23
|
+
icon: "post" | "reply";
|
|
24
|
+
label: string;
|
|
25
|
+
onCancel?: () => void;
|
|
26
|
+
onSubmit: (body: string) => Promise<void>;
|
|
27
|
+
placeholder: string;
|
|
28
|
+
}) {
|
|
29
|
+
const [draft, setDraft] = useState("");
|
|
30
|
+
const [error, setError] = useState<string | null>(null);
|
|
31
|
+
const Icon = icon === "post" ? MessageSquarePlusIcon : ReplyIcon;
|
|
32
|
+
const canPost = !disabledReason && draft.trim().length > 0;
|
|
33
|
+
|
|
34
|
+
async function submit() {
|
|
35
|
+
const text = draft.trim();
|
|
36
|
+
if (!text || disabledReason) return;
|
|
37
|
+
setDraft("");
|
|
38
|
+
setError(null);
|
|
39
|
+
try {
|
|
40
|
+
await onSubmit(text);
|
|
41
|
+
onCancel?.();
|
|
42
|
+
} catch (failure) {
|
|
43
|
+
setDraft((current) => current || text);
|
|
44
|
+
setError(failure instanceof Error ? failure.message : "Your message could not be saved.");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="space-y-2" data-conversation-composer={icon}>
|
|
50
|
+
<AutoGrowTextarea
|
|
51
|
+
aria-label={label}
|
|
52
|
+
autoFocus={autoFocus}
|
|
53
|
+
className="block min-h-16 w-full rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-base font-normal outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
54
|
+
disabled={Boolean(disabledReason)}
|
|
55
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
56
|
+
onKeyDown={(event) => { if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); void submit(); } }}
|
|
57
|
+
placeholder={placeholder}
|
|
58
|
+
value={draft}
|
|
59
|
+
/>
|
|
60
|
+
{error ? (
|
|
61
|
+
<p className="flex items-start gap-1.5 text-sm text-destructive" data-conversation-error role="alert">
|
|
62
|
+
<TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
63
|
+
<span>Not posted: {error} Your text is kept here.</span>
|
|
64
|
+
</p>
|
|
65
|
+
) : null}
|
|
66
|
+
{disabledReason ? <p className="text-sm text-muted-foreground">{disabledReason}</p> : null}
|
|
67
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
68
|
+
<button className={PILL_PRIMARY} data-conversation-post={icon} disabled={!canPost} onClick={() => void submit()} type="button">
|
|
69
|
+
<Icon aria-hidden="true" className="size-4" />
|
|
70
|
+
{buttonLabel}
|
|
71
|
+
</button>
|
|
72
|
+
{onCancel ? <button className={PILL_QUIET} onClick={onCancel} type="button">Cancel</button> : null}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function MessageBody({ answers, message, now, onReply, replying, tone }: {
|
|
79
|
+
answers?: string | null;
|
|
80
|
+
message: ConversationMessage;
|
|
81
|
+
now: number;
|
|
82
|
+
onReply: () => void;
|
|
83
|
+
replying: boolean;
|
|
84
|
+
tone: "root" | "reply";
|
|
85
|
+
}) {
|
|
86
|
+
return (
|
|
87
|
+
<div className={cn("space-y-1.5 rounded-[var(--feed-radius-lg,10px)] p-3", tone === "root" ? "bg-[var(--feed-control)]" : "bg-[var(--feed-footer)]")} data-conversation-message={message.id} data-conversation-pending={message.pending || undefined}>
|
|
88
|
+
<p className="flex flex-wrap items-center gap-x-2 text-sm">
|
|
89
|
+
<span className="font-semibold" data-conversation-author>{conversationAuthorName(message.authorLabel)}</span>
|
|
90
|
+
<span className="text-xs text-muted-foreground" data-conversation-age>
|
|
91
|
+
{message.pending ? (
|
|
92
|
+
<span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3 animate-spin" />Sending</span>
|
|
93
|
+
) : conversationAge(message.createdAt, now)}
|
|
94
|
+
</span>
|
|
95
|
+
</p>
|
|
96
|
+
{answers ? (
|
|
97
|
+
<p className="flex items-center gap-1 text-xs text-muted-foreground" data-conversation-answers>
|
|
98
|
+
<CornerDownRightIcon aria-hidden="true" className="size-3" />
|
|
99
|
+
In reply to {answers}
|
|
100
|
+
</p>
|
|
101
|
+
) : null}
|
|
102
|
+
<p className="whitespace-pre-wrap break-words text-base" data-conversation-content>{message.content}</p>
|
|
103
|
+
{!message.pending ? (
|
|
104
|
+
<button aria-expanded={replying} className={cn(PILL_QUIET, "-ml-3")} data-conversation-reply={message.id} onClick={onReply} type="button">
|
|
105
|
+
<ReplyIcon aria-hidden="true" className="size-4" />
|
|
106
|
+
Reply
|
|
107
|
+
</button>
|
|
108
|
+
) : null}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The Messages of one conversation, replies nested one level under their top-level Message, and a box to add another. It knows
|
|
115
|
+
* nothing about where the Messages come from: the caller loads them and saves a post.
|
|
116
|
+
*/
|
|
117
|
+
export function ConversationThread({ disabledReason = null, error = null, heading = "Messages", messages, now, onPost, onRetry, state = "ready" }: {
|
|
118
|
+
disabledReason?: string | null;
|
|
119
|
+
error?: string | null;
|
|
120
|
+
heading?: string;
|
|
121
|
+
messages: ReadonlyArray<ConversationMessage>;
|
|
122
|
+
now: number;
|
|
123
|
+
onPost: (body: string, parentMessageId: string | null) => Promise<void>;
|
|
124
|
+
onRetry?: () => void;
|
|
125
|
+
state?: ConversationState;
|
|
126
|
+
}) {
|
|
127
|
+
const tree = useMemo(() => buildConversationTree(messages), [messages]);
|
|
128
|
+
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
|
129
|
+
const authorOf = (id: string) => conversationAuthorName(messages.find((message) => message.id === id)?.authorLabel);
|
|
130
|
+
const toggleReply = (id: string) => setReplyingTo((current) => (current === id ? null : id));
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<section aria-label={heading} className="space-y-3" data-conversation-thread>
|
|
134
|
+
<h3 className="text-sm font-medium">{heading}</h3>
|
|
135
|
+
{state === "loading" ? <p className="flex items-center gap-1.5 text-sm text-muted-foreground" data-conversation-loading><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading messages</p> : null}
|
|
136
|
+
{state === "error" ? (
|
|
137
|
+
<p className="flex flex-wrap items-center gap-2 text-sm text-destructive" role="alert">
|
|
138
|
+
<TriangleAlertIcon aria-hidden="true" className="size-4" />
|
|
139
|
+
Messages could not be loaded. {error}
|
|
140
|
+
{onRetry ? <button className={PILL_QUIET} onClick={onRetry} type="button">Try again</button> : null}
|
|
141
|
+
</p>
|
|
142
|
+
) : null}
|
|
143
|
+
{state === "ready" && !tree.length ? <p className="text-sm text-muted-foreground" data-conversation-empty>No messages yet. Write down what this made you think.</p> : null}
|
|
144
|
+
<ul className="space-y-3">
|
|
145
|
+
{tree.map((root) => (
|
|
146
|
+
<li className="space-y-2" key={root.message.id}>
|
|
147
|
+
<MessageBody message={root.message} now={now} onReply={() => toggleReply(root.message.id)} replying={replyingTo === root.message.id} tone="root" />
|
|
148
|
+
{root.replies.length || replyingTo === root.message.id ? (
|
|
149
|
+
<ul className="ml-4 space-y-2" data-conversation-replies>
|
|
150
|
+
{root.replies.map((reply) => (
|
|
151
|
+
<li key={reply.message.id}>
|
|
152
|
+
<MessageBody answers={reply.answers} message={reply.message} now={now} onReply={() => toggleReply(reply.message.id)} replying={replyingTo === reply.message.id} tone="reply" />
|
|
153
|
+
{replyingTo === reply.message.id ? (
|
|
154
|
+
<div className="mt-2">
|
|
155
|
+
<Composer autoFocus buttonLabel="Reply" disabledReason={disabledReason} icon="reply" label={`Reply to ${authorOf(reply.message.id)}`} onCancel={() => setReplyingTo(null)} onSubmit={(body) => onPost(body, reply.message.id)} placeholder={`Reply to ${authorOf(reply.message.id)}…`} />
|
|
156
|
+
</div>
|
|
157
|
+
) : null}
|
|
158
|
+
</li>
|
|
159
|
+
))}
|
|
160
|
+
{replyingTo === root.message.id ? (
|
|
161
|
+
<li>
|
|
162
|
+
<Composer autoFocus buttonLabel="Reply" disabledReason={disabledReason} icon="reply" label={`Reply to ${authorOf(root.message.id)}`} onCancel={() => setReplyingTo(null)} onSubmit={(body) => onPost(body, root.message.id)} placeholder={`Reply to ${authorOf(root.message.id)}…`} />
|
|
163
|
+
</li>
|
|
164
|
+
) : null}
|
|
165
|
+
</ul>
|
|
166
|
+
) : null}
|
|
167
|
+
</li>
|
|
168
|
+
))}
|
|
169
|
+
</ul>
|
|
170
|
+
<Composer buttonLabel="Post" disabledReason={disabledReason} icon="post" label="Add a message" onSubmit={(body) => onPost(body, null)} placeholder="Add a message…" />
|
|
171
|
+
</section>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -11,6 +11,7 @@ import type { NeedsMeEntry } from "../lib/needs-me-items";
|
|
|
11
11
|
import { DefaultOutcome, Heading, type DetailProps } from "./needs-me-bodies";
|
|
12
12
|
import { decidedChoiceOf } from "./needs-me-shared";
|
|
13
13
|
import { predictionFromDecision, ReviewCard, type CardPrediction } from "./reference-review-card";
|
|
14
|
+
import { ReferenceConversation } from "./reference-conversation";
|
|
14
15
|
import { ReferenceCapturePanel } from "./reference-capture-panel";
|
|
15
16
|
import { useReferenceCapture } from "./reference-capture-client";
|
|
16
17
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
@@ -57,6 +58,7 @@ export function ReferenceBody(props: DetailProps & { payload: Extract<NeedsMeEnt
|
|
|
57
58
|
titleHref={url}
|
|
58
59
|
>
|
|
59
60
|
{payload.decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={payload.decision} /></div> : null}
|
|
61
|
+
<ReferenceConversation disabledReason={disabledReason ? "Editor access is required to write messages." : null} lineageId={lineageId} referenceId={reference.id} title={referenceDisplayTitle(reference)} />
|
|
60
62
|
</ReviewCard>
|
|
61
63
|
<DefaultOutcome item={item} />
|
|
62
64
|
{incomplete ? <p className="text-sm text-muted-foreground" data-needs-me-capture-note>The captured text looks cut off or missing. Read it below. If it is incomplete, choose Paste or correct the text and say whether the capture was complete, or rate the Reference as it is.</p> : null}
|
|
@@ -10,6 +10,7 @@ import { createLocalPreviewRealPublishedVersions, createLocalPreviewAssignments,
|
|
|
10
10
|
import { generateClient } from "aws-amplify/data";
|
|
11
11
|
import { testEditorOverrideAllowed } from "../lib/test-editor-override";
|
|
12
12
|
import { downloadData, getUrl } from "aws-amplify/storage";
|
|
13
|
+
import { fetchAuthSession } from "aws-amplify/auth";
|
|
13
14
|
import type { Schema } from "../amplify/data/resource";
|
|
14
15
|
import type { NewsDeskAppendix, NewsDeskCategoryTreeNode } from "../lib/content-types";
|
|
15
16
|
import { createEmptyCategorySteeringDashboard } from "../lib/category-dashboard";
|
|
@@ -45,7 +46,19 @@ import type {
|
|
|
45
46
|
SemanticRelationRecord,
|
|
46
47
|
UserDirectoryEntry,
|
|
47
48
|
} from "../lib/category-repository";
|
|
48
|
-
import { relationTypeKey } from "../lib/semantic-graph";
|
|
49
|
+
import { relationTypeKey, semanticStateKey, semanticVersionKey } from "../lib/semantic-graph";
|
|
50
|
+
import {
|
|
51
|
+
CONVERSATION_FEED_KEY,
|
|
52
|
+
REFERENCE_CONVERSATION_KIND,
|
|
53
|
+
conversationAnchorKey,
|
|
54
|
+
conversationMessageId,
|
|
55
|
+
conversationThreadId,
|
|
56
|
+
conversationThreadKind,
|
|
57
|
+
createWithSequenceRetry,
|
|
58
|
+
findOrCreateThread,
|
|
59
|
+
type ConversationAnchor,
|
|
60
|
+
type ConversationMessage,
|
|
61
|
+
} from "../lib/conversation";
|
|
49
62
|
import { legacyStageKeysToLoad, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
|
|
50
63
|
import { getContentItemStageDefinitions } from "../lib/site-brand";
|
|
51
64
|
import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
|
|
@@ -103,6 +116,23 @@ const LIST_MESSAGE_THREADS_BY_KIND_AND_UPDATED_AT_QUERY = `
|
|
|
103
116
|
}
|
|
104
117
|
`;
|
|
105
118
|
|
|
119
|
+
const LIST_MESSAGE_THREADS_BY_ANCHOR_AND_UPDATED_AT_QUERY = `
|
|
120
|
+
query ListMessageThreadsByAnchorAndUpdatedAt($primaryAnchorKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
|
|
121
|
+
listMessageThreadsByAnchorAndUpdatedAt(primaryAnchorKey: $primaryAnchorKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
|
|
122
|
+
items {
|
|
123
|
+
id threadKind status title summary primaryAnchorKind primaryAnchorId primaryAnchorLineageId primaryAnchorKey createdBySub createdByUserProfileId createdByLabel messageCount lastMessageId lastMessageAt contextDigest activeResponseMessageId responseLockOwner responseLockExpiresAt metadata createdAt updatedAt newsroomFeedKey
|
|
124
|
+
}
|
|
125
|
+
nextToken
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
const CREATE_SEMANTIC_RELATION_MUTATION = `
|
|
131
|
+
mutation CreateSemanticRelation($input: CreateSemanticRelationInput!) {
|
|
132
|
+
createSemanticRelation(input: $input) { id }
|
|
133
|
+
}
|
|
134
|
+
`;
|
|
135
|
+
|
|
106
136
|
const LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY = `
|
|
107
137
|
query ListMessagesByThreadAndSequence($threadId: ID!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
|
|
108
138
|
listMessagesByThreadAndSequence(threadId: $threadId, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
|
|
@@ -1388,6 +1418,41 @@ export async function createSectionForumThreadRecord(input: {
|
|
|
1388
1418
|
return { created: true, thread: normalizeThreadRecord(response.createMessageThread ?? thread) };
|
|
1389
1419
|
}
|
|
1390
1420
|
|
|
1421
|
+
/**
|
|
1422
|
+
* Appends one Message to a thread: the next sequence number, the Message, then the thread's count and last Message. The Message id
|
|
1423
|
+
* derives from the sequence number, so a create that loses a race for the number collides and retries with the next one.
|
|
1424
|
+
*/
|
|
1425
|
+
async function appendMessageToThread(thread: MessageThreadRecord, build: (sequenceNumber: number, now: string) => MessageRecord): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
|
|
1426
|
+
const threadId = thread.id;
|
|
1427
|
+
let now = new Date().toISOString();
|
|
1428
|
+
let built: MessageRecord | null = null;
|
|
1429
|
+
const { created } = await createWithSequenceRetry({
|
|
1430
|
+
list: () => listMessagesByThreadId(threadId, 1000),
|
|
1431
|
+
create: (sequenceNumber) => {
|
|
1432
|
+
now = new Date().toISOString();
|
|
1433
|
+
built = build(sequenceNumber, now);
|
|
1434
|
+
return runGraphql<{ createMessage?: MessageRecord | null }>(CREATE_MESSAGE_MUTATION, { input: built });
|
|
1435
|
+
},
|
|
1436
|
+
});
|
|
1437
|
+
const createdMessage = normalizeMessageRecord(created.createMessage ?? built!);
|
|
1438
|
+
const updated = await runGraphql<{ updateMessageThread?: MessageThreadRecord | null }>(
|
|
1439
|
+
UPDATE_MESSAGE_THREAD_MUTATION,
|
|
1440
|
+
{
|
|
1441
|
+
input: {
|
|
1442
|
+
id: threadId,
|
|
1443
|
+
messageCount: (thread.messageCount ?? 0) + 1,
|
|
1444
|
+
lastMessageId: createdMessage.id,
|
|
1445
|
+
lastMessageAt: createdMessage.createdAt,
|
|
1446
|
+
updatedAt: now,
|
|
1447
|
+
},
|
|
1448
|
+
},
|
|
1449
|
+
);
|
|
1450
|
+
return {
|
|
1451
|
+
thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, updatedAt: now }),
|
|
1452
|
+
message: createdMessage,
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1391
1456
|
export async function appendForumThreadMessageRecord(input: {
|
|
1392
1457
|
threadId: string;
|
|
1393
1458
|
summary: string;
|
|
@@ -1404,11 +1469,8 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1404
1469
|
if (!summary) throw new Error("summary is required");
|
|
1405
1470
|
if (!content) throw new Error("content is required");
|
|
1406
1471
|
const thread = await getMessageThreadRecord(threadId);
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
const now = new Date().toISOString();
|
|
1410
|
-
const message: MessageRecord = {
|
|
1411
|
-
id: `message-forum-${safeForumId(threadId)}-${String(nextSequence).padStart(4, "0")}`,
|
|
1472
|
+
return appendMessageToThread(thread, (sequenceNumber, now) => ({
|
|
1473
|
+
id: `message-forum-${safeForumId(threadId)}-${String(sequenceNumber).padStart(4, "0")}`,
|
|
1412
1474
|
messageKind: "forum_post",
|
|
1413
1475
|
messageDomain: "edition_coordination",
|
|
1414
1476
|
status: "active",
|
|
@@ -1417,7 +1479,7 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1417
1479
|
authorLabel: input.authorLabel?.trim() || "editor",
|
|
1418
1480
|
threadId,
|
|
1419
1481
|
parentMessageId: input.parentMessageId?.trim() || null,
|
|
1420
|
-
sequenceNumber
|
|
1482
|
+
sequenceNumber,
|
|
1421
1483
|
role: input.role || "human",
|
|
1422
1484
|
messageType: "forum_message",
|
|
1423
1485
|
content,
|
|
@@ -1425,30 +1487,184 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1425
1487
|
createdAt: now,
|
|
1426
1488
|
updatedAt: now,
|
|
1427
1489
|
newsroomFeedKey: "messages",
|
|
1428
|
-
};
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1490
|
+
}));
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
type ConversationAuthor = { sub: string | null; label: string };
|
|
1494
|
+
|
|
1495
|
+
async function currentConversationAuthor(): Promise<ConversationAuthor> {
|
|
1496
|
+
const snapshot = await loadReaderSessionSnapshot();
|
|
1497
|
+
let sub: string | null = null;
|
|
1498
|
+
try {
|
|
1499
|
+
const session = await fetchAuthSession();
|
|
1500
|
+
const claim = session.tokens?.idToken?.payload?.sub ?? session.tokens?.accessToken?.payload?.sub;
|
|
1501
|
+
sub = typeof claim === "string" && claim ? claim : null;
|
|
1502
|
+
} catch {
|
|
1503
|
+
sub = null;
|
|
1504
|
+
}
|
|
1505
|
+
return { sub, label: snapshot.auth.status === "signedIn" ? snapshot.auth.label : "editor" };
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
async function findConversationThread(anchorKey: string): Promise<MessageThreadRecord | null> {
|
|
1509
|
+
type Response = { listMessageThreadsByAnchorAndUpdatedAt?: { items?: Array<MessageThreadRecord | null> | null } | null };
|
|
1510
|
+
const response = await runGraphql<Response>(LIST_MESSAGE_THREADS_BY_ANCHOR_AND_UPDATED_AT_QUERY, {
|
|
1511
|
+
primaryAnchorKey: anchorKey,
|
|
1512
|
+
sortDirection: "DESC",
|
|
1513
|
+
limit: 10,
|
|
1514
|
+
});
|
|
1515
|
+
const found = (response.listMessageThreadsByAnchorAndUpdatedAt?.items ?? []).filter(Boolean) as MessageThreadRecord[];
|
|
1516
|
+
const thread = found.find((entry) => entry.status !== "deleted") ?? null;
|
|
1517
|
+
return thread ? normalizeThreadRecord(thread) : null;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** The one conversation for an anchor: found by its anchor key, created when there is none. Concurrent first posts share the thread. */
|
|
1521
|
+
export async function ensureConversationThread(anchor: ConversationAnchor, author?: ConversationAuthor): Promise<MessageThreadRecord> {
|
|
1522
|
+
const anchorKey = conversationAnchorKey(anchor);
|
|
1523
|
+
if (!anchorKey) throw new Error("An unattached conversation starts its own thread.");
|
|
1524
|
+
return findOrCreateThread({
|
|
1525
|
+
find: () => findConversationThread(anchorKey),
|
|
1526
|
+
create: async () => {
|
|
1527
|
+
const now = new Date().toISOString();
|
|
1528
|
+
const lineageId = anchor.lineageId!.trim();
|
|
1529
|
+
const thread: MessageThreadRecord = {
|
|
1530
|
+
id: conversationThreadId(anchor),
|
|
1531
|
+
threadKind: conversationThreadKind(anchor),
|
|
1532
|
+
status: "active",
|
|
1533
|
+
title: anchor.title?.trim() || lineageId,
|
|
1534
|
+
primaryAnchorKind: anchor.kind,
|
|
1535
|
+
primaryAnchorId: lineageId,
|
|
1536
|
+
primaryAnchorLineageId: lineageId,
|
|
1537
|
+
primaryAnchorKey: anchorKey,
|
|
1538
|
+
createdBySub: author?.sub ?? null,
|
|
1539
|
+
createdByLabel: author?.label ?? "editor",
|
|
1540
|
+
messageCount: 0,
|
|
1541
|
+
lastMessageId: null,
|
|
1542
|
+
lastMessageAt: null,
|
|
1543
|
+
metadata: JSON.stringify({ anchorKind: anchor.kind, anchorLineageId: lineageId }),
|
|
1544
|
+
createdAt: now,
|
|
1442
1545
|
updatedAt: now,
|
|
1443
|
-
|
|
1546
|
+
newsroomFeedKey: CONVERSATION_FEED_KEY,
|
|
1547
|
+
};
|
|
1548
|
+
const response = await runGraphql<{ createMessageThread?: MessageThreadRecord | null }>(CREATE_MESSAGE_THREAD_MUTATION, { input: thread });
|
|
1549
|
+
return normalizeThreadRecord(response.createMessageThread ?? thread);
|
|
1444
1550
|
},
|
|
1445
|
-
);
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function toConversationMessage(record: MessageRecord): ConversationMessage {
|
|
1446
1555
|
return {
|
|
1447
|
-
|
|
1448
|
-
|
|
1556
|
+
id: record.id,
|
|
1557
|
+
parentMessageId: record.parentMessageId ?? null,
|
|
1558
|
+
authorLabel: record.authorLabel ?? "",
|
|
1559
|
+
content: record.content ?? record.body ?? "",
|
|
1560
|
+
createdAt: record.createdAt,
|
|
1561
|
+
sequenceNumber: Number(record.sequenceNumber ?? 0),
|
|
1449
1562
|
};
|
|
1450
1563
|
}
|
|
1451
1564
|
|
|
1565
|
+
/** Every Message in the anchor's conversation, oldest first. An anchor with no conversation yet has none. */
|
|
1566
|
+
export async function loadConversationMessages(anchor: ConversationAnchor): Promise<ConversationMessage[]> {
|
|
1567
|
+
const anchorKey = conversationAnchorKey(anchor);
|
|
1568
|
+
if (!anchorKey) return [];
|
|
1569
|
+
const thread = await findConversationThread(anchorKey);
|
|
1570
|
+
if (!thread) return [];
|
|
1571
|
+
const records = await listMessagesByThreadId(thread.id, 500);
|
|
1572
|
+
return records.filter((record) => record.status !== "deleted").map(toConversationMessage);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/** How many Messages each Reference has, from the Reference conversation threads alone (no query per card). Keyed by lineage id. */
|
|
1576
|
+
export async function loadReferenceConversationCounts(): Promise<Map<string, number>> {
|
|
1577
|
+
const threads = await listMessageThreadsByKind(REFERENCE_CONVERSATION_KIND, 500);
|
|
1578
|
+
const counts = new Map<string, number>();
|
|
1579
|
+
for (const thread of threads) {
|
|
1580
|
+
const lineageId = thread.primaryAnchorLineageId;
|
|
1581
|
+
if (lineageId && thread.status !== "deleted" && (thread.messageCount ?? 0) > 0) counts.set(lineageId, thread.messageCount ?? 0);
|
|
1582
|
+
}
|
|
1583
|
+
return counts;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Writes a Message into the anchor's conversation (finding or creating the thread), and a `comment` relation from the Message to
|
|
1588
|
+
* the Reference or Item so the thing's own detail can find it. The relation is secondary: if it fails the Message is still saved.
|
|
1589
|
+
*/
|
|
1590
|
+
export async function appendConversationMessage(input: {
|
|
1591
|
+
anchor: ConversationAnchor;
|
|
1592
|
+
body: string;
|
|
1593
|
+
parentMessageId?: string | null;
|
|
1594
|
+
}): Promise<{ message: ConversationMessage; thread: MessageThreadRecord; relationError: string | null }> {
|
|
1595
|
+
const content = String(input.body || "").trim();
|
|
1596
|
+
if (!content) throw new Error("Write something to post.");
|
|
1597
|
+
const author = await currentConversationAuthor();
|
|
1598
|
+
const thread = await ensureConversationThread(input.anchor, author);
|
|
1599
|
+
const result = await appendMessageToThread(thread, (sequenceNumber, now) => ({
|
|
1600
|
+
id: conversationMessageId(thread.id, sequenceNumber),
|
|
1601
|
+
messageKind: "comment",
|
|
1602
|
+
messageDomain: "commentary",
|
|
1603
|
+
status: "active",
|
|
1604
|
+
summary: content.slice(0, 120),
|
|
1605
|
+
source: "newsroom.conversation",
|
|
1606
|
+
authorSub: author.sub,
|
|
1607
|
+
authorLabel: author.label,
|
|
1608
|
+
threadId: thread.id,
|
|
1609
|
+
parentMessageId: input.parentMessageId?.trim() || null,
|
|
1610
|
+
sequenceNumber,
|
|
1611
|
+
role: "human",
|
|
1612
|
+
messageType: "comment",
|
|
1613
|
+
content,
|
|
1614
|
+
metadata: JSON.stringify({ messageKind: "comment", anchorKey: conversationAnchorKey(input.anchor) }),
|
|
1615
|
+
createdAt: now,
|
|
1616
|
+
updatedAt: now,
|
|
1617
|
+
newsroomFeedKey: CONVERSATION_FEED_KEY,
|
|
1618
|
+
}));
|
|
1619
|
+
let relationError: string | null = null;
|
|
1620
|
+
if (input.anchor.kind !== "none") {
|
|
1621
|
+
try {
|
|
1622
|
+
await createCommentRelation(result.message, input.anchor);
|
|
1623
|
+
} catch (error) {
|
|
1624
|
+
relationError = error instanceof Error ? error.message : "The link to the Reference could not be saved.";
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
return { message: toConversationMessage(result.message), thread: result.thread, relationError };
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
async function createCommentRelation(message: MessageRecord, anchor: ConversationAnchor): Promise<void> {
|
|
1631
|
+
if (anchor.kind === "none") return;
|
|
1632
|
+
const lineageId = anchor.lineageId.trim();
|
|
1633
|
+
const objectId = anchor.id?.trim() || lineageId;
|
|
1634
|
+
const now = message.createdAt;
|
|
1635
|
+
await runGraphql(CREATE_SEMANTIC_RELATION_MUTATION, {
|
|
1636
|
+
input: {
|
|
1637
|
+
id: `semantic-relation-${message.id}-comment`,
|
|
1638
|
+
relationState: "current",
|
|
1639
|
+
predicate: "comment",
|
|
1640
|
+
relationTypeId: "semantic-relation-type-comment",
|
|
1641
|
+
relationTypeKey: "comment",
|
|
1642
|
+
relationDomain: "commentary",
|
|
1643
|
+
subjectKind: "message",
|
|
1644
|
+
subjectId: message.id,
|
|
1645
|
+
subjectLineageId: message.id,
|
|
1646
|
+
subjectVersionNumber: 1,
|
|
1647
|
+
objectKind: anchor.kind,
|
|
1648
|
+
objectId,
|
|
1649
|
+
objectLineageId: lineageId,
|
|
1650
|
+
subjectStateKey: semanticStateKey("message", message.id),
|
|
1651
|
+
objectStateKey: semanticStateKey(anchor.kind, lineageId),
|
|
1652
|
+
objectSubjectStateKey: `${semanticStateKey(anchor.kind, lineageId)}#message`,
|
|
1653
|
+
predicateObjectStateKey: `comment#${semanticStateKey(anchor.kind, lineageId)}`,
|
|
1654
|
+
subjectVersionKey: semanticVersionKey("message", message.id),
|
|
1655
|
+
objectVersionKey: semanticVersionKey(anchor.kind, objectId),
|
|
1656
|
+
score: 1,
|
|
1657
|
+
rank: 1,
|
|
1658
|
+
reviewRecommended: false,
|
|
1659
|
+
importedAt: now,
|
|
1660
|
+
createdAt: now,
|
|
1661
|
+
updatedAt: now,
|
|
1662
|
+
newsroomFeedKey: "semanticRelations",
|
|
1663
|
+
metadata: JSON.stringify({ kind: "conversation.comment", messageKind: "comment" }),
|
|
1664
|
+
},
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1452
1668
|
export async function deleteForumThreadMessageRecord(input: {
|
|
1453
1669
|
threadId: string;
|
|
1454
1670
|
messageId: string;
|
|
@@ -50,6 +50,8 @@ import { FEED_SURFACE_PROPS, FEED_SURFACE_STYLE, ReferenceFeed } from "./referen
|
|
|
50
50
|
import { ReferenceCapturePanel } from "./reference-capture-panel";
|
|
51
51
|
import { useReferenceCapture, type CorrectCaptureAction } from "./reference-capture-client";
|
|
52
52
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
53
|
+
import { ReferenceConversation } from "./reference-conversation";
|
|
54
|
+
import { useReferenceMessageCounts } from "./use-conversation";
|
|
53
55
|
import { captureStatusesByLineage } from "../lib/reference-capture";
|
|
54
56
|
import { applyFeedOrderSnapshot, partitionReferenceFeed, takeFeedOrderSnapshot, type FeedOrderSnapshot, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
|
|
55
57
|
import { referenceDek, referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
|
|
@@ -200,6 +202,7 @@ function ReferenceDetailPanel({
|
|
|
200
202
|
decision,
|
|
201
203
|
demo = false,
|
|
202
204
|
disabled,
|
|
205
|
+
onMessageCount,
|
|
203
206
|
onReview,
|
|
204
207
|
predictionState = "ready",
|
|
205
208
|
reviewReason,
|
|
@@ -223,6 +226,7 @@ function ReferenceDetailPanel({
|
|
|
223
226
|
decision?: RelevanceDecisionView;
|
|
224
227
|
demo?: boolean;
|
|
225
228
|
disabled?: boolean;
|
|
229
|
+
onMessageCount?: (lineageId: string, count: number) => void;
|
|
226
230
|
onRelevanceReview?: (curation: RelevanceCuration) => Promise<void> | void;
|
|
227
231
|
onReview: (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
|
|
228
232
|
predictionState?: PredictionState;
|
|
@@ -308,6 +312,7 @@ function ReferenceDetailPanel({
|
|
|
308
312
|
</details>
|
|
309
313
|
) : null}
|
|
310
314
|
{delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
|
|
315
|
+
<ReferenceConversation disabledReason={disabled ? "Editor access is required to write messages." : null} lineageId={lineageId} onCount={onMessageCount} referenceId={reference.id} title={referenceDisplayTitle(reference)} />
|
|
311
316
|
</ReviewCard>
|
|
312
317
|
|
|
313
318
|
<Separator />
|
|
@@ -445,6 +450,7 @@ export function NewsroomReferencesView({
|
|
|
445
450
|
const [view, setView] = useState<ReferenceView>("feed");
|
|
446
451
|
const [addOpen, setAddOpen] = useState(false);
|
|
447
452
|
const [feedTab, setFeedTab] = useState<ReferenceFeedTabKey>("feed");
|
|
453
|
+
const { counts: messageCounts, setCount: setMessageCount } = useReferenceMessageCounts(!loading);
|
|
448
454
|
const [visitRated, setVisitRated] = useState<ReadonlySet<string>>(() => new Set());
|
|
449
455
|
const [mountedAt] = useState(() => Date.now());
|
|
450
456
|
const markVisitRated = useCallback((lineageId: string) => {
|
|
@@ -774,6 +780,7 @@ export function NewsroomReferencesView({
|
|
|
774
780
|
<ReferenceFeed
|
|
775
781
|
disabledReason={feedDisabledReason}
|
|
776
782
|
loading={previewSkeleton || (loading && !references.length)}
|
|
783
|
+
messageCounts={messageCounts}
|
|
777
784
|
now={mountedAt}
|
|
778
785
|
onOpen={selectReference}
|
|
779
786
|
onRate={rateFromFeed}
|
|
@@ -909,6 +916,7 @@ export function NewsroomReferencesView({
|
|
|
909
916
|
decision={decisions?.get(referenceLineageId(selectedReference))}
|
|
910
917
|
demo={demo}
|
|
911
918
|
disabled={disabled}
|
|
919
|
+
onMessageCount={setMessageCount}
|
|
912
920
|
onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
|
|
913
921
|
onReview={runReview}
|
|
914
922
|
sectionAgreement={sectionAgreement}
|
|
@@ -952,6 +960,7 @@ export function NewsroomReferencesView({
|
|
|
952
960
|
demo={demo}
|
|
953
961
|
disabled={disabled}
|
|
954
962
|
onClose={closeDetail}
|
|
963
|
+
onMessageCount={setMessageCount}
|
|
955
964
|
onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
|
|
956
965
|
onReview={runReview}
|
|
957
966
|
sectionAgreement={sectionAgreement}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { ConversationThread } from "./conversation-thread";
|
|
5
|
+
import { useConversation } from "./use-conversation";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Messages about one Reference, under its review card. Shows nothing where no conversation store is provided.
|
|
9
|
+
* `onCount` tells the feed how many Messages there are, so its card count follows what is written here.
|
|
10
|
+
*/
|
|
11
|
+
export function ReferenceConversation({ disabledReason = null, lineageId, onCount, referenceId, title }: {
|
|
12
|
+
disabledReason?: string | null;
|
|
13
|
+
lineageId: string;
|
|
14
|
+
onCount?: (lineageId: string, count: number) => void;
|
|
15
|
+
/** The Reference version's id, when it differs from the lineage id. */
|
|
16
|
+
referenceId?: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
}) {
|
|
19
|
+
const conversation = useConversation({ kind: "reference", lineageId, title, ...(referenceId ? { id: referenceId } : {}) });
|
|
20
|
+
const [now, setNow] = useState(() => Date.now());
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const timer = window.setInterval(() => setNow(Date.now()), 60000);
|
|
23
|
+
return () => window.clearInterval(timer);
|
|
24
|
+
}, []);
|
|
25
|
+
const settled = conversation.messages.filter((message) => !message.pending).length;
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (conversation.state === "ready") onCount?.(lineageId, settled);
|
|
28
|
+
}, [conversation.state, lineageId, onCount, settled]);
|
|
29
|
+
if (!conversation.available) return null;
|
|
30
|
+
return (
|
|
31
|
+
<div data-reference-conversation={lineageId}>
|
|
32
|
+
<ConversationThread
|
|
33
|
+
disabledReason={disabledReason}
|
|
34
|
+
error={conversation.error}
|
|
35
|
+
messages={conversation.messages}
|
|
36
|
+
now={now}
|
|
37
|
+
onPost={conversation.post}
|
|
38
|
+
onRetry={conversation.retry}
|
|
39
|
+
state={conversation.state}
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { InfoIcon, Loader2Icon } from "lucide-react";
|
|
3
|
+
import { InfoIcon, Loader2Icon, MessageSquareIcon } from "lucide-react";
|
|
4
4
|
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
5
5
|
import type { ReferenceRecord } from "../lib/category-repository";
|
|
6
6
|
import { REFERENCE_REJECTION_REASON_CODES, isAutoAccepted, normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
@@ -25,6 +25,7 @@ import { CaptureBadge } from "./reference-capture-panel";
|
|
|
25
25
|
import { AutoAcceptedBadge } from "./auto-accepted-badge";
|
|
26
26
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
27
27
|
import type { CaptureStatus } from "../lib/reference-capture";
|
|
28
|
+
import { messageCountLabel } from "../lib/conversation";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Flat surfaces, no borders: the page tone, a card tone one step away from it, a footer tone, and a control tone for chips and
|
|
@@ -56,6 +57,8 @@ export type ReferenceFeedProps = {
|
|
|
56
57
|
disabledReason?: string | null;
|
|
57
58
|
/** True while the first read is in flight: cards are skeletons. */
|
|
58
59
|
loading?: boolean;
|
|
60
|
+
/** How many Messages each Reference has, by lineage id. A card with none shows no count. */
|
|
61
|
+
messageCounts?: ReadonlyMap<string, number>;
|
|
59
62
|
now: number;
|
|
60
63
|
onOpen: (lineageId: string) => void;
|
|
61
64
|
onRate: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null) => Promise<void> | void;
|
|
@@ -201,6 +204,7 @@ export function FeedImage({ host, lead, name, src }: { host: string | null; lead
|
|
|
201
204
|
|
|
202
205
|
export function FeedCard({
|
|
203
206
|
capture,
|
|
207
|
+
messageCount = 0,
|
|
204
208
|
decision,
|
|
205
209
|
disabledReason,
|
|
206
210
|
item,
|
|
@@ -216,6 +220,8 @@ export function FeedCard({
|
|
|
216
220
|
}: {
|
|
217
221
|
/** Optional: the capture badge. */
|
|
218
222
|
capture?: CaptureStatus | undefined;
|
|
223
|
+
/** Optional: how many Messages this Reference has. */
|
|
224
|
+
messageCount?: number;
|
|
219
225
|
decision: RelevanceDecisionView | undefined;
|
|
220
226
|
disabledReason: string | null;
|
|
221
227
|
item: FeedItem;
|
|
@@ -294,6 +300,12 @@ export function FeedCard({
|
|
|
294
300
|
{host}
|
|
295
301
|
</span>
|
|
296
302
|
{capture ? <CaptureBadge status={capture} /> : null}
|
|
303
|
+
{messageCount > 0 ? (
|
|
304
|
+
<span aria-label={messageCountLabel(messageCount)} className="inline-flex items-center gap-1 tabular-nums" data-feed-message-count={messageCount} role="img">
|
|
305
|
+
<MessageSquareIcon aria-hidden="true" className="size-3.5" />
|
|
306
|
+
{messageCount}
|
|
307
|
+
</span>
|
|
308
|
+
) : null}
|
|
297
309
|
{isAutoAccepted(reference) ? <AutoAcceptedBadge actor={reference.curationStatusUpdatedBy} reason={reference.curationStatusReason} /> : null}
|
|
298
310
|
</span>
|
|
299
311
|
</span>
|
|
@@ -398,6 +410,7 @@ export function SectionHeading({ children, id }: { children: ReactNode; id: stri
|
|
|
398
410
|
export function ReferenceFeed({
|
|
399
411
|
disabledReason = null,
|
|
400
412
|
loading = false,
|
|
413
|
+
messageCounts,
|
|
401
414
|
now,
|
|
402
415
|
onOpen,
|
|
403
416
|
onRate,
|
|
@@ -426,6 +439,7 @@ export function ReferenceFeed({
|
|
|
426
439
|
item={item}
|
|
427
440
|
key={item.lineageId}
|
|
428
441
|
lead={lead}
|
|
442
|
+
messageCount={messageCounts?.get(item.lineageId)}
|
|
429
443
|
now={now}
|
|
430
444
|
onOpen={() => onOpen(item.lineageId)}
|
|
431
445
|
onRate={async (reference, choice, reasonCode) => { onVisitRated(item.lineageId); await onRate(reference, choice, reasonCode); }}
|
|
@@ -101,6 +101,7 @@ import { referencePreviewsByLineage } from "../lib/reference-preview";
|
|
|
101
101
|
import { NewsroomOpsOverview } from "./newsroom-ops-overview";
|
|
102
102
|
import { NewsroomCurationBootstrap } from "./newsroom-curation-bootstrap";
|
|
103
103
|
import { NewsroomReferencesView } from "./newsroom-references-view";
|
|
104
|
+
import { ConversationProvider } from "./conversation-provider";
|
|
104
105
|
import { NewsroomAssignmentsView } from "./newsroom-assignments-view";
|
|
105
106
|
import { NewsroomTopicsView } from "./newsroom-topics-view";
|
|
106
107
|
import { MarkdownContext } from "./markdown-context";
|
|
@@ -3049,7 +3050,7 @@ function NewsDeskDashboard({
|
|
|
3049
3050
|
: formatDeskSectionHeadline(activeTab);
|
|
3050
3051
|
|
|
3051
3052
|
return (
|
|
3052
|
-
|
|
3053
|
+
<ConversationProvider authorLabel={authState.label} demo={Boolean(dashboard.isDemo)}>
|
|
3053
3054
|
<NewsroomOpsShell
|
|
3054
3055
|
activeTab={activeTab}
|
|
3055
3056
|
appTitle={SITE_BRAND.appTitle}
|
|
@@ -3286,7 +3287,7 @@ function NewsDeskDashboard({
|
|
|
3286
3287
|
</div>
|
|
3287
3288
|
</NewsroomOpsShell>
|
|
3288
3289
|
{topBarSearchControl.dialog}
|
|
3289
|
-
|
|
3290
|
+
</ConversationProvider>
|
|
3290
3291
|
);
|
|
3291
3292
|
}
|
|
3292
3293
|
|
|
@@ -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,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversations: Messages written about a Reference (later a Story, or nothing at all). One conversation per thing, held in one
|
|
3
|
+
* MessageThread found by its anchor key. Pure helpers only, so the browser and the tests share them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const CONVERSATION_FEED_KEY = "conversations";
|
|
7
|
+
export const REFERENCE_CONVERSATION_KIND = "reference_conversation";
|
|
8
|
+
export const STORY_CONVERSATION_KIND = "story_conversation";
|
|
9
|
+
export const GENERAL_CONVERSATION_KIND = "conversation";
|
|
10
|
+
|
|
11
|
+
/** What a conversation is about. `none` is a conversation that is about nothing but itself. */
|
|
12
|
+
export type ConversationAnchor =
|
|
13
|
+
| { kind: "reference" | "item"; lineageId: string; /** The version id when it differs from the lineage id. */ id?: string; title?: string }
|
|
14
|
+
| { kind: "none"; lineageId?: undefined; title?: string };
|
|
15
|
+
|
|
16
|
+
/** A Message as the conversation shows it. */
|
|
17
|
+
export type ConversationMessage = {
|
|
18
|
+
id: string;
|
|
19
|
+
parentMessageId: string | null;
|
|
20
|
+
authorLabel: string;
|
|
21
|
+
content: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
sequenceNumber: number;
|
|
24
|
+
/** True while an optimistic Message waits for the save. */
|
|
25
|
+
pending?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ConversationReply = { message: ConversationMessage; answers: string | null };
|
|
29
|
+
export type ConversationRoot = { message: ConversationMessage; replies: ConversationReply[] };
|
|
30
|
+
|
|
31
|
+
function slug(value: string): string {
|
|
32
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120) || "thing";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `reference#<lineageId>`, `item#<lineageId>`, or null when the conversation is anchored to nothing. */
|
|
36
|
+
export function conversationAnchorKey(anchor: ConversationAnchor): string | null {
|
|
37
|
+
return anchor.kind === "none" || !anchor.lineageId?.trim() ? null : `${anchor.kind}#${anchor.lineageId.trim()}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function conversationThreadKind(anchor: ConversationAnchor): string {
|
|
41
|
+
return anchor.kind === "reference" ? REFERENCE_CONVERSATION_KIND : anchor.kind === "item" ? STORY_CONVERSATION_KIND : GENERAL_CONVERSATION_KIND;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The thread id follows the anchor, so two editors starting the same conversation at once collide on create instead of splitting it. */
|
|
45
|
+
export function conversationThreadId(anchor: ConversationAnchor): string {
|
|
46
|
+
const key = conversationAnchorKey(anchor);
|
|
47
|
+
if (!key) throw new Error("A conversation without an anchor needs its own thread id.");
|
|
48
|
+
return `message-thread-conversation-${slug(key)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function conversationMessageId(threadId: string, sequenceNumber: number): string {
|
|
52
|
+
return `message-conversation-${slug(threadId)}-${String(sequenceNumber).padStart(4, "0")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One more than the highest sequence number in use. */
|
|
56
|
+
export function nextConversationSequence(existing: ReadonlyArray<{ sequenceNumber?: number | null }>): number {
|
|
57
|
+
return Math.max(0, ...existing.map((message) => Number(message.sequenceNumber || 0))) + 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A create that lost a race for its sequence number (the id derives from the number, so the second writer collides). */
|
|
61
|
+
export function isSequenceConflict(error: unknown): boolean {
|
|
62
|
+
const text = error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error ?? "");
|
|
63
|
+
return /conditional|already exists|duplicate|ConditionalCheckFailed/i.test(text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** "ryan@example.test" is shown as "ryan"; a name stays as it is. */
|
|
67
|
+
export function conversationAuthorName(label: string | null | undefined): string {
|
|
68
|
+
const text = String(label ?? "").trim();
|
|
69
|
+
if (!text) return "Someone";
|
|
70
|
+
const at = text.indexOf("@");
|
|
71
|
+
return at > 0 ? text.slice(0, at) : text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Top-level Messages in order, each with its replies. A reply to a reply stays flat under the same root and says who it answers.
|
|
76
|
+
* A reply whose parent is missing is shown as a top-level Message rather than lost.
|
|
77
|
+
*/
|
|
78
|
+
export function buildConversationTree(messages: ReadonlyArray<ConversationMessage>): ConversationRoot[] {
|
|
79
|
+
const sorted = [...messages].sort((a, b) => a.sequenceNumber - b.sequenceNumber || a.createdAt.localeCompare(b.createdAt));
|
|
80
|
+
const byId = new Map(sorted.map((message) => [message.id, message]));
|
|
81
|
+
const rootOf = (message: ConversationMessage): string => {
|
|
82
|
+
let current = message;
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
while (current.parentMessageId && byId.has(current.parentMessageId) && !seen.has(current.id)) {
|
|
85
|
+
seen.add(current.id);
|
|
86
|
+
current = byId.get(current.parentMessageId)!;
|
|
87
|
+
}
|
|
88
|
+
return current.id;
|
|
89
|
+
};
|
|
90
|
+
const roots = new Map<string, ConversationRoot>();
|
|
91
|
+
for (const message of sorted) {
|
|
92
|
+
if (rootOf(message) === message.id) roots.set(message.id, { message, replies: [] });
|
|
93
|
+
}
|
|
94
|
+
for (const message of sorted) {
|
|
95
|
+
const root = roots.get(rootOf(message));
|
|
96
|
+
if (!root || root.message.id === message.id) continue;
|
|
97
|
+
const parent = message.parentMessageId ? byId.get(message.parentMessageId) : undefined;
|
|
98
|
+
root.replies.push({ message, answers: parent && parent.id !== root.message.id ? conversationAuthorName(parent.authorLabel) : null });
|
|
99
|
+
}
|
|
100
|
+
return [...roots.values()];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Short relative time: "now", "5m", "3h", "2d". */
|
|
104
|
+
export function conversationAge(createdAt: string, now: number): string {
|
|
105
|
+
const time = Date.parse(createdAt);
|
|
106
|
+
if (!Number.isFinite(time)) return "";
|
|
107
|
+
const minutes = Math.max(0, Math.round((now - time) / 60000));
|
|
108
|
+
if (minutes < 1) return "now";
|
|
109
|
+
if (minutes < 60) return `${minutes}m`;
|
|
110
|
+
const hours = Math.round(minutes / 60);
|
|
111
|
+
if (hours < 24) return `${hours}h`;
|
|
112
|
+
return `${Math.round(hours / 24)}d`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function messageCountLabel(count: number): string {
|
|
116
|
+
return `${count} ${count === 1 ? "message" : "messages"}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Creates a Message with the next sequence number. The id derives from the number, so when another writer took it the create is
|
|
121
|
+
* refused and this tries the next number, up to `attempts` times. `list` returns the Messages already in the thread.
|
|
122
|
+
*/
|
|
123
|
+
export async function createWithSequenceRetry<Message>(input: {
|
|
124
|
+
list: () => Promise<ReadonlyArray<{ sequenceNumber?: number | null }>>;
|
|
125
|
+
create: (sequenceNumber: number) => Promise<Message>;
|
|
126
|
+
attempts?: number;
|
|
127
|
+
}): Promise<{ created: Message; sequenceNumber: number }> {
|
|
128
|
+
const attempts = input.attempts ?? 5;
|
|
129
|
+
let sequenceNumber = nextConversationSequence(await input.list());
|
|
130
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
131
|
+
try {
|
|
132
|
+
return { created: await input.create(sequenceNumber), sequenceNumber };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (!isSequenceConflict(error) || attempt >= attempts) throw error;
|
|
135
|
+
sequenceNumber = nextConversationSequence(await input.list());
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Finds the conversation's thread, else creates it; a create that loses a race for the (anchor-derived) id uses the winner's thread. */
|
|
141
|
+
export async function findOrCreateThread<Thread>(input: {
|
|
142
|
+
find: () => Promise<Thread | null>;
|
|
143
|
+
create: () => Promise<Thread>;
|
|
144
|
+
}): Promise<Thread> {
|
|
145
|
+
const existing = await input.find();
|
|
146
|
+
if (existing) return existing;
|
|
147
|
+
try {
|
|
148
|
+
return await input.create();
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const raced = isSequenceConflict(error) ? await input.find() : null;
|
|
151
|
+
if (raced) return raced;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
package/package.json
CHANGED
|
@@ -80,6 +80,9 @@
|
|
|
80
80
|
"./components/automation-run-now": "./components/automation-run-now.tsx",
|
|
81
81
|
"./components/automation-view": "./components/automation-view.tsx",
|
|
82
82
|
"./components/blog-page-background": "./components/blog-page-background.tsx",
|
|
83
|
+
"./components/conversation-context": "./components/conversation-context.tsx",
|
|
84
|
+
"./components/conversation-provider": "./components/conversation-provider.tsx",
|
|
85
|
+
"./components/conversation-thread": "./components/conversation-thread.tsx",
|
|
83
86
|
"./components/curation-delegation-client": "./components/curation-delegation-client.ts",
|
|
84
87
|
"./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
|
|
85
88
|
"./components/editorial-checks-admin-panel": "./components/editorial-checks-admin-panel.tsx",
|
|
@@ -146,6 +149,7 @@
|
|
|
146
149
|
"./components/reference-add-entry": "./components/reference-add-entry.tsx",
|
|
147
150
|
"./components/reference-capture-client": "./components/reference-capture-client.ts",
|
|
148
151
|
"./components/reference-capture-panel": "./components/reference-capture-panel.tsx",
|
|
152
|
+
"./components/reference-conversation": "./components/reference-conversation.tsx",
|
|
149
153
|
"./components/reference-curation-controls": "./components/reference-curation-controls.tsx",
|
|
150
154
|
"./components/reference-cyclotron-priming": "./components/reference-cyclotron-priming.tsx",
|
|
151
155
|
"./components/reference-cyclotron-status": "./components/reference-cyclotron-status.tsx",
|
|
@@ -189,6 +193,7 @@
|
|
|
189
193
|
"./components/use-blog-header-obstacles": "./components/use-blog-header-obstacles.ts",
|
|
190
194
|
"./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
|
|
191
195
|
"./components/use-console-visibility": "./components/use-console-visibility.ts",
|
|
196
|
+
"./components/use-conversation": "./components/use-conversation.ts",
|
|
192
197
|
"./components/use-editorial-checks": "./components/use-editorial-checks.ts",
|
|
193
198
|
"./components/use-media-query": "./components/use-media-query.ts",
|
|
194
199
|
"./components/use-near-viewport": "./components/use-near-viewport.ts",
|
|
@@ -229,6 +234,7 @@
|
|
|
229
234
|
"./lib/content-repository": "./lib/content-repository.ts",
|
|
230
235
|
"./lib/content-source-context": "./lib/content-source-context.ts",
|
|
231
236
|
"./lib/content-types": "./lib/content-types.ts",
|
|
237
|
+
"./lib/conversation": "./lib/conversation.ts",
|
|
232
238
|
"./lib/curation-client": "./lib/curation-client.ts",
|
|
233
239
|
"./lib/curation-delegation-helpers": "./lib/curation-delegation-helpers.ts",
|
|
234
240
|
"./lib/curation-routes": "./lib/curation-routes.ts",
|
|
@@ -504,7 +510,7 @@
|
|
|
504
510
|
"optional": true
|
|
505
511
|
}
|
|
506
512
|
},
|
|
507
|
-
"version": "5.
|
|
513
|
+
"version": "5.57.0",
|
|
508
514
|
"dependencies": {
|
|
509
515
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
510
516
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED