@anthusai/papyrus 5.58.0 → 5.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/amplify/data/message-actions.js +68 -0
  2. package/amplify/data/message-actions.ts +70 -0
  3. package/amplify/data/resolvers/delete-own-message.js +30 -0
  4. package/amplify/data/resolvers/edit-message.js +33 -0
  5. package/amplify/data/resolvers/moderate-message.js +32 -0
  6. package/amplify/data/resolvers/post-message.js +68 -0
  7. package/amplify/data/resolvers/rename-thread.js +28 -0
  8. package/amplify/data/resource.js +8 -5
  9. package/amplify/data/resource.ts +8 -5
  10. package/amplify/data/schema.js +14 -7
  11. package/amplify/data/schema.ts +14 -7
  12. package/components/conversation-context.tsx +29 -2
  13. package/components/conversation-provider.tsx +5 -1
  14. package/components/conversation-thread.tsx +133 -19
  15. package/components/conversations-memory-source.ts +43 -6
  16. package/components/conversations-network-source.ts +13 -1
  17. package/components/conversations-source.ts +15 -3
  18. package/components/conversations-view.tsx +437 -76
  19. package/components/list-detail-layout.tsx +44 -0
  20. package/components/needs-me-bodies.tsx +14 -14
  21. package/components/needs-me-feed.tsx +8 -18
  22. package/components/news-desk-taxonomy-client.ts +142 -57
  23. package/components/newsroom-article-editor.tsx +10 -4
  24. package/components/newsroom-organizer.css +10 -1
  25. package/components/newsroom-references-view.tsx +41 -100
  26. package/components/notification-cards.tsx +4 -4
  27. package/components/notification-center.tsx +5 -7
  28. package/components/proposals-view.tsx +4 -4
  29. package/components/reference-conversation.tsx +5 -0
  30. package/components/reference-feed.tsx +69 -85
  31. package/components/reference-review-card.tsx +1 -1
  32. package/components/references-page-loading.tsx +12 -6
  33. package/components/topic-steering-workspace.tsx +4 -4
  34. package/components/use-conversation.ts +36 -2
  35. package/components/use-feed-preferences.ts +33 -0
  36. package/lib/category-repository.ts +7 -38
  37. package/lib/console-chat-client.ts +8 -178
  38. package/lib/conversation.ts +73 -2
  39. package/lib/needs-me-items.ts +4 -4
  40. package/lib/needs-me-ranking.ts +9 -22
  41. package/lib/newsroom-board.ts +10 -1
  42. package/lib/newsroom-demo-dashboard.ts +0 -7
  43. package/lib/newsroom-organizer-nav.ts +2 -2
  44. package/lib/post-message.ts +54 -0
  45. package/lib/proposals-admin.ts +2 -2
  46. package/lib/reference-feed-ranking.ts +77 -160
  47. package/lib/review-drafts.ts +2 -2
  48. package/lib/review-fixture.ts +1 -1
  49. package/lib/review-requests.ts +4 -4
  50. package/lib/semantic-graph.ts +1 -1
  51. package/package.json +4 -1
  52. package/routes.manifest.json +1 -1
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { Loader2Icon, MessageSquarePlusIcon, MessagesSquareIcon, 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
5
  import { latestMessages } from "../lib/conversation-feed";
6
- import { buildConversationTree, conversationAge, conversationAuthorName, type ConversationMessage } from "../lib/conversation";
6
+ import { buildConversationTree, canDeleteMessage, canEditMessage, conversationAge, messageAuthorName, type ConversationMessage, type ConversationViewer } from "../lib/conversation";
7
7
  import { cn } from "../lib/utils";
8
8
  import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
9
9
  import type { ConversationState } from "./use-conversation";
@@ -18,10 +18,12 @@ const PILL_QUIET = cn(PILL, "text-muted-foreground hover:bg-[var(--feed-control)
18
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
19
  * back (unless you have typed something new) with the reason, and nothing you wrote is lost.
20
20
  */
21
- export 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 }: {
22
22
  autoFocus?: boolean;
23
23
  buttonLabel: string;
24
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;
25
27
  icon: "post" | "reply";
26
28
  label: string;
27
29
  onCancel?: () => void;
@@ -31,11 +33,11 @@ export function Composer({ autoFocus = false, buttonLabel, disabledReason, icon,
31
33
  const [draft, setDraft] = useState("");
32
34
  const [error, setError] = useState<string | null>(null);
33
35
  const Icon = icon === "post" ? MessageSquarePlusIcon : ReplyIcon;
34
- const canPost = !disabledReason && draft.trim().length > 0;
36
+ const canPost = !disabledReason && !postBlockedReason && draft.trim().length > 0;
35
37
 
36
38
  async function submit() {
37
39
  const text = draft.trim();
38
- if (!text || disabledReason) return;
40
+ if (!text || disabledReason || postBlockedReason) return;
39
41
  setDraft("");
40
42
  setError(null);
41
43
  try {
@@ -66,6 +68,7 @@ export function Composer({ autoFocus = false, buttonLabel, disabledReason, icon,
66
68
  </p>
67
69
  ) : null}
68
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}
69
72
  <div className="flex flex-wrap items-center gap-2">
70
73
  <button className={PILL_PRIMARY} data-conversation-post={icon} disabled={!canPost} onClick={() => void submit()} type="button">
71
74
  <Icon aria-hidden="true" className="size-4" />
@@ -77,23 +80,82 @@ export function Composer({ autoFocus = false, buttonLabel, disabledReason, icon,
77
80
  );
78
81
  }
79
82
 
80
- function MessageBody({ answers, message, now, onReply, replying, tone }: {
83
+ type Mode = "view" | "editing" | "confirming";
84
+
85
+ function MessageBody({ answers, authorNames, message, now, onDelete, onEdit, onReply, replying, tone, viewer }: {
81
86
  answers?: string | null;
87
+ authorNames?: ReadonlyMap<string, string>;
82
88
  message: ConversationMessage;
83
89
  now: number;
90
+ onDelete?: (id: string) => Promise<void>;
91
+ onEdit?: (id: string, body: string) => Promise<void>;
84
92
  onReply: () => void;
85
93
  replying: boolean;
86
94
  tone: "root" | "reply";
95
+ viewer?: ConversationViewer;
87
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
+
88
149
  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}>
150
+ <div className={frame} data-conversation-message={message.id} data-conversation-pending={message.pending || undefined}>
90
151
  <p className="flex flex-wrap items-center gap-x-2 text-sm">
91
- <span className="font-semibold" data-conversation-author>{conversationAuthorName(message.authorLabel)}</span>
152
+ <span className="font-semibold" data-conversation-author>{messageAuthorName(message, authorNames)}</span>
92
153
  <span className="text-xs text-muted-foreground" data-conversation-age>
93
154
  {message.pending ? (
94
155
  <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3 animate-spin" />Sending</span>
95
156
  ) : conversationAge(message.createdAt, now)}
96
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}
97
159
  </p>
98
160
  {answers ? (
99
161
  <p className="flex items-center gap-1 text-xs text-muted-foreground" data-conversation-answers>
@@ -101,12 +163,58 @@ function MessageBody({ answers, message, now, onReply, replying, tone }: {
101
163
  In reply to {answers}
102
164
  </p>
103
165
  ) : 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>
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>
110
218
  ) : null}
111
219
  </div>
112
220
  );
@@ -116,7 +224,9 @@ function MessageBody({ answers, message, now, onReply, replying, tone }: {
116
224
  * The Messages of one conversation, replies nested one level under their top-level Message, and a box to add another. It knows
117
225
  * nothing about where the Messages come from: the caller loads them and saves a post.
118
226
  */
119
- export function ConversationThread({ disabledReason = null, error = null, heading = "Messages", hideHeading = false, latest, 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>;
120
230
  disabledReason?: string | null;
121
231
  error?: string | null;
122
232
  heading?: string;
@@ -126,16 +236,20 @@ export function ConversationThread({ disabledReason = null, error = null, headin
126
236
  latest?: number;
127
237
  messages: ReadonlyArray<ConversationMessage>;
128
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>;
129
242
  onPost: (body: string, parentMessageId: string | null) => Promise<void>;
130
243
  onRetry?: () => void;
131
244
  state?: ConversationState;
245
+ viewer?: ConversationViewer;
132
246
  }) {
133
- const fullTree = useMemo(() => buildConversationTree(messages), [messages]);
247
+ const fullTree = useMemo(() => buildConversationTree(messages, (message) => messageAuthorName(message, authorNames)), [messages, authorNames]);
134
248
  const [showAll, setShowAll] = useState(false);
135
249
  const { shown: tree, hidden: hiddenRoots } = latestMessages(fullTree, latest ?? Number.MAX_SAFE_INTEGER, showAll);
136
250
  const hiddenCount = hiddenRoots ? messages.length - tree.reduce((sum, root) => sum + 1 + root.replies.length, 0) : 0;
137
251
  const [replyingTo, setReplyingTo] = useState<string | null>(null);
138
- const authorOf = (id: string) => conversationAuthorName(messages.find((message) => message.id === id)?.authorLabel);
252
+ const authorOf = (id: string) => { const found = messages.find((message) => message.id === id); return found ? messageAuthorName(found, authorNames) : "Someone"; };
139
253
  const toggleReply = (id: string) => setReplyingTo((current) => (current === id ? null : id));
140
254
 
141
255
  return (
@@ -159,12 +273,12 @@ export function ConversationThread({ disabledReason = null, error = null, headin
159
273
  <ul className="space-y-3">
160
274
  {tree.map((root) => (
161
275
  <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" />
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} />
163
277
  {root.replies.length || replyingTo === root.message.id ? (
164
278
  <ul className="ml-4 space-y-2" data-conversation-replies>
165
279
  {root.replies.map((reply) => (
166
280
  <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" />
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} />
168
282
  {replyingTo === reply.message.id ? (
169
283
  <div className="mt-2">
170
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)}…`} />
@@ -1,5 +1,5 @@
1
1
  import type { MessageThreadRecord } from "../lib/category-repository";
2
- import type { ConversationMessage } from "../lib/conversation";
2
+ import { NO_VIEWER, canDeleteMessage, canEditMessage, conversationPreviewLine, threadMetadataWithLastAuthor, type ConversationMessage, type ConversationViewer } from "../lib/conversation";
3
3
  import type { ConversationReferenceView, ConversationStoryView, ConversationsSource } from "./conversations-source";
4
4
 
5
5
  export type MemoryConversation = { thread: MessageThreadRecord; messages: ConversationMessage[] };
@@ -10,9 +10,23 @@ export function createMemoryConversationsSource(authorLabel: string, seed: {
10
10
  references?: Record<string, ConversationReferenceView>;
11
11
  stories?: Record<string, ConversationStoryView>;
12
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>;
13
19
  } = {}): ConversationsSource & { conversations: MemoryConversation[] } {
14
20
  const conversations = [...(seed.conversations ?? [])];
15
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
+ };
16
30
  let started = 0;
17
31
  return {
18
32
  authorLabel,
@@ -22,21 +36,44 @@ export function createMemoryConversationsSource(authorLabel: string, seed: {
22
36
  const start = nextToken ? Number(nextToken) : 0;
23
37
  return { items: all.slice(start, start + pageSize), nextToken: start + pageSize < all.length ? String(start + pageSize) : null };
24
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
+ },
25
55
  loadMessages: async (threadId) => [...(conversations.find((entry) => entry.thread.id === threadId)?.messages ?? [])],
26
56
  post: async ({ thread, body, parentMessageId }) => {
27
57
  const entry = conversations.find((candidate) => candidate.thread.id === thread.id);
28
58
  if (!entry) throw new Error("That conversation is not here.");
29
59
  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 };
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 };
31
61
  entry.messages.push(message);
32
- entry.thread = { ...entry.thread, messageCount: entry.messages.length, lastMessageAt: message.createdAt, updatedAt: message.createdAt };
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) };
33
63
  return message;
34
64
  },
35
- start: async (body) => {
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) => {
36
73
  started += 1;
37
74
  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 };
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 };
40
77
  conversations.push({ thread, messages: [message] });
41
78
  return { thread, message };
42
79
  },
@@ -1,29 +1,41 @@
1
1
  import { referencePreviewsByLineage } from "../lib/reference-preview";
2
2
  import {
3
3
  appendConversationMessage,
4
+ deleteConversationMessage,
5
+ editConversationMessage,
6
+ loadConversationThread,
4
7
  loadConversationThreadMessages,
8
+ loadConversationViewer,
9
+ loadUserProfileNames,
5
10
  loadConversationThreadPage,
6
11
  loadReferenceAttachmentsForLineageId,
7
12
  loadReferenceRecordByLineageId,
8
13
  loadStoryHeaders,
14
+ renameConversationThread,
9
15
  startUnattachedConversation,
10
16
  } from "./news-desk-taxonomy-client";
11
17
  import type { ConversationReferenceView, ConversationsSource } from "./conversations-source";
12
18
 
13
19
  const PAGE_SIZE = 20;
14
20
 
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. */
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. */
16
22
  export function createNetworkConversationsSource(authorLabel: string): ConversationsSource {
17
23
  return {
18
24
  authorLabel,
19
25
  loadThreads: (threadKind, nextToken) => loadConversationThreadPage(threadKind, nextToken, PAGE_SIZE),
20
26
  loadMessages: loadConversationThreadMessages,
27
+ loadViewer: loadConversationViewer,
28
+ loadAuthors: loadUserProfileNames,
29
+ edit: editConversationMessage,
30
+ remove: (messageId, moderate) => deleteConversationMessage(messageId, moderate),
21
31
  post: async ({ thread, body, parentMessageId }) => {
22
32
  const anchor = thread.primaryAnchorLineageId && (thread.primaryAnchorKind === "reference" || thread.primaryAnchorKind === "item")
23
33
  ? { kind: thread.primaryAnchorKind, lineageId: thread.primaryAnchorLineageId, title: thread.title } as const
24
34
  : { kind: "none" } as const;
25
35
  return (await appendConversationMessage({ anchor, body, parentMessageId, thread })).message;
26
36
  },
37
+ loadThread: loadConversationThread,
38
+ rename: renameConversationThread,
27
39
  start: startUnattachedConversation,
28
40
  loadReferences: async (lineageIds) => {
29
41
  const views = new Map<string, ConversationReferenceView>();
@@ -1,5 +1,5 @@
1
1
  import type { MessageThreadRecord, ReferenceRecord } from "../lib/category-repository";
2
- import type { ConversationMessage } from "../lib/conversation";
2
+ import type { ConversationMessage, ConversationViewer } from "../lib/conversation";
3
3
  import type { PagedThreads } from "../lib/conversation-feed";
4
4
  import type { ReferencePreview } from "../lib/reference-preview";
5
5
 
@@ -16,9 +16,21 @@ export type ConversationsSource = {
16
16
  /** One page of the conversation threads of one kind, newest activity first. */
17
17
  loadThreads: (threadKind: string, nextToken: string | null) => Promise<PagedThreads<MessageThreadRecord>>;
18
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>;
19
27
  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 }>;
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 }>;
22
34
  loadReferences: (lineageIds: string[]) => Promise<Map<string, ConversationReferenceView>>;
23
35
  loadStories: (lineageIds: string[]) => Promise<Map<string, ConversationStoryView>>;
24
36
  };