@anthusai/papyrus 5.56.1 → 5.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +25 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +25 -3
- package/amplify/data/schema.ts +25 -3
- package/amplify/functions/category-action/handler.ts +51 -125
- package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
- package/components/conversation-context.tsx +52 -0
- package/components/conversation-provider.tsx +20 -0
- package/components/conversation-thread.tsx +188 -0
- 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/decision-control.tsx +307 -0
- package/components/needs-me-bodies.tsx +4 -3
- package/components/needs-me-feed.tsx +8 -14
- package/components/needs-me-reference-detail.tsx +2 -0
- package/components/news-desk-taxonomy-client.ts +322 -37
- package/components/newsroom-organizer.css +24 -0
- package/components/newsroom-references-view.tsx +9 -0
- package/components/notification-cards.tsx +5 -4
- package/components/reference-conversation.tsx +43 -0
- package/components/reference-feed.tsx +69 -37
- package/components/reference-review-card.tsx +8 -134
- package/components/story-reporting-trail-client.ts +4 -3
- package/components/story-reporting-trail.tsx +8 -0
- package/components/topic-steering-workspace.tsx +43 -784
- package/components/use-conversation.ts +84 -0
- package/components/use-reference-ratings.ts +11 -7
- package/lib/category-repository.ts +7 -0
- package/lib/conversation-feed.ts +96 -0
- package/lib/conversation.ts +154 -0
- package/lib/cyclotron-suggestions.ts +18 -36
- package/lib/local-preview-seed.ts +16 -27
- package/lib/reference-feed-keys.ts +69 -0
- package/lib/reference-policy.ts +83 -11
- package/lib/reference-review-details.ts +21 -57
- package/lib/relevance-decisions.ts +6 -2
- package/lib/story-reporting-trail.ts +28 -2
- package/package.json +15 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
|
@@ -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,188 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Loader2Icon, MessageSquarePlusIcon, MessagesSquareIcon, ReplyIcon, TriangleAlertIcon, CornerDownRightIcon } from "lucide-react";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
|
+
import { latestMessages } from "../lib/conversation-feed";
|
|
6
|
+
import { buildConversationTree, conversationAge, conversationAuthorName, type ConversationMessage } from "../lib/conversation";
|
|
7
|
+
import { cn } from "../lib/utils";
|
|
8
|
+
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
9
|
+
import type { ConversationState } from "./use-conversation";
|
|
10
|
+
|
|
11
|
+
/** The decision control's register: caption labels, md corners, a tone and never an outline. */
|
|
12
|
+
const LABEL = "font-[family-name:var(--font-sans)] text-[10px] font-extrabold tracking-[0.07em] uppercase";
|
|
13
|
+
const PILL = "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-[var(--feed-radius-md,16px)] px-3.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring disabled:opacity-[var(--feed-quiet,0.6)]";
|
|
14
|
+
const PILL_PRIMARY = cn(PILL, "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]");
|
|
15
|
+
const PILL_QUIET = cn(PILL, "text-muted-foreground hover:bg-[var(--feed-control)]/70 hover:text-foreground");
|
|
16
|
+
|
|
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
|
|
19
|
+
* back (unless you have typed something new) with the reason, and nothing you wrote is lost.
|
|
20
|
+
*/
|
|
21
|
+
export function Composer({ autoFocus = false, buttonLabel, disabledReason, icon, label, onCancel, onSubmit, placeholder }: {
|
|
22
|
+
autoFocus?: boolean;
|
|
23
|
+
buttonLabel: string;
|
|
24
|
+
disabledReason?: string | null;
|
|
25
|
+
icon: "post" | "reply";
|
|
26
|
+
label: string;
|
|
27
|
+
onCancel?: () => void;
|
|
28
|
+
onSubmit: (body: string) => Promise<void>;
|
|
29
|
+
placeholder: string;
|
|
30
|
+
}) {
|
|
31
|
+
const [draft, setDraft] = useState("");
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
const Icon = icon === "post" ? MessageSquarePlusIcon : ReplyIcon;
|
|
34
|
+
const canPost = !disabledReason && draft.trim().length > 0;
|
|
35
|
+
|
|
36
|
+
async function submit() {
|
|
37
|
+
const text = draft.trim();
|
|
38
|
+
if (!text || disabledReason) return;
|
|
39
|
+
setDraft("");
|
|
40
|
+
setError(null);
|
|
41
|
+
try {
|
|
42
|
+
await onSubmit(text);
|
|
43
|
+
onCancel?.();
|
|
44
|
+
} catch (failure) {
|
|
45
|
+
setDraft((current) => current || text);
|
|
46
|
+
setError(failure instanceof Error ? failure.message : "Your message could not be saved.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="space-y-2" data-conversation-composer={icon}>
|
|
52
|
+
<AutoGrowTextarea
|
|
53
|
+
aria-label={label}
|
|
54
|
+
autoFocus={autoFocus}
|
|
55
|
+
className="block min-h-16 w-full rounded-[var(--feed-radius-md,16px)] border-0 bg-[var(--feed-control)] px-3 py-2 text-sm font-normal leading-5 outline-none placeholder:text-muted-foreground focus-visible:ring-[3px] focus-visible:ring-ring"
|
|
56
|
+
disabled={Boolean(disabledReason)}
|
|
57
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
58
|
+
onKeyDown={(event) => { if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); void submit(); } }}
|
|
59
|
+
placeholder={placeholder}
|
|
60
|
+
value={draft}
|
|
61
|
+
/>
|
|
62
|
+
{error ? (
|
|
63
|
+
<p className="flex items-start gap-1.5 text-sm text-destructive" data-conversation-error role="alert">
|
|
64
|
+
<TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
65
|
+
<span>Not posted: {error} Your text is kept here.</span>
|
|
66
|
+
</p>
|
|
67
|
+
) : null}
|
|
68
|
+
{disabledReason ? <p className="text-sm text-muted-foreground">{disabledReason}</p> : null}
|
|
69
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
70
|
+
<button className={PILL_PRIMARY} data-conversation-post={icon} disabled={!canPost} onClick={() => void submit()} type="button">
|
|
71
|
+
<Icon aria-hidden="true" className="size-4" />
|
|
72
|
+
{buttonLabel}
|
|
73
|
+
</button>
|
|
74
|
+
{onCancel ? <button className={PILL_QUIET} onClick={onCancel} type="button">Cancel</button> : null}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function MessageBody({ answers, message, now, onReply, replying, tone }: {
|
|
81
|
+
answers?: string | null;
|
|
82
|
+
message: ConversationMessage;
|
|
83
|
+
now: number;
|
|
84
|
+
onReply: () => void;
|
|
85
|
+
replying: boolean;
|
|
86
|
+
tone: "root" | "reply";
|
|
87
|
+
}) {
|
|
88
|
+
return (
|
|
89
|
+
<div className={cn("space-y-1.5 rounded-[var(--feed-radius-md,16px)] p-3", tone === "root" ? "bg-[var(--feed-control)]" : "bg-[var(--feed-footer)]")} data-conversation-message={message.id} data-conversation-pending={message.pending || undefined}>
|
|
90
|
+
<p className="flex flex-wrap items-center gap-x-2 text-sm">
|
|
91
|
+
<span className="font-semibold" data-conversation-author>{conversationAuthorName(message.authorLabel)}</span>
|
|
92
|
+
<span className="text-xs text-muted-foreground" data-conversation-age>
|
|
93
|
+
{message.pending ? (
|
|
94
|
+
<span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3 animate-spin" />Sending</span>
|
|
95
|
+
) : conversationAge(message.createdAt, now)}
|
|
96
|
+
</span>
|
|
97
|
+
</p>
|
|
98
|
+
{answers ? (
|
|
99
|
+
<p className="flex items-center gap-1 text-xs text-muted-foreground" data-conversation-answers>
|
|
100
|
+
<CornerDownRightIcon aria-hidden="true" className="size-3" />
|
|
101
|
+
In reply to {answers}
|
|
102
|
+
</p>
|
|
103
|
+
) : null}
|
|
104
|
+
<p className="whitespace-pre-wrap break-words text-sm leading-5" data-conversation-content>{message.content}</p>
|
|
105
|
+
{!message.pending ? (
|
|
106
|
+
<button aria-expanded={replying} className={cn(PILL_QUIET, "-ml-3")} data-conversation-reply={message.id} onClick={onReply} type="button">
|
|
107
|
+
<ReplyIcon aria-hidden="true" className="size-4" />
|
|
108
|
+
Reply
|
|
109
|
+
</button>
|
|
110
|
+
) : null}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The Messages of one conversation, replies nested one level under their top-level Message, and a box to add another. It knows
|
|
117
|
+
* nothing about where the Messages come from: the caller loads them and saves a post.
|
|
118
|
+
*/
|
|
119
|
+
export function ConversationThread({ disabledReason = null, error = null, heading = "Messages", hideHeading = false, latest, messages, now, onPost, onRetry, state = "ready" }: {
|
|
120
|
+
disabledReason?: string | null;
|
|
121
|
+
error?: string | null;
|
|
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;
|
|
127
|
+
messages: ReadonlyArray<ConversationMessage>;
|
|
128
|
+
now: number;
|
|
129
|
+
onPost: (body: string, parentMessageId: string | null) => Promise<void>;
|
|
130
|
+
onRetry?: () => void;
|
|
131
|
+
state?: ConversationState;
|
|
132
|
+
}) {
|
|
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;
|
|
137
|
+
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
|
138
|
+
const authorOf = (id: string) => conversationAuthorName(messages.find((message) => message.id === id)?.authorLabel);
|
|
139
|
+
const toggleReply = (id: string) => setReplyingTo((current) => (current === id ? null : id));
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<section aria-label={heading} className="space-y-3" data-conversation-thread>
|
|
143
|
+
<h3 className={cn(LABEL, "text-muted-foreground", hideHeading && "sr-only")}>{heading}</h3>
|
|
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}
|
|
145
|
+
{state === "error" ? (
|
|
146
|
+
<p className="flex flex-wrap items-center gap-2 text-sm text-destructive" role="alert">
|
|
147
|
+
<TriangleAlertIcon aria-hidden="true" className="size-4" />
|
|
148
|
+
Messages could not be loaded. {error}
|
|
149
|
+
{onRetry ? <button className={PILL_QUIET} onClick={onRetry} type="button">Try again</button> : null}
|
|
150
|
+
</p>
|
|
151
|
+
) : null}
|
|
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}
|
|
159
|
+
<ul className="space-y-3">
|
|
160
|
+
{tree.map((root) => (
|
|
161
|
+
<li className="space-y-2" key={root.message.id}>
|
|
162
|
+
<MessageBody message={root.message} now={now} onReply={() => toggleReply(root.message.id)} replying={replyingTo === root.message.id} tone="root" />
|
|
163
|
+
{root.replies.length || replyingTo === root.message.id ? (
|
|
164
|
+
<ul className="ml-4 space-y-2" data-conversation-replies>
|
|
165
|
+
{root.replies.map((reply) => (
|
|
166
|
+
<li key={reply.message.id}>
|
|
167
|
+
<MessageBody answers={reply.answers} message={reply.message} now={now} onReply={() => toggleReply(reply.message.id)} replying={replyingTo === reply.message.id} tone="reply" />
|
|
168
|
+
{replyingTo === reply.message.id ? (
|
|
169
|
+
<div className="mt-2">
|
|
170
|
+
<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)}…`} />
|
|
171
|
+
</div>
|
|
172
|
+
) : null}
|
|
173
|
+
</li>
|
|
174
|
+
))}
|
|
175
|
+
{replyingTo === root.message.id ? (
|
|
176
|
+
<li>
|
|
177
|
+
<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)}…`} />
|
|
178
|
+
</li>
|
|
179
|
+
) : null}
|
|
180
|
+
</ul>
|
|
181
|
+
) : null}
|
|
182
|
+
</li>
|
|
183
|
+
))}
|
|
184
|
+
</ul>
|
|
185
|
+
<Composer buttonLabel="Post" disabledReason={disabledReason} icon="post" label="Add a message" onSubmit={(body) => onPost(body, null)} placeholder="Add a message…" />
|
|
186
|
+
</section>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -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
|
+
};
|