@anthusai/papyrus 5.43.0-next.42 → 5.43.0-next.45
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-thread.tsx +19 -5
- package/components/conversations-memory-source.ts +46 -0
- package/components/conversations-network-source.ts +40 -0
- package/components/conversations-page.tsx +14 -0
- package/components/conversations-source.ts +24 -0
- package/components/conversations-view.tsx +319 -0
- package/components/news-desk-taxonomy-client.ts +73 -2
- package/components/reference-feed.tsx +53 -26
- package/components/story-reporting-trail-client.ts +4 -3
- package/components/story-reporting-trail.tsx +8 -0
- package/components/topic-steering-workspace.tsx +2 -620
- package/lib/conversation-feed.ts +96 -0
- package/lib/story-reporting-trail.ts +28 -2
- package/package.json +7 -1
- package/routes.manifest.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Loader2Icon, MessageSquarePlusIcon, ReplyIcon, TriangleAlertIcon, CornerDownRightIcon } from "lucide-react";
|
|
3
|
+
import { Loader2Icon, MessageSquarePlusIcon, MessagesSquareIcon, ReplyIcon, TriangleAlertIcon, CornerDownRightIcon } from "lucide-react";
|
|
4
4
|
import { useMemo, useState } from "react";
|
|
5
|
+
import { latestMessages } from "../lib/conversation-feed";
|
|
5
6
|
import { buildConversationTree, conversationAge, conversationAuthorName, type ConversationMessage } from "../lib/conversation";
|
|
6
7
|
import { cn } from "../lib/utils";
|
|
7
8
|
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
@@ -17,7 +18,7 @@ const PILL_QUIET = cn(PILL, "text-muted-foreground hover:bg-[var(--feed-control)
|
|
|
17
18
|
* 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
|
|
18
19
|
* back (unless you have typed something new) with the reason, and nothing you wrote is lost.
|
|
19
20
|
*/
|
|
20
|
-
function Composer({ autoFocus = false, buttonLabel, disabledReason, icon, label, onCancel, onSubmit, placeholder }: {
|
|
21
|
+
export function Composer({ autoFocus = false, buttonLabel, disabledReason, icon, label, onCancel, onSubmit, placeholder }: {
|
|
21
22
|
autoFocus?: boolean;
|
|
22
23
|
buttonLabel: string;
|
|
23
24
|
disabledReason?: string | null;
|
|
@@ -115,24 +116,31 @@ function MessageBody({ answers, message, now, onReply, replying, tone }: {
|
|
|
115
116
|
* The Messages of one conversation, replies nested one level under their top-level Message, and a box to add another. It knows
|
|
116
117
|
* nothing about where the Messages come from: the caller loads them and saves a post.
|
|
117
118
|
*/
|
|
118
|
-
export function ConversationThread({ disabledReason = null, error = null, heading = "Messages", messages, now, onPost, onRetry, state = "ready" }: {
|
|
119
|
+
export function ConversationThread({ disabledReason = null, error = null, heading = "Messages", hideHeading = false, latest, messages, now, onPost, onRetry, state = "ready" }: {
|
|
119
120
|
disabledReason?: string | null;
|
|
120
121
|
error?: string | null;
|
|
121
122
|
heading?: string;
|
|
123
|
+
/** Keep the heading for screen readers only (the Messages page labels the card itself). */
|
|
124
|
+
hideHeading?: boolean;
|
|
125
|
+
/** Show only the newest this-many top-level messages (with their replies), and say how many are held back. */
|
|
126
|
+
latest?: number;
|
|
122
127
|
messages: ReadonlyArray<ConversationMessage>;
|
|
123
128
|
now: number;
|
|
124
129
|
onPost: (body: string, parentMessageId: string | null) => Promise<void>;
|
|
125
130
|
onRetry?: () => void;
|
|
126
131
|
state?: ConversationState;
|
|
127
132
|
}) {
|
|
128
|
-
const
|
|
133
|
+
const fullTree = useMemo(() => buildConversationTree(messages), [messages]);
|
|
134
|
+
const [showAll, setShowAll] = useState(false);
|
|
135
|
+
const { shown: tree, hidden: hiddenRoots } = latestMessages(fullTree, latest ?? Number.MAX_SAFE_INTEGER, showAll);
|
|
136
|
+
const hiddenCount = hiddenRoots ? messages.length - tree.reduce((sum, root) => sum + 1 + root.replies.length, 0) : 0;
|
|
129
137
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
|
130
138
|
const authorOf = (id: string) => conversationAuthorName(messages.find((message) => message.id === id)?.authorLabel);
|
|
131
139
|
const toggleReply = (id: string) => setReplyingTo((current) => (current === id ? null : id));
|
|
132
140
|
|
|
133
141
|
return (
|
|
134
142
|
<section aria-label={heading} className="space-y-3" data-conversation-thread>
|
|
135
|
-
<h3 className={cn(LABEL, "text-muted-foreground")}>{heading}</h3>
|
|
143
|
+
<h3 className={cn(LABEL, "text-muted-foreground", hideHeading && "sr-only")}>{heading}</h3>
|
|
136
144
|
{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}
|
|
137
145
|
{state === "error" ? (
|
|
138
146
|
<p className="flex flex-wrap items-center gap-2 text-sm text-destructive" role="alert">
|
|
@@ -142,6 +150,12 @@ export function ConversationThread({ disabledReason = null, error = null, headin
|
|
|
142
150
|
</p>
|
|
143
151
|
) : null}
|
|
144
152
|
{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}
|
|
153
|
+
{hiddenCount > 0 ? (
|
|
154
|
+
<button className={cn(PILL_QUIET, "w-full bg-[var(--feed-control)]/45")} data-conversation-show-all onClick={() => setShowAll(true)} type="button">
|
|
155
|
+
<MessagesSquareIcon aria-hidden="true" className="size-4" />
|
|
156
|
+
Show all {messages.length} messages
|
|
157
|
+
</button>
|
|
158
|
+
) : null}
|
|
145
159
|
<ul className="space-y-3">
|
|
146
160
|
{tree.map((root) => (
|
|
147
161
|
<li className="space-y-2" key={root.message.id}>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { MessageThreadRecord } from "../lib/category-repository";
|
|
2
|
+
import type { ConversationMessage } from "../lib/conversation";
|
|
3
|
+
import type { ConversationReferenceView, ConversationStoryView, ConversationsSource } from "./conversations-source";
|
|
4
|
+
|
|
5
|
+
export type MemoryConversation = { thread: MessageThreadRecord; messages: ConversationMessage[] };
|
|
6
|
+
|
|
7
|
+
/** A source that keeps everything in memory: the demo newsroom (starting empty) and the tests (starting with fixtures). */
|
|
8
|
+
export function createMemoryConversationsSource(authorLabel: string, seed: {
|
|
9
|
+
conversations?: MemoryConversation[];
|
|
10
|
+
references?: Record<string, ConversationReferenceView>;
|
|
11
|
+
stories?: Record<string, ConversationStoryView>;
|
|
12
|
+
pageSize?: number;
|
|
13
|
+
} = {}): ConversationsSource & { conversations: MemoryConversation[] } {
|
|
14
|
+
const conversations = [...(seed.conversations ?? [])];
|
|
15
|
+
const pageSize = seed.pageSize ?? 20;
|
|
16
|
+
let started = 0;
|
|
17
|
+
return {
|
|
18
|
+
authorLabel,
|
|
19
|
+
conversations,
|
|
20
|
+
loadThreads: async (threadKind, nextToken) => {
|
|
21
|
+
const all = conversations.map((entry) => entry.thread).filter((thread) => thread.threadKind === threadKind).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
22
|
+
const start = nextToken ? Number(nextToken) : 0;
|
|
23
|
+
return { items: all.slice(start, start + pageSize), nextToken: start + pageSize < all.length ? String(start + pageSize) : null };
|
|
24
|
+
},
|
|
25
|
+
loadMessages: async (threadId) => [...(conversations.find((entry) => entry.thread.id === threadId)?.messages ?? [])],
|
|
26
|
+
post: async ({ thread, body, parentMessageId }) => {
|
|
27
|
+
const entry = conversations.find((candidate) => candidate.thread.id === thread.id);
|
|
28
|
+
if (!entry) throw new Error("That conversation is not here.");
|
|
29
|
+
const sequenceNumber = Math.max(0, ...entry.messages.map((message) => message.sequenceNumber)) + 1;
|
|
30
|
+
const message: ConversationMessage = { id: `${thread.id}-m${sequenceNumber}`, parentMessageId, authorLabel, content: body.trim(), createdAt: new Date().toISOString(), sequenceNumber };
|
|
31
|
+
entry.messages.push(message);
|
|
32
|
+
entry.thread = { ...entry.thread, messageCount: entry.messages.length, lastMessageAt: message.createdAt, updatedAt: message.createdAt };
|
|
33
|
+
return message;
|
|
34
|
+
},
|
|
35
|
+
start: async (body) => {
|
|
36
|
+
started += 1;
|
|
37
|
+
const now = new Date().toISOString();
|
|
38
|
+
const thread: MessageThreadRecord = { id: `message-thread-conversation-new-${started}`, threadKind: "conversation", status: "active", title: body.trim().split("\n")[0].slice(0, 80), createdByLabel: authorLabel, messageCount: 1, lastMessageAt: now, createdAt: now, updatedAt: now };
|
|
39
|
+
const message: ConversationMessage = { id: `${thread.id}-m1`, parentMessageId: null, authorLabel, content: body.trim(), createdAt: now, sequenceNumber: 1 };
|
|
40
|
+
conversations.push({ thread, messages: [message] });
|
|
41
|
+
return { thread, message };
|
|
42
|
+
},
|
|
43
|
+
loadReferences: async (ids) => new Map(ids.filter((id) => seed.references?.[id]).map((id) => [id, seed.references![id]])),
|
|
44
|
+
loadStories: async (ids) => new Map(ids.filter((id) => seed.stories?.[id]).map((id) => [id, seed.stories![id]])),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { referencePreviewsByLineage } from "../lib/reference-preview";
|
|
2
|
+
import {
|
|
3
|
+
appendConversationMessage,
|
|
4
|
+
loadConversationThreadMessages,
|
|
5
|
+
loadConversationThreadPage,
|
|
6
|
+
loadReferenceAttachmentsForLineageId,
|
|
7
|
+
loadReferenceRecordByLineageId,
|
|
8
|
+
loadStoryHeaders,
|
|
9
|
+
startUnattachedConversation,
|
|
10
|
+
} from "./news-desk-taxonomy-client";
|
|
11
|
+
import type { ConversationReferenceView, ConversationsSource } from "./conversations-source";
|
|
12
|
+
|
|
13
|
+
const PAGE_SIZE = 20;
|
|
14
|
+
|
|
15
|
+
/** The newsroom's own source: the MessageThread and Message models. A Reference that cannot be read is left out, and its card shows only the messages. */
|
|
16
|
+
export function createNetworkConversationsSource(authorLabel: string): ConversationsSource {
|
|
17
|
+
return {
|
|
18
|
+
authorLabel,
|
|
19
|
+
loadThreads: (threadKind, nextToken) => loadConversationThreadPage(threadKind, nextToken, PAGE_SIZE),
|
|
20
|
+
loadMessages: loadConversationThreadMessages,
|
|
21
|
+
post: async ({ thread, body, parentMessageId }) => {
|
|
22
|
+
const anchor = thread.primaryAnchorLineageId && (thread.primaryAnchorKind === "reference" || thread.primaryAnchorKind === "item")
|
|
23
|
+
? { kind: thread.primaryAnchorKind, lineageId: thread.primaryAnchorLineageId, title: thread.title } as const
|
|
24
|
+
: { kind: "none" } as const;
|
|
25
|
+
return (await appendConversationMessage({ anchor, body, parentMessageId, thread })).message;
|
|
26
|
+
},
|
|
27
|
+
start: startUnattachedConversation,
|
|
28
|
+
loadReferences: async (lineageIds) => {
|
|
29
|
+
const views = new Map<string, ConversationReferenceView>();
|
|
30
|
+
await Promise.all([...new Set(lineageIds)].map(async (lineageId) => {
|
|
31
|
+
try {
|
|
32
|
+
const [reference, attachments] = await Promise.all([loadReferenceRecordByLineageId(lineageId), loadReferenceAttachmentsForLineageId(lineageId).catch(() => [])]);
|
|
33
|
+
if (reference) views.set(lineageId, { reference, preview: referencePreviewsByLineage(attachments).get(lineageId) });
|
|
34
|
+
} catch { /* left out */ }
|
|
35
|
+
}));
|
|
36
|
+
return views;
|
|
37
|
+
},
|
|
38
|
+
loadStories: loadStoryHeaders,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useMemo } from "react";
|
|
5
|
+
import { ConversationsView } from "./conversations-view";
|
|
6
|
+
import { createMemoryConversationsSource } from "./conversations-memory-source";
|
|
7
|
+
import { createNetworkConversationsSource } from "./conversations-network-source";
|
|
8
|
+
|
|
9
|
+
/** The Messages page: every conversation, from the backend (or kept in memory in the demo). */
|
|
10
|
+
export function ConversationsPage({ authorLabel, demo }: { authorLabel: string; demo: boolean }) {
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const source = useMemo(() => (demo ? createMemoryConversationsSource(authorLabel) : createNetworkConversationsSource(authorLabel)), [authorLabel, demo]);
|
|
13
|
+
return <ConversationsView demo={demo} onNavigate={(href) => router.push(href)} source={source} />;
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MessageThreadRecord, ReferenceRecord } from "../lib/category-repository";
|
|
2
|
+
import type { ConversationMessage } from "../lib/conversation";
|
|
3
|
+
import type { PagedThreads } from "../lib/conversation-feed";
|
|
4
|
+
import type { ReferencePreview } from "../lib/reference-preview";
|
|
5
|
+
|
|
6
|
+
/** A Reference as the Messages page shows it: the record and the page's preview picture, when it has one. */
|
|
7
|
+
export type ConversationReferenceView = { reference: ReferenceRecord; preview: ReferencePreview | undefined };
|
|
8
|
+
export type ConversationStoryView = { title: string; stage: string | null };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where the Messages page reads and writes. The newsroom's is in conversations-network-source.ts; the tests give it an in-memory one.
|
|
12
|
+
* Every read is a page or a batch, so a page of conversations never costs one request per card per render.
|
|
13
|
+
*/
|
|
14
|
+
export type ConversationsSource = {
|
|
15
|
+
authorLabel: string;
|
|
16
|
+
/** One page of the conversation threads of one kind, newest activity first. */
|
|
17
|
+
loadThreads: (threadKind: string, nextToken: string | null) => Promise<PagedThreads<MessageThreadRecord>>;
|
|
18
|
+
loadMessages: (threadId: string) => Promise<ConversationMessage[]>;
|
|
19
|
+
post: (input: { thread: MessageThreadRecord; body: string; parentMessageId: string | null }) => Promise<ConversationMessage>;
|
|
20
|
+
/** A new conversation about nothing but itself. */
|
|
21
|
+
start: (body: string) => Promise<{ thread: MessageThreadRecord; message: ConversationMessage }>;
|
|
22
|
+
loadReferences: (lineageIds: string[]) => Promise<Map<string, ConversationReferenceView>>;
|
|
23
|
+
loadStories: (lineageIds: string[]) => Promise<Map<string, ConversationStoryView>>;
|
|
24
|
+
};
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { BookmarkIcon, ExternalLinkIcon, InboxIcon, Loader2Icon, MessageCircleIcon, MessageSquareTextIcon, MessagesSquareIcon, NewspaperIcon, TriangleAlertIcon } from "lucide-react";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import type { MessageThreadRecord } from "../lib/category-repository";
|
|
6
|
+
import { conversationAge, messageCountLabel, type ConversationMessage } from "../lib/conversation";
|
|
7
|
+
import { CONVERSATION_SECTIONS, SECTION_EMPTY_COPY, createMergedThreadStream, sectionOfThreadKind, threadKindsForSection, type ConversationSection } from "../lib/conversation-feed";
|
|
8
|
+
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
9
|
+
import { cn } from "../lib/utils";
|
|
10
|
+
import { Composer, ConversationThread } from "./conversation-thread";
|
|
11
|
+
import type { ConversationReferenceView, ConversationStoryView, ConversationsSource } from "./conversations-source";
|
|
12
|
+
import { FEED_GRID, FEED_SURFACE_PROPS, PILL, ReferenceCardHeader } from "./reference-feed";
|
|
13
|
+
|
|
14
|
+
const PAGE_SIZE = 12;
|
|
15
|
+
/** A card shows this many of the newest messages; the rest are one button away. */
|
|
16
|
+
const LATEST_MESSAGES = 3;
|
|
17
|
+
const STALE_AFTER_MS = 60_000;
|
|
18
|
+
const CONSOLE_SELECT_THREAD_EVENT = "papyrus:console-select-thread";
|
|
19
|
+
const LABEL = "font-[family-name:var(--font-sans)] text-[10px] font-extrabold tracking-[0.07em] uppercase";
|
|
20
|
+
|
|
21
|
+
const SECTION_ICONS = { all: InboxIcon, references: BookmarkIcon, stories: NewspaperIcon, chats: MessageCircleIcon, other: MessagesSquareIcon } as const;
|
|
22
|
+
|
|
23
|
+
type KindLabel = { icon: typeof BookmarkIcon; text: string };
|
|
24
|
+
function kindLabel(kind: string): KindLabel {
|
|
25
|
+
switch (sectionOfThreadKind(kind)) {
|
|
26
|
+
case "references": return { icon: BookmarkIcon, text: "Reference" };
|
|
27
|
+
case "stories": return { icon: NewspaperIcon, text: "Story" };
|
|
28
|
+
case "chats": return { icon: MessageCircleIcon, text: "Chat" };
|
|
29
|
+
default: return kind === "edition_forum" || kind === "section_forum"
|
|
30
|
+
? { icon: MessagesSquareIcon, text: kind === "edition_forum" ? "Edition forum" : "Section forum" }
|
|
31
|
+
: { icon: MessageSquareTextIcon, text: "Message" };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One conversation's Messages: loaded once, shown at once when posted, taken back when the save fails. */
|
|
36
|
+
function useThreadMessages(source: ConversationsSource, thread: MessageThreadRecord, seed: ConversationMessage[] | undefined, reloadKey: number) {
|
|
37
|
+
const [messages, setMessages] = useState<ConversationMessage[]>(seed ?? []);
|
|
38
|
+
const [state, setState] = useState<"loading" | "ready" | "error">(seed ? "ready" : "loading");
|
|
39
|
+
const [error, setError] = useState<string | null>(null);
|
|
40
|
+
const [attempt, setAttempt] = useState(0);
|
|
41
|
+
const counter = useRef(0);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let active = true;
|
|
44
|
+
source.loadMessages(thread.id).then((loaded) => {
|
|
45
|
+
if (!active) return;
|
|
46
|
+
setMessages((current) => [...loaded, ...current.filter((message) => message.pending)]);
|
|
47
|
+
setState("ready");
|
|
48
|
+
setError(null);
|
|
49
|
+
}).catch((failure) => {
|
|
50
|
+
if (!active) return;
|
|
51
|
+
setError(failure instanceof Error ? failure.message : "The conversation could not be loaded.");
|
|
52
|
+
setState("error");
|
|
53
|
+
});
|
|
54
|
+
return () => { active = false; };
|
|
55
|
+
}, [source, thread.id, reloadKey, attempt]);
|
|
56
|
+
const post = useCallback(async (body: string, parentMessageId: string | null) => {
|
|
57
|
+
counter.current += 1;
|
|
58
|
+
const pendingId = `pending-${counter.current}`;
|
|
59
|
+
const pending: ConversationMessage = { id: pendingId, parentMessageId, authorLabel: source.authorLabel, content: body.trim(), createdAt: new Date().toISOString(), sequenceNumber: Number.MAX_SAFE_INTEGER - 1000 + counter.current, pending: true };
|
|
60
|
+
setMessages((current) => [...current, pending]);
|
|
61
|
+
try {
|
|
62
|
+
const saved = await source.post({ thread, body, parentMessageId });
|
|
63
|
+
setMessages((current) => current.map((message) => (message.id === pendingId ? saved : message)));
|
|
64
|
+
} catch (failure) {
|
|
65
|
+
setMessages((current) => current.filter((message) => message.id !== pendingId));
|
|
66
|
+
throw failure instanceof Error ? failure : new Error("Your message could not be saved.");
|
|
67
|
+
}
|
|
68
|
+
}, [source, thread]);
|
|
69
|
+
return { messages, state, error, post, retry: () => setAttempt((current) => current + 1) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function CardCaption({ kind, now, updatedAt }: { kind: string; now: number; updatedAt: string }) {
|
|
73
|
+
const { icon: Icon, text } = kindLabel(kind);
|
|
74
|
+
const age = conversationAge(updatedAt, now);
|
|
75
|
+
return (
|
|
76
|
+
<p className={cn(LABEL, "flex items-center justify-between gap-2 text-muted-foreground")} data-conversation-caption>
|
|
77
|
+
<span className="inline-flex items-center gap-1.5"><Icon aria-hidden="true" className="size-3.5" />{text}</span>
|
|
78
|
+
{age ? <span className="font-medium normal-case tracking-normal" data-conversation-activity>Active {age === "now" ? "now" : `${age} ago`}</span> : null}
|
|
79
|
+
</p>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The Reference a conversation is about, as an attachment under its messages: the standard header, no decision controls. */
|
|
84
|
+
function ReferenceAttachment({ href, now, onNavigate, thread, view }: { href: string; now: number; onNavigate?: (href: string) => void; thread: MessageThreadRecord; view: ConversationReferenceView | undefined }) {
|
|
85
|
+
const open = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
86
|
+
if (!onNavigate || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
onNavigate(href);
|
|
89
|
+
};
|
|
90
|
+
return (
|
|
91
|
+
<a
|
|
92
|
+
aria-label={`Open the Reference: ${view?.reference.title ?? thread.title}`}
|
|
93
|
+
className="flex flex-col overflow-hidden rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-footer)] text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
|
94
|
+
data-conversation-attachment="reference"
|
|
95
|
+
href={href}
|
|
96
|
+
onClick={open}
|
|
97
|
+
>
|
|
98
|
+
{view ? <ReferenceCardHeader now={now} preview={view.preview} reference={view.reference} /> : (
|
|
99
|
+
<span className="flex items-center gap-2 p-4 text-sm text-muted-foreground"><BookmarkIcon aria-hidden="true" className="size-4" />{thread.title}<ExternalLinkIcon aria-hidden="true" className="size-4" /></span>
|
|
100
|
+
)}
|
|
101
|
+
</a>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ConversationCard({ demo, now, onNavigate, reloadKey, references, seed, source, stories, thread }: {
|
|
106
|
+
demo: boolean;
|
|
107
|
+
now: number;
|
|
108
|
+
onNavigate?: (href: string) => void;
|
|
109
|
+
reloadKey: number;
|
|
110
|
+
references: ReadonlyMap<string, ConversationReferenceView>;
|
|
111
|
+
seed: ConversationMessage[] | undefined;
|
|
112
|
+
source: ConversationsSource;
|
|
113
|
+
stories: ReadonlyMap<string, ConversationStoryView>;
|
|
114
|
+
thread: MessageThreadRecord;
|
|
115
|
+
}) {
|
|
116
|
+
const section = sectionOfThreadKind(thread.threadKind);
|
|
117
|
+
const lineageId = thread.primaryAnchorLineageId ?? "";
|
|
118
|
+
// Forum threads and chats keep their own detail views; every other conversation is read and answered right here.
|
|
119
|
+
const inline = section === "references" || section === "stories" || thread.threadKind === "conversation";
|
|
120
|
+
const messages = useThreadMessages(source, thread, seed, reloadKey);
|
|
121
|
+
const story = stories.get(lineageId);
|
|
122
|
+
const frame = "flex flex-col gap-3 rounded-[20px] bg-[var(--feed-card)] p-4";
|
|
123
|
+
|
|
124
|
+
if (!inline) {
|
|
125
|
+
const isChat = section === "chats";
|
|
126
|
+
const href = getNewsroomNavHref(`/newsroom/forum/${encodeURIComponent(thread.id)}`, demo);
|
|
127
|
+
return (
|
|
128
|
+
<article className={frame} data-conversation-card={thread.id} data-conversation-kind={thread.threadKind}>
|
|
129
|
+
<CardCaption kind={thread.threadKind} now={now} updatedAt={thread.updatedAt} />
|
|
130
|
+
<h3 className="text-base font-semibold leading-snug" data-conversation-title>{thread.title}</h3>
|
|
131
|
+
<p className="text-sm text-muted-foreground">{messageCountLabel(thread.messageCount ?? 0)}</p>
|
|
132
|
+
{isChat ? (
|
|
133
|
+
<button className={cn(PILL, "self-start bg-[var(--feed-control)] text-foreground")} data-conversation-open onClick={() => window.dispatchEvent(new CustomEvent(CONSOLE_SELECT_THREAD_EVENT, { detail: { threadId: thread.id } }))} type="button">
|
|
134
|
+
<MessageCircleIcon aria-hidden="true" className="size-4" />Open chat
|
|
135
|
+
</button>
|
|
136
|
+
) : (
|
|
137
|
+
<a className={cn(PILL, "self-start bg-[var(--feed-control)] text-foreground")} data-conversation-open href={href} onClick={(event) => { if (onNavigate && !event.metaKey && !event.ctrlKey && event.button === 0) { event.preventDefault(); onNavigate(href); } }}>
|
|
138
|
+
<MessagesSquareIcon aria-hidden="true" className="size-4" />Open thread
|
|
139
|
+
</a>
|
|
140
|
+
)}
|
|
141
|
+
</article>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<article className={frame} data-conversation-card={thread.id} data-conversation-kind={thread.threadKind}>
|
|
147
|
+
<CardCaption kind={thread.threadKind} now={now} updatedAt={thread.updatedAt} />
|
|
148
|
+
{section === "stories" ? (
|
|
149
|
+
<div className="space-y-1 rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-footer)] p-3" data-conversation-story>
|
|
150
|
+
<p className="text-base font-semibold leading-snug">{story?.title ?? thread.title}</p>
|
|
151
|
+
<p className="flex flex-wrap items-center gap-x-3 text-sm text-muted-foreground">
|
|
152
|
+
{story?.stage ? <span data-conversation-stage>Stage: {story.stage}</span> : null}
|
|
153
|
+
<a className="inline-flex items-center gap-1 underline-offset-2 hover:underline" href={getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(lineageId)}`, demo)}>
|
|
154
|
+
<ExternalLinkIcon aria-hidden="true" className="size-3.5" />Open the Story
|
|
155
|
+
</a>
|
|
156
|
+
</p>
|
|
157
|
+
</div>
|
|
158
|
+
) : null}
|
|
159
|
+
<ConversationThread
|
|
160
|
+
error={messages.error}
|
|
161
|
+
heading={`Messages in ${thread.title}`}
|
|
162
|
+
hideHeading
|
|
163
|
+
latest={LATEST_MESSAGES}
|
|
164
|
+
messages={messages.messages}
|
|
165
|
+
now={now}
|
|
166
|
+
onPost={messages.post}
|
|
167
|
+
onRetry={messages.retry}
|
|
168
|
+
state={messages.state}
|
|
169
|
+
/>
|
|
170
|
+
{section === "references" && lineageId ? (
|
|
171
|
+
<ReferenceAttachment href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo)} now={now} onNavigate={onNavigate} thread={thread} view={references.get(lineageId)} />
|
|
172
|
+
) : null}
|
|
173
|
+
</article>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Every conversation, newest activity first, as cards in a grid. Messages lead each card; the Reference or Story it is about is an
|
|
179
|
+
* attachment under them. Sections are buttons. Reads page by page from the thread indexes: nothing is loaded that is not shown.
|
|
180
|
+
*/
|
|
181
|
+
export function ConversationsView({ demo = false, onNavigate, source }: {
|
|
182
|
+
demo?: boolean;
|
|
183
|
+
/** Open a link inside the app without a page load. Without it links are ordinary links. */
|
|
184
|
+
onNavigate?: (href: string) => void;
|
|
185
|
+
source: ConversationsSource;
|
|
186
|
+
}) {
|
|
187
|
+
const [section, setSection] = useState<ConversationSection>("all");
|
|
188
|
+
const [threads, setThreads] = useState<MessageThreadRecord[]>([]);
|
|
189
|
+
const [seeds, setSeeds] = useState<Record<string, ConversationMessage[]>>({});
|
|
190
|
+
const [loading, setLoading] = useState(true);
|
|
191
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
192
|
+
const [error, setError] = useState<string | null>(null);
|
|
193
|
+
const [hasMore, setHasMore] = useState(false);
|
|
194
|
+
const [references, setReferences] = useState<ReadonlyMap<string, ConversationReferenceView>>(() => new Map());
|
|
195
|
+
const [stories, setStories] = useState<ReadonlyMap<string, ConversationStoryView>>(() => new Map());
|
|
196
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
197
|
+
const [now, setNow] = useState(() => Date.now());
|
|
198
|
+
const loadedAt = useRef(Date.now());
|
|
199
|
+
const stream = useRef<ReturnType<typeof createMergedThreadStream<MessageThreadRecord>> | null>(null);
|
|
200
|
+
const generation = useRef(0);
|
|
201
|
+
|
|
202
|
+
const enrich = useCallback((page: MessageThreadRecord[]) => {
|
|
203
|
+
const referenceIds = page.filter((thread) => thread.threadKind === "reference_conversation" && thread.primaryAnchorLineageId).map((thread) => thread.primaryAnchorLineageId!);
|
|
204
|
+
const storyIds = page.filter((thread) => thread.threadKind === "story_conversation" && thread.primaryAnchorLineageId).map((thread) => thread.primaryAnchorLineageId!);
|
|
205
|
+
if (referenceIds.length) void source.loadReferences(referenceIds).then((loaded) => setReferences((current) => new Map([...current, ...loaded]))).catch(() => undefined);
|
|
206
|
+
if (storyIds.length) void source.loadStories(storyIds).then((loaded) => setStories((current) => new Map([...current, ...loaded]))).catch(() => undefined);
|
|
207
|
+
}, [source]);
|
|
208
|
+
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const mine = ++generation.current;
|
|
211
|
+
stream.current = createMergedThreadStream(threadKindsForSection(section), source.loadThreads);
|
|
212
|
+
setLoading(true);
|
|
213
|
+
setError(null);
|
|
214
|
+
stream.current.next(PAGE_SIZE).then((page) => {
|
|
215
|
+
if (generation.current !== mine) return;
|
|
216
|
+
setThreads(page.items);
|
|
217
|
+
setHasMore(page.hasMore);
|
|
218
|
+
setLoading(false);
|
|
219
|
+
loadedAt.current = Date.now();
|
|
220
|
+
enrich(page.items);
|
|
221
|
+
}).catch((failure) => {
|
|
222
|
+
if (generation.current !== mine) return;
|
|
223
|
+
setError(failure instanceof Error ? failure.message : "Conversations could not be loaded.");
|
|
224
|
+
setLoading(false);
|
|
225
|
+
});
|
|
226
|
+
}, [enrich, section, source, reloadKey]);
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
const timer = window.setInterval(() => setNow(Date.now()), 60_000);
|
|
230
|
+
// Nothing subscribes: the page catches up when it is looked at again.
|
|
231
|
+
const refocus = () => { if (document.visibilityState !== "hidden" && Date.now() - loadedAt.current > STALE_AFTER_MS) setReloadKey((current) => current + 1); };
|
|
232
|
+
window.addEventListener("focus", refocus);
|
|
233
|
+
document.addEventListener("visibilitychange", refocus);
|
|
234
|
+
return () => { window.clearInterval(timer); window.removeEventListener("focus", refocus); document.removeEventListener("visibilitychange", refocus); };
|
|
235
|
+
}, []);
|
|
236
|
+
|
|
237
|
+
const loadMore = async () => {
|
|
238
|
+
if (!stream.current || loadingMore) return;
|
|
239
|
+
const mine = generation.current;
|
|
240
|
+
setLoadingMore(true);
|
|
241
|
+
try {
|
|
242
|
+
const page = await stream.current.next(PAGE_SIZE);
|
|
243
|
+
if (generation.current !== mine) return;
|
|
244
|
+
setThreads((current) => [...current, ...page.items.filter((thread) => !current.some((entry) => entry.id === thread.id))]);
|
|
245
|
+
setHasMore(page.hasMore);
|
|
246
|
+
enrich(page.items);
|
|
247
|
+
} catch (failure) {
|
|
248
|
+
setError(failure instanceof Error ? failure.message : "More conversations could not be loaded.");
|
|
249
|
+
} finally {
|
|
250
|
+
setLoadingMore(false);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const startConversation = async (body: string) => {
|
|
255
|
+
const { thread, message } = await source.start(body);
|
|
256
|
+
setSeeds((current) => ({ ...current, [thread.id]: [message] }));
|
|
257
|
+
setThreads((current) => [thread, ...current.filter((entry) => entry.id !== thread.id)]);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const canStart = section === "all" || section === "other";
|
|
261
|
+
return (
|
|
262
|
+
<div className="@container space-y-5" data-conversations-view {...FEED_SURFACE_PROPS}>
|
|
263
|
+
<div aria-label="Conversations" className="flex flex-wrap gap-1.5" data-conversation-sections role="group">
|
|
264
|
+
{CONVERSATION_SECTIONS.map(({ key, label }) => {
|
|
265
|
+
const Icon = SECTION_ICONS[key];
|
|
266
|
+
const on = section === key;
|
|
267
|
+
return (
|
|
268
|
+
<button
|
|
269
|
+
aria-pressed={on}
|
|
270
|
+
className={cn(PILL, on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]/45 text-muted-foreground hover:bg-[var(--feed-control)]/70 hover:text-foreground")}
|
|
271
|
+
data-conversation-section={key}
|
|
272
|
+
key={key}
|
|
273
|
+
onClick={() => setSection(key)}
|
|
274
|
+
type="button"
|
|
275
|
+
>
|
|
276
|
+
<Icon aria-hidden="true" className="size-4" />
|
|
277
|
+
{label}
|
|
278
|
+
</button>
|
|
279
|
+
);
|
|
280
|
+
})}
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
{canStart ? (
|
|
284
|
+
<section aria-label="New message" className="max-w-2xl space-y-2 rounded-[20px] bg-[var(--feed-card)] p-4" data-conversation-new>
|
|
285
|
+
<h2 className={cn(LABEL, "text-muted-foreground")}>New message</h2>
|
|
286
|
+
<Composer buttonLabel="Post" icon="post" label="New message" onSubmit={startConversation} placeholder="New message… (a thought that is not about any one Reference or Story)" />
|
|
287
|
+
</section>
|
|
288
|
+
) : null}
|
|
289
|
+
|
|
290
|
+
{loading ? <p className="flex items-center gap-1.5 text-sm text-muted-foreground" data-conversations-loading role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading conversations</p> : null}
|
|
291
|
+
{error ? (
|
|
292
|
+
<p className="flex items-start gap-1.5 text-sm text-destructive" data-conversations-error role="alert">
|
|
293
|
+
<TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
294
|
+
<span>Conversations could not be loaded. {error}</span>
|
|
295
|
+
</p>
|
|
296
|
+
) : null}
|
|
297
|
+
{!loading && !error && !threads.length ? (
|
|
298
|
+
<p className="flex items-center gap-2 text-sm text-muted-foreground" data-conversations-empty>
|
|
299
|
+
<InboxIcon aria-hidden="true" className="size-4" />
|
|
300
|
+
{SECTION_EMPTY_COPY[section]}
|
|
301
|
+
</p>
|
|
302
|
+
) : null}
|
|
303
|
+
|
|
304
|
+
{threads.length ? (
|
|
305
|
+
<div className={FEED_GRID} data-conversation-grid>
|
|
306
|
+
{threads.map((thread) => (
|
|
307
|
+
<ConversationCard demo={demo} key={thread.id} now={now} onNavigate={onNavigate} reloadKey={reloadKey} references={references} seed={seeds[thread.id]} source={source} stories={stories} thread={thread} />
|
|
308
|
+
))}
|
|
309
|
+
</div>
|
|
310
|
+
) : null}
|
|
311
|
+
{hasMore ? (
|
|
312
|
+
<button className={cn(PILL, "bg-[var(--feed-control)] text-foreground")} data-conversations-more disabled={loadingMore} onClick={() => void loadMore()} type="button">
|
|
313
|
+
{loadingMore ? <Loader2Icon aria-hidden="true" className="size-4 animate-spin" /> : null}
|
|
314
|
+
Show more conversations
|
|
315
|
+
</button>
|
|
316
|
+
) : null}
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
@@ -49,6 +49,7 @@ import type {
|
|
|
49
49
|
import { relationTypeKey, semanticStateKey, semanticVersionKey } from "../lib/semantic-graph";
|
|
50
50
|
import {
|
|
51
51
|
CONVERSATION_FEED_KEY,
|
|
52
|
+
GENERAL_CONVERSATION_KIND,
|
|
52
53
|
REFERENCE_CONVERSATION_KIND,
|
|
53
54
|
conversationAnchorKey,
|
|
54
55
|
conversationMessageId,
|
|
@@ -1430,7 +1431,9 @@ async function appendMessageToThread(thread: MessageThreadRecord, build: (sequen
|
|
|
1430
1431
|
list: () => listMessagesByThreadId(threadId, 1000),
|
|
1431
1432
|
create: (sequenceNumber) => {
|
|
1432
1433
|
now = new Date().toISOString();
|
|
1433
|
-
|
|
1434
|
+
// `createdAt` is part of the composite sort key of messagesByResponseTargetStatusCreatedAt, so AppSync refuses a Message that
|
|
1435
|
+
// sets it without `responseStatus`. A person's message waits on no responder: COMPLETED, as the Python message contract writes.
|
|
1436
|
+
built = { responseStatus: "COMPLETED", ...build(sequenceNumber, now) };
|
|
1434
1437
|
return runGraphql<{ createMessage?: MessageRecord | null }>(CREATE_MESSAGE_MUTATION, { input: built });
|
|
1435
1438
|
},
|
|
1436
1439
|
});
|
|
@@ -1591,11 +1594,13 @@ export async function appendConversationMessage(input: {
|
|
|
1591
1594
|
anchor: ConversationAnchor;
|
|
1592
1595
|
body: string;
|
|
1593
1596
|
parentMessageId?: string | null;
|
|
1597
|
+
/** The conversation to write into, when the caller already holds it (the Messages page). Without it the anchor finds or creates it. */
|
|
1598
|
+
thread?: MessageThreadRecord;
|
|
1594
1599
|
}): Promise<{ message: ConversationMessage; thread: MessageThreadRecord; relationError: string | null }> {
|
|
1595
1600
|
const content = String(input.body || "").trim();
|
|
1596
1601
|
if (!content) throw new Error("Write something to post.");
|
|
1597
1602
|
const author = await currentConversationAuthor();
|
|
1598
|
-
const thread = await ensureConversationThread(input.anchor, author);
|
|
1603
|
+
const thread = input.thread ?? await ensureConversationThread(input.anchor, author);
|
|
1599
1604
|
const result = await appendMessageToThread(thread, (sequenceNumber, now) => ({
|
|
1600
1605
|
id: conversationMessageId(thread.id, sequenceNumber),
|
|
1601
1606
|
messageKind: "comment",
|
|
@@ -1627,6 +1632,65 @@ export async function appendConversationMessage(input: {
|
|
|
1627
1632
|
return { message: toConversationMessage(result.message), thread: result.thread, relationError };
|
|
1628
1633
|
}
|
|
1629
1634
|
|
|
1635
|
+
/** One page of conversation threads of a kind, newest activity first. */
|
|
1636
|
+
export async function loadConversationThreadPage(threadKind: string, nextToken: string | null, limit = 20): Promise<{ items: MessageThreadRecord[]; nextToken: string | null }> {
|
|
1637
|
+
type ThreadConnectionResponse = { listMessageThreadsByKindAndUpdatedAt?: { items?: Array<MessageThreadRecord | null> | null; nextToken?: string | null } | null };
|
|
1638
|
+
const response = await runGraphql<ThreadConnectionResponse>(LIST_MESSAGE_THREADS_BY_KIND_AND_UPDATED_AT_QUERY, { threadKind, sortDirection: "DESC", limit, nextToken });
|
|
1639
|
+
const connection = response.listMessageThreadsByKindAndUpdatedAt;
|
|
1640
|
+
const items = ((connection?.items ?? []).filter(Boolean) as MessageThreadRecord[]).map(normalizeThreadRecord).filter((thread) => thread.status !== "deleted");
|
|
1641
|
+
return { items, nextToken: connection?.nextToken ?? null };
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/** Every Message in a conversation thread, oldest first. */
|
|
1645
|
+
export async function loadConversationThreadMessages(threadId: string): Promise<ConversationMessage[]> {
|
|
1646
|
+
const records = await listMessagesByThreadId(threadId, 500);
|
|
1647
|
+
return records.filter((record) => record.status !== "deleted").map(toConversationMessage);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/** 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 }> {
|
|
1652
|
+
const content = String(body || "").trim();
|
|
1653
|
+
if (!content) throw new Error("Write something to post.");
|
|
1654
|
+
const author = await currentConversationAuthor();
|
|
1655
|
+
const now = new Date().toISOString();
|
|
1656
|
+
const id = `message-thread-conversation-new-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1657
|
+
const thread: MessageThreadRecord = {
|
|
1658
|
+
id,
|
|
1659
|
+
threadKind: GENERAL_CONVERSATION_KIND,
|
|
1660
|
+
status: "active",
|
|
1661
|
+
title: content.split("\n")[0].slice(0, 80),
|
|
1662
|
+
createdBySub: author.sub,
|
|
1663
|
+
createdByLabel: author.label,
|
|
1664
|
+
messageCount: 0,
|
|
1665
|
+
lastMessageId: null,
|
|
1666
|
+
lastMessageAt: null,
|
|
1667
|
+
metadata: JSON.stringify({ anchorKind: "none" }),
|
|
1668
|
+
createdAt: now,
|
|
1669
|
+
updatedAt: now,
|
|
1670
|
+
newsroomFeedKey: CONVERSATION_FEED_KEY,
|
|
1671
|
+
};
|
|
1672
|
+
const created = await runGraphql<{ createMessageThread?: MessageThreadRecord | null }>(CREATE_MESSAGE_THREAD_MUTATION, { input: thread });
|
|
1673
|
+
const saved = normalizeThreadRecord(created.createMessageThread ?? thread);
|
|
1674
|
+
const result = await appendConversationMessage({ anchor: { kind: "none" }, body: content, thread: saved });
|
|
1675
|
+
return { message: result.message, thread: result.thread };
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/** The title and editorial stage of each Story, by lineage id. A Story that cannot be read has no entry. */
|
|
1679
|
+
export async function loadStoryHeaders(lineageIds: string[]): Promise<Map<string, { title: string; stage: string | null }>> {
|
|
1680
|
+
const headers = new Map<string, { title: string; stage: string | null }>();
|
|
1681
|
+
await Promise.all([...new Set(lineageIds)].map(async (lineageId) => {
|
|
1682
|
+
try {
|
|
1683
|
+
type Response = { listItemsByLineageAndVersion?: { items?: Array<{ versionNumber?: number | null; headline?: string | null; title?: string | null; stage?: string | null } | null> | null } | null };
|
|
1684
|
+
const response = await runGraphql<Response>(`query StoryHeader($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title stage } } }`, { lineageId });
|
|
1685
|
+
const items = (response.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as Array<{ versionNumber?: number | null; headline?: string | null; title?: string | null; stage?: string | null }>;
|
|
1686
|
+
const newest = items.sort((left, right) => (right.versionNumber ?? 0) - (left.versionNumber ?? 0))[0];
|
|
1687
|
+
const title = newest?.headline || newest?.title;
|
|
1688
|
+
if (title) headers.set(lineageId, { title, stage: newest?.stage ?? null });
|
|
1689
|
+
} catch { /* a Story that cannot be read keeps the title the thread was started with */ }
|
|
1690
|
+
}));
|
|
1691
|
+
return headers;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1630
1694
|
async function createCommentRelation(message: MessageRecord, anchor: ConversationAnchor): Promise<void> {
|
|
1631
1695
|
if (anchor.kind === "none") return;
|
|
1632
1696
|
const lineageId = anchor.lineageId.trim();
|
|
@@ -1843,10 +1907,17 @@ async function runGraphql<T extends Record<string, unknown>>(
|
|
|
1843
1907
|
const client = generateClient<Schema>() as unknown as {
|
|
1844
1908
|
graphql: (options: Record<string, unknown>) => Promise<{ data?: T | null; errors?: unknown[] | null }>;
|
|
1845
1909
|
};
|
|
1910
|
+
// The Amplify client rejects with the raw `{ data, errors }` response when the API returns errors; turn that into an
|
|
1911
|
+
// Error carrying the API's own words, so a refusal is said in words instead of as an unknown failure.
|
|
1846
1912
|
const response = await client.graphql({
|
|
1847
1913
|
query,
|
|
1848
1914
|
variables,
|
|
1849
1915
|
authMode: USER_POOL_AUTH_MODE,
|
|
1916
|
+
}).catch((failure: unknown) => {
|
|
1917
|
+
if (failure && typeof failure === "object" && Array.isArray((failure as { errors?: unknown }).errors)) {
|
|
1918
|
+
assertNoGraphQLErrors((failure as { errors: unknown[] }).errors);
|
|
1919
|
+
}
|
|
1920
|
+
throw failure;
|
|
1850
1921
|
});
|
|
1851
1922
|
assertNoGraphQLErrors(response.errors);
|
|
1852
1923
|
return (response.data ?? {}) as T;
|