@anthusai/papyrus 5.57.0 → 5.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/message-actions.js +68 -0
- package/amplify/data/message-actions.ts +70 -0
- package/amplify/data/resolvers/delete-own-message.js +30 -0
- package/amplify/data/resolvers/edit-message.js +33 -0
- package/amplify/data/resolvers/moderate-message.js +32 -0
- package/amplify/data/resolvers/post-message.js +68 -0
- package/amplify/data/resolvers/rename-thread.js +28 -0
- package/amplify/data/resource.js +28 -3
- package/amplify/data/resource.ts +28 -3
- package/amplify/data/schema.js +34 -5
- package/amplify/data/schema.ts +34 -5
- package/amplify/functions/category-action/handler.ts +51 -125
- package/amplify/functions/shared/reference-curation-metadata.ts +21 -29
- package/components/conversation-context.tsx +29 -2
- package/components/conversation-provider.tsx +5 -1
- package/components/conversation-thread.tsx +154 -25
- package/components/conversations-memory-source.ts +83 -0
- package/components/conversations-network-source.ts +52 -0
- package/components/conversations-page.tsx +14 -0
- package/components/conversations-source.ts +36 -0
- package/components/conversations-view.tsx +680 -0
- package/components/decision-control.tsx +307 -0
- package/components/list-detail-layout.tsx +44 -0
- package/components/needs-me-bodies.tsx +18 -17
- package/components/needs-me-feed.tsx +16 -32
- package/components/news-desk-taxonomy-client.ts +216 -62
- package/components/newsroom-article-editor.tsx +10 -4
- package/components/newsroom-organizer.css +34 -1
- package/components/newsroom-references-view.tsx +41 -100
- package/components/notification-cards.tsx +9 -8
- package/components/notification-center.tsx +5 -7
- package/components/proposals-view.tsx +4 -4
- package/components/reference-conversation.tsx +5 -0
- package/components/reference-feed.tsx +129 -127
- package/components/reference-review-card.tsx +9 -135
- package/components/references-page-loading.tsx +12 -6
- package/components/story-reporting-trail-client.ts +4 -3
- package/components/story-reporting-trail.tsx +8 -0
- package/components/topic-steering-workspace.tsx +44 -786
- package/components/use-conversation.ts +36 -2
- package/components/use-feed-preferences.ts +33 -0
- package/components/use-reference-ratings.ts +11 -7
- package/lib/category-repository.ts +14 -38
- package/lib/console-chat-client.ts +8 -178
- package/lib/conversation-feed.ts +96 -0
- package/lib/conversation.ts +73 -2
- package/lib/cyclotron-suggestions.ts +18 -36
- package/lib/local-preview-seed.ts +16 -27
- package/lib/needs-me-items.ts +4 -4
- package/lib/needs-me-ranking.ts +9 -22
- package/lib/newsroom-board.ts +10 -1
- package/lib/newsroom-demo-dashboard.ts +0 -7
- package/lib/newsroom-organizer-nav.ts +2 -2
- package/lib/post-message.ts +54 -0
- package/lib/proposals-admin.ts +2 -2
- package/lib/reference-feed-keys.ts +69 -0
- package/lib/reference-feed-ranking.ts +77 -160
- package/lib/reference-policy.ts +83 -11
- package/lib/reference-review-details.ts +21 -57
- package/lib/relevance-decisions.ts +6 -2
- package/lib/review-drafts.ts +2 -2
- package/lib/review-fixture.ts +1 -1
- package/lib/review-requests.ts +4 -4
- package/lib/semantic-graph.ts +1 -1
- package/lib/story-reporting-trail.ts +28 -2
- package/package.json +12 -2
- package/routes.manifest.json +1 -1
- package/components/review-rating-grid.tsx +0 -190
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Loader2Icon, MessageSquarePlusIcon, ReplyIcon, TriangleAlertIcon, CornerDownRightIcon } from "lucide-react";
|
|
3
|
+
import { BanIcon, CheckIcon, Loader2Icon, MessageSquarePlusIcon, MessagesSquareIcon, PencilIcon, ReplyIcon, Trash2Icon, TriangleAlertIcon, CornerDownRightIcon, XIcon } from "lucide-react";
|
|
4
4
|
import { useMemo, useState } from "react";
|
|
5
|
-
import {
|
|
5
|
+
import { latestMessages } from "../lib/conversation-feed";
|
|
6
|
+
import { buildConversationTree, canDeleteMessage, canEditMessage, conversationAge, messageAuthorName, type ConversationMessage, type ConversationViewer } from "../lib/conversation";
|
|
6
7
|
import { cn } from "../lib/utils";
|
|
7
8
|
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
8
9
|
import type { ConversationState } from "./use-conversation";
|
|
9
10
|
|
|
10
|
-
/**
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
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");
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
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
|
|
17
19
|
* back (unless you have typed something new) with the reason, and nothing you wrote is lost.
|
|
18
20
|
*/
|
|
19
|
-
function Composer({ autoFocus = false, buttonLabel, disabledReason, icon, label, onCancel, onSubmit, placeholder }: {
|
|
21
|
+
export function Composer({ autoFocus = false, buttonLabel, disabledReason, icon, label, onCancel, onSubmit, placeholder, postBlockedReason }: {
|
|
20
22
|
autoFocus?: boolean;
|
|
21
23
|
buttonLabel: string;
|
|
22
24
|
disabledReason?: string | null;
|
|
25
|
+
/** Something else the post needs first (a title): the box stays open for writing, the button waits, and the reason is said in words. */
|
|
26
|
+
postBlockedReason?: string | null;
|
|
23
27
|
icon: "post" | "reply";
|
|
24
28
|
label: string;
|
|
25
29
|
onCancel?: () => void;
|
|
@@ -29,11 +33,11 @@ function Composer({ autoFocus = false, buttonLabel, disabledReason, icon, label,
|
|
|
29
33
|
const [draft, setDraft] = useState("");
|
|
30
34
|
const [error, setError] = useState<string | null>(null);
|
|
31
35
|
const Icon = icon === "post" ? MessageSquarePlusIcon : ReplyIcon;
|
|
32
|
-
const canPost = !disabledReason && draft.trim().length > 0;
|
|
36
|
+
const canPost = !disabledReason && !postBlockedReason && draft.trim().length > 0;
|
|
33
37
|
|
|
34
38
|
async function submit() {
|
|
35
39
|
const text = draft.trim();
|
|
36
|
-
if (!text || disabledReason) return;
|
|
40
|
+
if (!text || disabledReason || postBlockedReason) return;
|
|
37
41
|
setDraft("");
|
|
38
42
|
setError(null);
|
|
39
43
|
try {
|
|
@@ -50,7 +54,7 @@ function Composer({ autoFocus = false, buttonLabel, disabledReason, icon, label,
|
|
|
50
54
|
<AutoGrowTextarea
|
|
51
55
|
aria-label={label}
|
|
52
56
|
autoFocus={autoFocus}
|
|
53
|
-
className="block min-h-16 w-full rounded-[var(--feed-radius-
|
|
57
|
+
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"
|
|
54
58
|
disabled={Boolean(disabledReason)}
|
|
55
59
|
onChange={(event) => setDraft(event.target.value)}
|
|
56
60
|
onKeyDown={(event) => { if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); void submit(); } }}
|
|
@@ -64,6 +68,7 @@ function Composer({ autoFocus = false, buttonLabel, disabledReason, icon, label,
|
|
|
64
68
|
</p>
|
|
65
69
|
) : null}
|
|
66
70
|
{disabledReason ? <p className="text-sm text-muted-foreground">{disabledReason}</p> : null}
|
|
71
|
+
{postBlockedReason && draft.trim() ? <p className="text-sm text-muted-foreground" data-conversation-blocked>{postBlockedReason}</p> : null}
|
|
67
72
|
<div className="flex flex-wrap items-center gap-2">
|
|
68
73
|
<button className={PILL_PRIMARY} data-conversation-post={icon} disabled={!canPost} onClick={() => void submit()} type="button">
|
|
69
74
|
<Icon aria-hidden="true" className="size-4" />
|
|
@@ -75,23 +80,82 @@ function Composer({ autoFocus = false, buttonLabel, disabledReason, icon, label,
|
|
|
75
80
|
);
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
|
|
83
|
+
type Mode = "view" | "editing" | "confirming";
|
|
84
|
+
|
|
85
|
+
function MessageBody({ answers, authorNames, message, now, onDelete, onEdit, onReply, replying, tone, viewer }: {
|
|
79
86
|
answers?: string | null;
|
|
87
|
+
authorNames?: ReadonlyMap<string, string>;
|
|
80
88
|
message: ConversationMessage;
|
|
81
89
|
now: number;
|
|
90
|
+
onDelete?: (id: string) => Promise<void>;
|
|
91
|
+
onEdit?: (id: string, body: string) => Promise<void>;
|
|
82
92
|
onReply: () => void;
|
|
83
93
|
replying: boolean;
|
|
84
94
|
tone: "root" | "reply";
|
|
95
|
+
viewer?: ConversationViewer;
|
|
85
96
|
}) {
|
|
97
|
+
const [mode, setMode] = useState<Mode>("view");
|
|
98
|
+
const [draft, setDraft] = useState(message.content);
|
|
99
|
+
const [busy, setBusy] = useState(false);
|
|
100
|
+
const [error, setError] = useState<string | null>(null);
|
|
101
|
+
const canEdit = Boolean(onEdit) && canEditMessage(viewer, message);
|
|
102
|
+
const canDelete = Boolean(onDelete) && canDeleteMessage(viewer, message);
|
|
103
|
+
const frame = cn("space-y-1.5 rounded-[var(--feed-radius-md,16px)] p-3", tone === "root" ? "bg-[var(--feed-control)]" : "bg-[var(--feed-footer)]");
|
|
104
|
+
|
|
105
|
+
if (message.deleted) {
|
|
106
|
+
return (
|
|
107
|
+
<div className={frame} data-conversation-message={message.id} data-conversation-deleted="true">
|
|
108
|
+
<p className="flex items-center gap-1.5 text-sm text-muted-foreground" data-conversation-content>
|
|
109
|
+
<BanIcon aria-hidden="true" className="size-4" />
|
|
110
|
+
Message deleted
|
|
111
|
+
</p>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const stop = () => { setMode("view"); setError(null); setDraft(message.content); };
|
|
117
|
+
async function save() {
|
|
118
|
+
const text = draft.trim();
|
|
119
|
+
if (!text) { setError("A message cannot be empty."); return; }
|
|
120
|
+
if (text === message.content.trim()) { stop(); return; }
|
|
121
|
+
setBusy(true);
|
|
122
|
+
setError(null);
|
|
123
|
+
try {
|
|
124
|
+
await onEdit!(message.id, text);
|
|
125
|
+
setMode("view");
|
|
126
|
+
} catch (failure) {
|
|
127
|
+
setError(failure instanceof Error ? failure.message : "The message could not be saved.");
|
|
128
|
+
} finally {
|
|
129
|
+
setBusy(false);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function confirmDelete() {
|
|
133
|
+
setBusy(true);
|
|
134
|
+
setError(null);
|
|
135
|
+
try {
|
|
136
|
+
await onDelete!(message.id);
|
|
137
|
+
} catch (failure) {
|
|
138
|
+
setError(failure instanceof Error ? failure.message : "The message could not be deleted.");
|
|
139
|
+
setBusy(false);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const failure = error ? (
|
|
143
|
+
<p className="flex items-start gap-1.5 text-sm text-destructive" data-conversation-action-error role="alert">
|
|
144
|
+
<TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
145
|
+
<span>{mode === "editing" ? `Not saved: ${error} Your text is kept here.` : `Not deleted: ${error}`}</span>
|
|
146
|
+
</p>
|
|
147
|
+
) : null;
|
|
148
|
+
|
|
86
149
|
return (
|
|
87
|
-
<div className={
|
|
150
|
+
<div className={frame} data-conversation-message={message.id} data-conversation-pending={message.pending || undefined}>
|
|
88
151
|
<p className="flex flex-wrap items-center gap-x-2 text-sm">
|
|
89
|
-
<span className="font-semibold" data-conversation-author>{
|
|
152
|
+
<span className="font-semibold" data-conversation-author>{messageAuthorName(message, authorNames)}</span>
|
|
90
153
|
<span className="text-xs text-muted-foreground" data-conversation-age>
|
|
91
154
|
{message.pending ? (
|
|
92
155
|
<span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3 animate-spin" />Sending</span>
|
|
93
156
|
) : conversationAge(message.createdAt, now)}
|
|
94
157
|
</span>
|
|
158
|
+
{message.editedAt && !message.pending ? <span className="text-xs text-muted-foreground" data-conversation-edited>edited {conversationAge(message.editedAt, now) === "now" ? "just now" : `${conversationAge(message.editedAt, now)} ago`}</span> : null}
|
|
95
159
|
</p>
|
|
96
160
|
{answers ? (
|
|
97
161
|
<p className="flex items-center gap-1 text-xs text-muted-foreground" data-conversation-answers>
|
|
@@ -99,12 +163,58 @@ function MessageBody({ answers, message, now, onReply, replying, tone }: {
|
|
|
99
163
|
In reply to {answers}
|
|
100
164
|
</p>
|
|
101
165
|
) : null}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
166
|
+
{mode === "editing" ? (
|
|
167
|
+
<div className="space-y-2" data-conversation-editing>
|
|
168
|
+
<AutoGrowTextarea
|
|
169
|
+
aria-label="Edit your message"
|
|
170
|
+
autoFocus
|
|
171
|
+
className="block min-h-16 w-full rounded-[var(--feed-radius-md,16px)] border-0 bg-[var(--feed-card)] px-3 py-2 text-sm font-normal leading-5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
|
172
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
173
|
+
onKeyDown={(event) => { if (event.key === "Escape") stop(); else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); void save(); } }}
|
|
174
|
+
value={draft}
|
|
175
|
+
/>
|
|
176
|
+
{failure}
|
|
177
|
+
<div className="flex flex-wrap gap-2">
|
|
178
|
+
<button className={PILL_PRIMARY} data-conversation-edit-save disabled={busy || !draft.trim()} onClick={() => void save()} type="button">
|
|
179
|
+
{busy ? <Loader2Icon aria-hidden="true" className="size-4 animate-spin" /> : <CheckIcon aria-hidden="true" className="size-4" />}Save
|
|
180
|
+
</button>
|
|
181
|
+
<button className={PILL_QUIET} data-conversation-edit-cancel disabled={busy} onClick={stop} type="button"><XIcon aria-hidden="true" className="size-4" />Cancel</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
) : (
|
|
185
|
+
<p className="whitespace-pre-wrap break-words text-sm leading-5" data-conversation-content>{message.content}</p>
|
|
186
|
+
)}
|
|
187
|
+
{mode === "confirming" ? (
|
|
188
|
+
<div className="space-y-2" data-conversation-confirm>
|
|
189
|
+
<p className="flex items-center gap-1.5 text-sm font-medium"><Trash2Icon aria-hidden="true" className="size-4" />Delete this message?</p>
|
|
190
|
+
{failure}
|
|
191
|
+
<div className="flex flex-wrap gap-2">
|
|
192
|
+
<button className={cn(PILL, "bg-[var(--feed-card)] font-semibold text-destructive")} data-conversation-delete-confirm disabled={busy} onClick={() => void confirmDelete()} type="button">
|
|
193
|
+
{busy ? <Loader2Icon aria-hidden="true" className="size-4 animate-spin" /> : <Trash2Icon aria-hidden="true" className="size-4" />}Delete
|
|
194
|
+
</button>
|
|
195
|
+
<button className={PILL_QUIET} data-conversation-delete-keep disabled={busy} onClick={stop} type="button"><XIcon aria-hidden="true" className="size-4" />Keep</button>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
) : null}
|
|
199
|
+
{mode === "view" && !message.pending ? (
|
|
200
|
+
<div className="flex flex-wrap items-center gap-x-1">
|
|
201
|
+
<button aria-expanded={replying} className={cn(PILL_QUIET, "-ml-3")} data-conversation-reply={message.id} onClick={onReply} type="button">
|
|
202
|
+
<ReplyIcon aria-hidden="true" className="size-4" />
|
|
203
|
+
Reply
|
|
204
|
+
</button>
|
|
205
|
+
{canEdit ? (
|
|
206
|
+
<button className={PILL_QUIET} data-conversation-edit={message.id} onClick={() => { setDraft(message.content); setMode("editing"); }} type="button">
|
|
207
|
+
<PencilIcon aria-hidden="true" className="size-4" />
|
|
208
|
+
Edit
|
|
209
|
+
</button>
|
|
210
|
+
) : null}
|
|
211
|
+
{canDelete ? (
|
|
212
|
+
<button className={PILL_QUIET} data-conversation-delete={message.id} onClick={() => setMode("confirming")} type="button">
|
|
213
|
+
<Trash2Icon aria-hidden="true" className="size-4" />
|
|
214
|
+
Delete
|
|
215
|
+
</button>
|
|
216
|
+
) : null}
|
|
217
|
+
</div>
|
|
108
218
|
) : null}
|
|
109
219
|
</div>
|
|
110
220
|
);
|
|
@@ -114,24 +224,37 @@ function MessageBody({ answers, message, now, onReply, replying, tone }: {
|
|
|
114
224
|
* The Messages of one conversation, replies nested one level under their top-level Message, and a box to add another. It knows
|
|
115
225
|
* nothing about where the Messages come from: the caller loads them and saves a post.
|
|
116
226
|
*/
|
|
117
|
-
export function ConversationThread({ disabledReason = null, error = null, heading = "Messages", messages, now, onPost, onRetry, state = "ready" }: {
|
|
227
|
+
export function ConversationThread({ authorNames, disabledReason = null, error = null, heading = "Messages", hideHeading = false, latest, messages, now, onDelete, onEdit, onPost, onRetry, state = "ready", viewer }: {
|
|
228
|
+
/** Display names by author profile id. */
|
|
229
|
+
authorNames?: ReadonlyMap<string, string>;
|
|
118
230
|
disabledReason?: string | null;
|
|
119
231
|
error?: string | null;
|
|
120
232
|
heading?: string;
|
|
233
|
+
/** Keep the heading for screen readers only (the Messages page labels the card itself). */
|
|
234
|
+
hideHeading?: boolean;
|
|
235
|
+
/** Show only the newest this-many top-level messages (with their replies), and say how many are held back. */
|
|
236
|
+
latest?: number;
|
|
121
237
|
messages: ReadonlyArray<ConversationMessage>;
|
|
122
238
|
now: number;
|
|
239
|
+
/** Without these (or without a viewer) no Message offers Edit or Delete. */
|
|
240
|
+
onDelete?: (messageId: string) => Promise<void>;
|
|
241
|
+
onEdit?: (messageId: string, body: string) => Promise<void>;
|
|
123
242
|
onPost: (body: string, parentMessageId: string | null) => Promise<void>;
|
|
124
243
|
onRetry?: () => void;
|
|
125
244
|
state?: ConversationState;
|
|
245
|
+
viewer?: ConversationViewer;
|
|
126
246
|
}) {
|
|
127
|
-
const
|
|
247
|
+
const fullTree = useMemo(() => buildConversationTree(messages, (message) => messageAuthorName(message, authorNames)), [messages, authorNames]);
|
|
248
|
+
const [showAll, setShowAll] = useState(false);
|
|
249
|
+
const { shown: tree, hidden: hiddenRoots } = latestMessages(fullTree, latest ?? Number.MAX_SAFE_INTEGER, showAll);
|
|
250
|
+
const hiddenCount = hiddenRoots ? messages.length - tree.reduce((sum, root) => sum + 1 + root.replies.length, 0) : 0;
|
|
128
251
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
|
129
|
-
const authorOf = (id: string) =>
|
|
252
|
+
const authorOf = (id: string) => { const found = messages.find((message) => message.id === id); return found ? messageAuthorName(found, authorNames) : "Someone"; };
|
|
130
253
|
const toggleReply = (id: string) => setReplyingTo((current) => (current === id ? null : id));
|
|
131
254
|
|
|
132
255
|
return (
|
|
133
256
|
<section aria-label={heading} className="space-y-3" data-conversation-thread>
|
|
134
|
-
<h3 className="text-
|
|
257
|
+
<h3 className={cn(LABEL, "text-muted-foreground", hideHeading && "sr-only")}>{heading}</h3>
|
|
135
258
|
{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
259
|
{state === "error" ? (
|
|
137
260
|
<p className="flex flex-wrap items-center gap-2 text-sm text-destructive" role="alert">
|
|
@@ -141,15 +264,21 @@ export function ConversationThread({ disabledReason = null, error = null, headin
|
|
|
141
264
|
</p>
|
|
142
265
|
) : null}
|
|
143
266
|
{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}
|
|
267
|
+
{hiddenCount > 0 ? (
|
|
268
|
+
<button className={cn(PILL_QUIET, "w-full bg-[var(--feed-control)]/45")} data-conversation-show-all onClick={() => setShowAll(true)} type="button">
|
|
269
|
+
<MessagesSquareIcon aria-hidden="true" className="size-4" />
|
|
270
|
+
Show all {messages.length} messages
|
|
271
|
+
</button>
|
|
272
|
+
) : null}
|
|
144
273
|
<ul className="space-y-3">
|
|
145
274
|
{tree.map((root) => (
|
|
146
275
|
<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" />
|
|
276
|
+
<MessageBody authorNames={authorNames} message={root.message} now={now} onDelete={onDelete} onEdit={onEdit} onReply={() => toggleReply(root.message.id)} replying={replyingTo === root.message.id} tone="root" viewer={viewer} />
|
|
148
277
|
{root.replies.length || replyingTo === root.message.id ? (
|
|
149
278
|
<ul className="ml-4 space-y-2" data-conversation-replies>
|
|
150
279
|
{root.replies.map((reply) => (
|
|
151
280
|
<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" />
|
|
281
|
+
<MessageBody answers={reply.answers} authorNames={authorNames} message={reply.message} now={now} onDelete={onDelete} onEdit={onEdit} onReply={() => toggleReply(reply.message.id)} replying={replyingTo === reply.message.id} tone="reply" viewer={viewer} />
|
|
153
282
|
{replyingTo === reply.message.id ? (
|
|
154
283
|
<div className="mt-2">
|
|
155
284
|
<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)}…`} />
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { MessageThreadRecord } from "../lib/category-repository";
|
|
2
|
+
import { NO_VIEWER, canDeleteMessage, canEditMessage, conversationPreviewLine, threadMetadataWithLastAuthor, type ConversationMessage, type ConversationViewer } 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
|
+
/** Who is looking. A Message they post carries their sub. */
|
|
14
|
+
viewer?: ConversationViewer;
|
|
15
|
+
/** The viewer's own profile id, recorded on what they post. */
|
|
16
|
+
viewerProfileId?: string;
|
|
17
|
+
/** Display names by profile id. */
|
|
18
|
+
profiles?: Record<string, string>;
|
|
19
|
+
} = {}): ConversationsSource & { conversations: MemoryConversation[] } {
|
|
20
|
+
const conversations = [...(seed.conversations ?? [])];
|
|
21
|
+
const pageSize = seed.pageSize ?? 20;
|
|
22
|
+
const viewer = seed.viewer ?? NO_VIEWER;
|
|
23
|
+
const find = (messageId: string) => {
|
|
24
|
+
for (const entry of conversations) {
|
|
25
|
+
const message = entry.messages.find((candidate) => candidate.id === messageId);
|
|
26
|
+
if (message) return { entry, message };
|
|
27
|
+
}
|
|
28
|
+
throw new Error("That message is not here.");
|
|
29
|
+
};
|
|
30
|
+
let started = 0;
|
|
31
|
+
return {
|
|
32
|
+
authorLabel,
|
|
33
|
+
conversations,
|
|
34
|
+
loadThreads: async (threadKind, nextToken) => {
|
|
35
|
+
const all = conversations.map((entry) => entry.thread).filter((thread) => thread.threadKind === threadKind).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
36
|
+
const start = nextToken ? Number(nextToken) : 0;
|
|
37
|
+
return { items: all.slice(start, start + pageSize), nextToken: start + pageSize < all.length ? String(start + pageSize) : null };
|
|
38
|
+
},
|
|
39
|
+
loadViewer: async () => viewer,
|
|
40
|
+
loadAuthors: async (ids) => new Map(ids.filter((id) => seed.profiles?.[id]).map((id) => [id, seed.profiles![id]])),
|
|
41
|
+
edit: async (messageId, body) => {
|
|
42
|
+
const { entry, message } = find(messageId);
|
|
43
|
+
if (!canEditMessage(viewer, message)) throw new Error("Only the author can edit a message.");
|
|
44
|
+
const edited = { ...message, content: body.trim(), editedAt: new Date().toISOString() };
|
|
45
|
+
entry.messages = entry.messages.map((candidate) => (candidate.id === messageId ? edited : candidate));
|
|
46
|
+
return edited;
|
|
47
|
+
},
|
|
48
|
+
remove: async (messageId) => {
|
|
49
|
+
const { entry, message } = find(messageId);
|
|
50
|
+
if (!canDeleteMessage(viewer, message)) throw new Error("Only the author or an admin can delete a message.");
|
|
51
|
+
const removed = { ...message, content: "", deleted: true, deletedAt: new Date().toISOString() };
|
|
52
|
+
entry.messages = entry.messages.map((candidate) => (candidate.id === messageId ? removed : candidate));
|
|
53
|
+
return removed;
|
|
54
|
+
},
|
|
55
|
+
loadMessages: async (threadId) => [...(conversations.find((entry) => entry.thread.id === threadId)?.messages ?? [])],
|
|
56
|
+
post: async ({ thread, body, parentMessageId }) => {
|
|
57
|
+
const entry = conversations.find((candidate) => candidate.thread.id === thread.id);
|
|
58
|
+
if (!entry) throw new Error("That conversation is not here.");
|
|
59
|
+
const sequenceNumber = Math.max(0, ...entry.messages.map((message) => message.sequenceNumber)) + 1;
|
|
60
|
+
const message: ConversationMessage = { id: `${thread.id}-m${sequenceNumber}`, parentMessageId, authorLabel, authorSub: viewer.sub, authorUserProfileId: seed.viewerProfileId ?? null, content: body.trim(), createdAt: new Date().toISOString(), sequenceNumber };
|
|
61
|
+
entry.messages.push(message);
|
|
62
|
+
entry.thread = { ...entry.thread, messageCount: entry.messages.length, lastMessageAt: message.createdAt, updatedAt: message.createdAt, summary: conversationPreviewLine(message.content), metadata: threadMetadataWithLastAuthor(entry.thread.metadata, authorLabel) };
|
|
63
|
+
return message;
|
|
64
|
+
},
|
|
65
|
+
loadThread: async (threadId) => conversations.find((entry) => entry.thread.id === threadId)?.thread ?? null,
|
|
66
|
+
rename: async (thread, title) => {
|
|
67
|
+
const entry = conversations.find((candidate) => candidate.thread.id === thread.id);
|
|
68
|
+
if (!entry) throw new Error("That conversation is not here.");
|
|
69
|
+
entry.thread = { ...entry.thread, title: title.trim() };
|
|
70
|
+
return entry.thread;
|
|
71
|
+
},
|
|
72
|
+
start: async (title, body) => {
|
|
73
|
+
started += 1;
|
|
74
|
+
const now = new Date().toISOString();
|
|
75
|
+
const thread: MessageThreadRecord = { id: `message-thread-conversation-new-${started}`, threadKind: "conversation", status: "active", title: title.trim(), createdBySub: viewer.sub, summary: conversationPreviewLine(body), metadata: threadMetadataWithLastAuthor(null, authorLabel), createdByLabel: authorLabel, messageCount: 1, lastMessageAt: now, createdAt: now, updatedAt: now };
|
|
76
|
+
const message: ConversationMessage = { id: `${thread.id}-m1`, parentMessageId: null, authorLabel, authorSub: viewer.sub, authorUserProfileId: seed.viewerProfileId ?? null, content: body.trim(), createdAt: now, sequenceNumber: 1 };
|
|
77
|
+
conversations.push({ thread, messages: [message] });
|
|
78
|
+
return { thread, message };
|
|
79
|
+
},
|
|
80
|
+
loadReferences: async (ids) => new Map(ids.filter((id) => seed.references?.[id]).map((id) => [id, seed.references![id]])),
|
|
81
|
+
loadStories: async (ids) => new Map(ids.filter((id) => seed.stories?.[id]).map((id) => [id, seed.stories![id]])),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { referencePreviewsByLineage } from "../lib/reference-preview";
|
|
2
|
+
import {
|
|
3
|
+
appendConversationMessage,
|
|
4
|
+
deleteConversationMessage,
|
|
5
|
+
editConversationMessage,
|
|
6
|
+
loadConversationThread,
|
|
7
|
+
loadConversationThreadMessages,
|
|
8
|
+
loadConversationViewer,
|
|
9
|
+
loadUserProfileNames,
|
|
10
|
+
loadConversationThreadPage,
|
|
11
|
+
loadReferenceAttachmentsForLineageId,
|
|
12
|
+
loadReferenceRecordByLineageId,
|
|
13
|
+
loadStoryHeaders,
|
|
14
|
+
renameConversationThread,
|
|
15
|
+
startUnattachedConversation,
|
|
16
|
+
} from "./news-desk-taxonomy-client";
|
|
17
|
+
import type { ConversationReferenceView, ConversationsSource } from "./conversations-source";
|
|
18
|
+
|
|
19
|
+
const PAGE_SIZE = 20;
|
|
20
|
+
|
|
21
|
+
/** The newsroom's own source: the MessageThread and Message models. A Reference that cannot be read is left out, and its header shows only the thread's own title. */
|
|
22
|
+
export function createNetworkConversationsSource(authorLabel: string): ConversationsSource {
|
|
23
|
+
return {
|
|
24
|
+
authorLabel,
|
|
25
|
+
loadThreads: (threadKind, nextToken) => loadConversationThreadPage(threadKind, nextToken, PAGE_SIZE),
|
|
26
|
+
loadMessages: loadConversationThreadMessages,
|
|
27
|
+
loadViewer: loadConversationViewer,
|
|
28
|
+
loadAuthors: loadUserProfileNames,
|
|
29
|
+
edit: editConversationMessage,
|
|
30
|
+
remove: (messageId, moderate) => deleteConversationMessage(messageId, moderate),
|
|
31
|
+
post: async ({ thread, body, parentMessageId }) => {
|
|
32
|
+
const anchor = thread.primaryAnchorLineageId && (thread.primaryAnchorKind === "reference" || thread.primaryAnchorKind === "item")
|
|
33
|
+
? { kind: thread.primaryAnchorKind, lineageId: thread.primaryAnchorLineageId, title: thread.title } as const
|
|
34
|
+
: { kind: "none" } as const;
|
|
35
|
+
return (await appendConversationMessage({ anchor, body, parentMessageId, thread })).message;
|
|
36
|
+
},
|
|
37
|
+
loadThread: loadConversationThread,
|
|
38
|
+
rename: renameConversationThread,
|
|
39
|
+
start: startUnattachedConversation,
|
|
40
|
+
loadReferences: async (lineageIds) => {
|
|
41
|
+
const views = new Map<string, ConversationReferenceView>();
|
|
42
|
+
await Promise.all([...new Set(lineageIds)].map(async (lineageId) => {
|
|
43
|
+
try {
|
|
44
|
+
const [reference, attachments] = await Promise.all([loadReferenceRecordByLineageId(lineageId), loadReferenceAttachmentsForLineageId(lineageId).catch(() => [])]);
|
|
45
|
+
if (reference) views.set(lineageId, { reference, preview: referencePreviewsByLineage(attachments).get(lineageId) });
|
|
46
|
+
} catch { /* left out */ }
|
|
47
|
+
}));
|
|
48
|
+
return views;
|
|
49
|
+
},
|
|
50
|
+
loadStories: loadStoryHeaders,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -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,36 @@
|
|
|
1
|
+
import type { MessageThreadRecord, ReferenceRecord } from "../lib/category-repository";
|
|
2
|
+
import type { ConversationMessage, ConversationViewer } 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
|
+
/** Who is looking: their sub and whether they are an admin. Decides which Messages show Edit and Delete. */
|
|
20
|
+
loadViewer: () => Promise<ConversationViewer>;
|
|
21
|
+
/** Display names by profile id, in one request for a whole conversation. Profiles that cannot be read are simply absent. */
|
|
22
|
+
loadAuthors: (profileIds: string[]) => Promise<Map<string, string>>;
|
|
23
|
+
/** The author changes their own Message's text; rejects with the reason when the API refuses. */
|
|
24
|
+
edit: (messageId: string, body: string) => Promise<ConversationMessage>;
|
|
25
|
+
/** A soft delete: the Message stays, marked deleted. */
|
|
26
|
+
remove: (messageId: string, moderate: boolean) => Promise<ConversationMessage>;
|
|
27
|
+
post: (input: { thread: MessageThreadRecord; body: string; parentMessageId: string | null }) => Promise<ConversationMessage>;
|
|
28
|
+
/** One thread by id, for a linked address whose thread is not on the pages read so far. Null when there is none. */
|
|
29
|
+
loadThread: (threadId: string) => Promise<MessageThreadRecord | null>;
|
|
30
|
+
/** Gives the conversation a new title; rejects with the reason when it cannot be saved. */
|
|
31
|
+
rename: (thread: MessageThreadRecord, title: string) => Promise<MessageThreadRecord>;
|
|
32
|
+
/** A new conversation about nothing but itself, with its own title and first message. */
|
|
33
|
+
start: (title: string, body: string) => Promise<{ thread: MessageThreadRecord; message: ConversationMessage }>;
|
|
34
|
+
loadReferences: (lineageIds: string[]) => Promise<Map<string, ConversationReferenceView>>;
|
|
35
|
+
loadStories: (lineageIds: string[]) => Promise<Map<string, ConversationStoryView>>;
|
|
36
|
+
};
|