@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.
- 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 +8 -5
- package/amplify/data/resource.ts +8 -5
- package/amplify/data/schema.js +14 -7
- package/amplify/data/schema.ts +14 -7
- package/components/conversation-context.tsx +29 -2
- package/components/conversation-provider.tsx +5 -1
- package/components/conversation-thread.tsx +133 -19
- package/components/conversations-memory-source.ts +43 -6
- package/components/conversations-network-source.ts +13 -1
- package/components/conversations-source.ts +15 -3
- package/components/conversations-view.tsx +437 -76
- package/components/list-detail-layout.tsx +44 -0
- package/components/needs-me-bodies.tsx +14 -14
- package/components/needs-me-feed.tsx +8 -18
- package/components/news-desk-taxonomy-client.ts +142 -57
- package/components/newsroom-article-editor.tsx +10 -4
- package/components/newsroom-organizer.css +10 -1
- package/components/newsroom-references-view.tsx +41 -100
- package/components/notification-cards.tsx +4 -4
- 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 +69 -85
- package/components/reference-review-card.tsx +1 -1
- package/components/references-page-loading.tsx +12 -6
- package/components/topic-steering-workspace.tsx +4 -4
- package/components/use-conversation.ts +36 -2
- package/components/use-feed-preferences.ts +33 -0
- package/lib/category-repository.ts +7 -38
- package/lib/console-chat-client.ts +8 -178
- package/lib/conversation.ts +73 -2
- 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-ranking.ts +77 -160
- 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/package.json +4 -1
- package/routes.manifest.json +1 -1
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { BookmarkIcon, ExternalLinkIcon, InboxIcon, Loader2Icon, MessageCircleIcon, MessageSquareTextIcon, MessagesSquareIcon, NewspaperIcon, TriangleAlertIcon } from "lucide-react";
|
|
3
|
+
import { BookmarkIcon, CheckIcon, ChevronRightIcon, ExternalLinkIcon, InboxIcon, LayoutGridIcon, ListIcon, Loader2Icon, MessageCircleIcon, MessageSquareIcon, MessageSquareTextIcon, MessagesSquareIcon, NewspaperIcon, PencilIcon, SquarePenIcon, TriangleAlertIcon, XIcon } from "lucide-react";
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
5
|
import type { MessageThreadRecord } from "../lib/category-repository";
|
|
6
|
-
import { conversationAge,
|
|
6
|
+
import { NO_VIEWER, canRenameThread, conversationAge, conversationAuthorName, conversationPreviewLine, threadLastAuthorLabel, threadMetadataWithLastAuthor, type ConversationMessage, type ConversationViewer } from "../lib/conversation";
|
|
7
|
+
import { referenceDisplayTitle } from "../lib/newsroom-references";
|
|
8
|
+
import { referenceSourceHost } from "../lib/reference-preview";
|
|
7
9
|
import { CONVERSATION_SECTIONS, SECTION_EMPTY_COPY, createMergedThreadStream, sectionOfThreadKind, threadKindsForSection, type ConversationSection } from "../lib/conversation-feed";
|
|
8
10
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
9
11
|
import { cn } from "../lib/utils";
|
|
10
12
|
import { Composer, ConversationThread } from "./conversation-thread";
|
|
13
|
+
import { ListDetailLayout } from "./list-detail-layout";
|
|
11
14
|
import type { ConversationReferenceView, ConversationStoryView, ConversationsSource } from "./conversations-source";
|
|
12
|
-
import { FEED_GRID, FEED_SURFACE_PROPS, PILL, ReferenceCardHeader } from "./reference-feed";
|
|
15
|
+
import { FEED_GRID, FEED_SURFACE_PROPS, PILL, ReferenceCardHeader, SELECTED_RING } from "./reference-feed";
|
|
13
16
|
|
|
14
17
|
const PAGE_SIZE = 12;
|
|
15
|
-
|
|
16
|
-
const
|
|
18
|
+
const LAYOUT_STORAGE_KEY = "papyrus:messages-layout";
|
|
19
|
+
const THREAD_PARAM = "thread";
|
|
20
|
+
/** `?thread=new`: the detail view holds the New thread form. */
|
|
21
|
+
const NEW_THREAD = "new";
|
|
17
22
|
const STALE_AFTER_MS = 60_000;
|
|
18
23
|
const CONSOLE_SELECT_THREAD_EVENT = "papyrus:console-select-thread";
|
|
19
24
|
const LABEL = "font-[family-name:var(--font-sans)] text-[10px] font-extrabold tracking-[0.07em] uppercase";
|
|
@@ -33,7 +38,7 @@ function kindLabel(kind: string): KindLabel {
|
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
/** 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) {
|
|
41
|
+
function useThreadMessages(source: ConversationsSource, thread: MessageThreadRecord, seed: ConversationMessage[] | undefined, reloadKey: number, viewer: ConversationViewer, onSaved?: (message: ConversationMessage) => void) {
|
|
37
42
|
const [messages, setMessages] = useState<ConversationMessage[]>(seed ?? []);
|
|
38
43
|
const [state, setState] = useState<"loading" | "ready" | "error">(seed ? "ready" : "loading");
|
|
39
44
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -61,26 +66,163 @@ function useThreadMessages(source: ConversationsSource, thread: MessageThreadRec
|
|
|
61
66
|
try {
|
|
62
67
|
const saved = await source.post({ thread, body, parentMessageId });
|
|
63
68
|
setMessages((current) => current.map((message) => (message.id === pendingId ? saved : message)));
|
|
69
|
+
onSaved?.(saved);
|
|
64
70
|
} catch (failure) {
|
|
65
71
|
setMessages((current) => current.filter((message) => message.id !== pendingId));
|
|
66
72
|
throw failure instanceof Error ? failure : new Error("Your message could not be saved.");
|
|
67
73
|
}
|
|
68
|
-
}, [source, thread]);
|
|
69
|
-
|
|
74
|
+
}, [source, thread, onSaved]);
|
|
75
|
+
// Display names come from the authors' profiles, one request for every author not yet asked about.
|
|
76
|
+
const [names, setNames] = useState<ReadonlyMap<string, string>>(() => new Map());
|
|
77
|
+
const asked = useRef(new Set<string>());
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const missing = [...new Set(messages.map((message) => message.authorUserProfileId).filter((id): id is string => Boolean(id) && !asked.current.has(id as string)))];
|
|
80
|
+
if (!missing.length) return;
|
|
81
|
+
missing.forEach((id) => asked.current.add(id));
|
|
82
|
+
let active = true;
|
|
83
|
+
source.loadAuthors(missing).then((loaded) => { if (active && loaded.size) setNames((current) => new Map([...current, ...loaded])); }).catch(() => undefined);
|
|
84
|
+
return () => { active = false; };
|
|
85
|
+
}, [messages, source]);
|
|
86
|
+
const edit = useCallback(async (messageId: string, body: string) => {
|
|
87
|
+
const saved = await source.edit(messageId, body);
|
|
88
|
+
setMessages((current) => current.map((message) => (message.id === messageId ? { ...message, ...saved } : message)));
|
|
89
|
+
}, [source]);
|
|
90
|
+
const remove = useCallback(async (messageId: string) => {
|
|
91
|
+
// An admin deleting someone else's message moderates it; everyone else deletes their own.
|
|
92
|
+
const target = messages.find((message) => message.id === messageId);
|
|
93
|
+
const moderate = viewer.isAdmin && target?.authorSub !== viewer.sub;
|
|
94
|
+
const saved = await source.remove(messageId, moderate);
|
|
95
|
+
setMessages((current) => current.map((message) => (message.id === messageId ? { ...message, ...saved } : message)));
|
|
96
|
+
}, [source, messages, viewer]);
|
|
97
|
+
return { messages, state, error, post, edit, remove, names, retry: () => setAttempt((current) => current + 1) };
|
|
70
98
|
}
|
|
71
99
|
|
|
72
|
-
|
|
73
|
-
|
|
100
|
+
/** Threads that open in the detail here. Forum threads and chats open their own views. */
|
|
101
|
+
function selectableKind(kind: string): boolean {
|
|
102
|
+
const section = sectionOfThreadKind(kind);
|
|
103
|
+
return section === "references" || section === "stories" || kind === "conversation";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const CONTROL_FIELD = "block 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";
|
|
107
|
+
const PILL_QUIET = cn(PILL, "text-muted-foreground hover:bg-[var(--feed-control)]/70 hover:text-foreground");
|
|
108
|
+
|
|
109
|
+
function relativeActivity(updatedAt: string, now: number): string {
|
|
74
110
|
const age = conversationAge(updatedAt, now);
|
|
111
|
+
return !age ? "" : age === "now" ? "just now" : `${age} ago`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** What a thread is attached to, as one small line: the Reference's source and headline, or the Story's title and stage. Nothing for an unattached thread. */
|
|
115
|
+
function AttachedLine({ lineageId, references, section, stories }: {
|
|
116
|
+
lineageId: string;
|
|
117
|
+
references: ReadonlyMap<string, ConversationReferenceView>;
|
|
118
|
+
section: ReturnType<typeof sectionOfThreadKind>;
|
|
119
|
+
stories: ReadonlyMap<string, ConversationStoryView>;
|
|
120
|
+
}) {
|
|
121
|
+
if (section === "references") {
|
|
122
|
+
const view = references.get(lineageId);
|
|
123
|
+
if (!view) return null;
|
|
124
|
+
const site = view.preview?.siteName ?? referenceSourceHost(view.reference);
|
|
125
|
+
return (
|
|
126
|
+
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground" data-conversation-attached="reference">
|
|
127
|
+
<BookmarkIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
128
|
+
<span className="truncate">{site ? `${site} · ` : ""}{referenceDisplayTitle(view.reference)}</span>
|
|
129
|
+
</span>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (section === "stories") {
|
|
133
|
+
const story = stories.get(lineageId);
|
|
134
|
+
if (!story) return null;
|
|
135
|
+
return (
|
|
136
|
+
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground" data-conversation-attached="story">
|
|
137
|
+
<NewspaperIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
138
|
+
<span className="truncate">{story.title}{story.stage ? <> · <span data-conversation-stage>Stage: {story.stage}</span></> : null}</span>
|
|
139
|
+
</span>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One conversation as a card (grid) or a line (list). The whole item is one control: a thread opens in the detail, a forum thread
|
|
147
|
+
* opens its own view, a chat opens in the console. Nothing here reads messages: the title, the attachment, the count and the last
|
|
148
|
+
* activity all come from the thread record and the batched headers.
|
|
149
|
+
*/
|
|
150
|
+
function ThreadItem({ demo, mode, now, onNavigate, onSelect, references, selected, stories, thread }: {
|
|
151
|
+
demo: boolean;
|
|
152
|
+
mode: "grid" | "list";
|
|
153
|
+
now: number;
|
|
154
|
+
onNavigate?: (href: string) => void;
|
|
155
|
+
onSelect: (threadId: string) => void;
|
|
156
|
+
references: ReadonlyMap<string, ConversationReferenceView>;
|
|
157
|
+
selected: boolean;
|
|
158
|
+
stories: ReadonlyMap<string, ConversationStoryView>;
|
|
159
|
+
thread: MessageThreadRecord;
|
|
160
|
+
}) {
|
|
161
|
+
const section = sectionOfThreadKind(thread.threadKind);
|
|
162
|
+
const { icon: KindIcon, text: kindText } = kindLabel(thread.threadKind);
|
|
163
|
+
const lineageId = thread.primaryAnchorLineageId ?? "";
|
|
164
|
+
const author = threadLastAuthorLabel(thread.metadata);
|
|
165
|
+
const when = relativeActivity(thread.lastMessageAt ?? thread.updatedAt, now);
|
|
166
|
+
const activity = [author ? conversationAuthorName(author) : null, when].filter(Boolean).join(" · ");
|
|
167
|
+
const preview = (thread.summary ?? "").trim();
|
|
168
|
+
const count = thread.messageCount ?? 0;
|
|
169
|
+
const control = "rounded-[var(--feed-radius-lg,10px)] text-left outline-none after:absolute after:inset-0 after:rounded-[inherit] focus-visible:after:ring-[3px] focus-visible:after:ring-ring";
|
|
170
|
+
const opensElsewhere = !selectableKind(thread.threadKind);
|
|
171
|
+
const titleControl = (() => {
|
|
172
|
+
if (section === "chats") {
|
|
173
|
+
return <button className={control} data-conversation-open onClick={() => window.dispatchEvent(new CustomEvent(CONSOLE_SELECT_THREAD_EVENT, { detail: { threadId: thread.id } }))} type="button">{thread.title}</button>;
|
|
174
|
+
}
|
|
175
|
+
if (opensElsewhere) {
|
|
176
|
+
const href = getNewsroomNavHref(`/newsroom/forum/${encodeURIComponent(thread.id)}`, demo);
|
|
177
|
+
return <a className={control} data-conversation-open href={href} onClick={(event) => { if (onNavigate && !event.metaKey && !event.ctrlKey && event.button === 0) { event.preventDefault(); onNavigate(href); } }}>{thread.title}</a>;
|
|
178
|
+
}
|
|
179
|
+
return <button aria-current={selected ? "true" : undefined} className={control} data-conversation-open onClick={() => onSelect(thread.id)} type="button">{thread.title}</button>;
|
|
180
|
+
})();
|
|
181
|
+
const tone = "bg-[var(--feed-card)] hover:bg-[var(--feed-control)]/60";
|
|
182
|
+
// The open thread wears the ring around its outside, as a selected References card does: never a mark on one edge.
|
|
183
|
+
const ring = selected ? SELECTED_RING : undefined;
|
|
184
|
+
const countBadge = (
|
|
185
|
+
<span aria-label={`${count} ${count === 1 ? "message" : "messages"}`} className="inline-flex shrink-0 items-center gap-1 text-sm text-muted-foreground" data-conversation-count>
|
|
186
|
+
<MessageSquareIcon aria-hidden="true" className="size-4" />
|
|
187
|
+
{count}
|
|
188
|
+
</span>
|
|
189
|
+
);
|
|
190
|
+
const marker = selected ? (
|
|
191
|
+
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold text-foreground" data-conversation-selected><ChevronRightIcon aria-hidden="true" className="size-3.5" />Open</span>
|
|
192
|
+
) : opensElsewhere ? (
|
|
193
|
+
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground"><ExternalLinkIcon aria-hidden="true" className="size-3.5" />{section === "chats" ? "Open chat" : "Open thread"}</span>
|
|
194
|
+
) : null;
|
|
195
|
+
|
|
196
|
+
if (mode === "list") {
|
|
197
|
+
return (
|
|
198
|
+
<article className={cn("relative flex min-w-0 items-center gap-3 rounded-[var(--feed-radius-lg,10px)] px-3 py-2.5 transition-colors", tone)} data-conversation-card={thread.id} data-selected={selected || undefined} style={ring} data-conversation-kind={thread.threadKind} data-conversation-mode="list">
|
|
199
|
+
<KindIcon aria-label={kindText} className="size-4 shrink-0 text-muted-foreground" role="img" />
|
|
200
|
+
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold" data-conversation-title>{titleControl}</h3>
|
|
201
|
+
<span className="hidden min-w-0 max-w-[40%] shrink @[44rem]:flex"><AttachedLine lineageId={lineageId} references={references} section={section} stories={stories} /></span>
|
|
202
|
+
{countBadge}
|
|
203
|
+
{activity ? <span className="hidden shrink-0 truncate text-xs text-muted-foreground @[30rem]:inline" data-conversation-activity>{activity}</span> : null}
|
|
204
|
+
{marker}
|
|
205
|
+
</article>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
75
208
|
return (
|
|
76
|
-
<
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
209
|
+
<article className={cn("relative flex flex-col gap-2 rounded-[20px] p-4 transition-colors", tone)} data-conversation-card={thread.id} data-selected={selected || undefined} style={ring} data-conversation-kind={thread.threadKind} data-conversation-mode="grid">
|
|
210
|
+
<p className={cn(LABEL, "flex items-center justify-between gap-2 text-muted-foreground")} data-conversation-caption>
|
|
211
|
+
<span className="inline-flex items-center gap-1.5"><KindIcon aria-hidden="true" className="size-3.5" />{kindText}</span>
|
|
212
|
+
{marker}
|
|
213
|
+
</p>
|
|
214
|
+
<h3 className="text-base font-semibold leading-snug" data-conversation-title>{titleControl}</h3>
|
|
215
|
+
<AttachedLine lineageId={lineageId} references={references} section={section} stories={stories} />
|
|
216
|
+
{preview ? <p className="line-clamp-2 text-sm text-muted-foreground" data-conversation-preview>{preview}</p> : null}
|
|
217
|
+
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
218
|
+
{countBadge}
|
|
219
|
+
{activity ? <span className="min-w-0 truncate text-xs text-muted-foreground" data-conversation-activity>{activity}</span> : null}
|
|
220
|
+
</p>
|
|
221
|
+
</article>
|
|
80
222
|
);
|
|
81
223
|
}
|
|
82
224
|
|
|
83
|
-
/** The Reference a conversation is about,
|
|
225
|
+
/** The Reference a conversation is about, under its title: the standard header, no decision controls, and a link to the Reference. */
|
|
84
226
|
function ReferenceAttachment({ href, now, onNavigate, thread, view }: { href: string; now: number; onNavigate?: (href: string) => void; thread: MessageThreadRecord; view: ConversationReferenceView | undefined }) {
|
|
85
227
|
const open = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
86
228
|
if (!onNavigate || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
|
|
@@ -89,62 +231,111 @@ function ReferenceAttachment({ href, now, onNavigate, thread, view }: { href: st
|
|
|
89
231
|
};
|
|
90
232
|
return (
|
|
91
233
|
<a
|
|
92
|
-
aria-label={`Open the Reference: ${view
|
|
234
|
+
aria-label={`Open the Reference: ${view ? referenceDisplayTitle(view.reference) : thread.title}`}
|
|
93
235
|
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
236
|
data-conversation-attachment="reference"
|
|
95
237
|
href={href}
|
|
96
238
|
onClick={open}
|
|
97
239
|
>
|
|
98
240
|
{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" />
|
|
241
|
+
<span className="flex items-center gap-2 p-4 text-sm text-muted-foreground"><BookmarkIcon aria-hidden="true" className="size-4" />Open the Reference<ExternalLinkIcon aria-hidden="true" className="size-4" /></span>
|
|
100
242
|
)}
|
|
101
243
|
</a>
|
|
102
244
|
);
|
|
103
245
|
}
|
|
104
246
|
|
|
105
|
-
|
|
247
|
+
/** The conversation's title, with a Rename button that opens an input in place. A refused save says why and keeps what was typed. */
|
|
248
|
+
function ThreadTitle({ canRename, onRename, thread }: { canRename: boolean; onRename: (title: string) => Promise<void>; thread: MessageThreadRecord }) {
|
|
249
|
+
const [editing, setEditing] = useState(false);
|
|
250
|
+
const [draft, setDraft] = useState(thread.title);
|
|
251
|
+
const [saving, setSaving] = useState(false);
|
|
252
|
+
const [error, setError] = useState<string | null>(null);
|
|
253
|
+
|
|
254
|
+
const save = async () => {
|
|
255
|
+
const next = draft.trim();
|
|
256
|
+
if (!next) { setError("A title cannot be empty."); return; }
|
|
257
|
+
if (next === thread.title) { setEditing(false); return; }
|
|
258
|
+
setSaving(true);
|
|
259
|
+
setError(null);
|
|
260
|
+
try {
|
|
261
|
+
await onRename(next);
|
|
262
|
+
setEditing(false);
|
|
263
|
+
} catch (failure) {
|
|
264
|
+
setError(failure instanceof Error ? failure.message : "The title could not be saved.");
|
|
265
|
+
} finally {
|
|
266
|
+
setSaving(false);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
const cancel = () => { setEditing(false); setError(null); setDraft(thread.title); };
|
|
270
|
+
|
|
271
|
+
if (!editing) {
|
|
272
|
+
return (
|
|
273
|
+
<div className="flex items-start justify-between gap-2" data-conversation-heading>
|
|
274
|
+
<h2 className="min-w-0 break-words text-xl font-bold leading-snug tracking-tight" data-conversation-detail-title>{thread.title}</h2>
|
|
275
|
+
{canRename ? (
|
|
276
|
+
<button className={cn(PILL_QUIET, "shrink-0")} data-conversation-rename onClick={() => { setDraft(thread.title); setEditing(true); }} type="button">
|
|
277
|
+
<PencilIcon aria-hidden="true" className="size-4" />Rename
|
|
278
|
+
</button>
|
|
279
|
+
) : null}
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return (
|
|
284
|
+
<div className="space-y-2" data-conversation-heading>
|
|
285
|
+
<input
|
|
286
|
+
aria-label="Conversation title"
|
|
287
|
+
autoFocus
|
|
288
|
+
className={CONTROL_FIELD}
|
|
289
|
+
data-conversation-rename-input
|
|
290
|
+
maxLength={120}
|
|
291
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
292
|
+
onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void save(); } else if (event.key === "Escape") cancel(); }}
|
|
293
|
+
value={draft}
|
|
294
|
+
/>
|
|
295
|
+
{error ? (
|
|
296
|
+
<p className="flex items-start gap-1.5 text-sm text-destructive" data-conversation-rename-error role="alert">
|
|
297
|
+
<TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
298
|
+
<span>Not renamed: {error} Your text is kept here.</span>
|
|
299
|
+
</p>
|
|
300
|
+
) : null}
|
|
301
|
+
<div className="flex flex-wrap gap-2">
|
|
302
|
+
<button className={cn(PILL, "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]")} data-conversation-rename-save disabled={saving || !draft.trim()} onClick={() => void save()} type="button">
|
|
303
|
+
{saving ? <Loader2Icon aria-hidden="true" className="size-4 animate-spin" /> : <CheckIcon aria-hidden="true" className="size-4" />}Save
|
|
304
|
+
</button>
|
|
305
|
+
<button className={PILL_QUIET} data-conversation-rename-cancel onClick={cancel} type="button"><XIcon aria-hidden="true" className="size-4" />Cancel</button>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** One conversation opened: its own title, what it is about, and every message with its replies. Messages are read only now. */
|
|
312
|
+
function ThreadDetail({ demo, now, onNavigate, onPosted, onRename, reloadKey, references, seed, source, stories, thread, viewer }: {
|
|
106
313
|
demo: boolean;
|
|
107
314
|
now: number;
|
|
108
315
|
onNavigate?: (href: string) => void;
|
|
316
|
+
onPosted: (thread: MessageThreadRecord, message: ConversationMessage) => void;
|
|
317
|
+
onRename: (thread: MessageThreadRecord, title: string) => Promise<void>;
|
|
109
318
|
reloadKey: number;
|
|
110
319
|
references: ReadonlyMap<string, ConversationReferenceView>;
|
|
111
320
|
seed: ConversationMessage[] | undefined;
|
|
112
321
|
source: ConversationsSource;
|
|
113
322
|
stories: ReadonlyMap<string, ConversationStoryView>;
|
|
114
323
|
thread: MessageThreadRecord;
|
|
324
|
+
viewer: ConversationViewer;
|
|
115
325
|
}) {
|
|
116
326
|
const section = sectionOfThreadKind(thread.threadKind);
|
|
117
327
|
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
328
|
const story = stories.get(lineageId);
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
329
|
+
const saved = useCallback((message: ConversationMessage) => onPosted(thread, message), [onPosted, thread]);
|
|
330
|
+
const messages = useThreadMessages(source, thread, seed, reloadKey, viewer, saved);
|
|
331
|
+
const { icon: KindIcon, text: kindText } = kindLabel(thread.threadKind);
|
|
145
332
|
return (
|
|
146
|
-
<
|
|
147
|
-
<
|
|
333
|
+
<div className="space-y-4" data-conversation-detail={thread.id}>
|
|
334
|
+
<p className={cn(LABEL, "inline-flex items-center gap-1.5 text-muted-foreground")}><KindIcon aria-hidden="true" className="size-3.5" />{kindText}</p>
|
|
335
|
+
<ThreadTitle canRename={canRenameThread(viewer, thread)} onRename={(title) => onRename(thread, title)} thread={thread} />
|
|
336
|
+
{section === "references" && lineageId ? (
|
|
337
|
+
<ReferenceAttachment href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo)} now={now} onNavigate={onNavigate} thread={thread} view={references.get(lineageId)} />
|
|
338
|
+
) : null}
|
|
148
339
|
{section === "stories" ? (
|
|
149
340
|
<div className="space-y-1 rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-footer)] p-3" data-conversation-story>
|
|
150
341
|
<p className="text-base font-semibold leading-snug">{story?.title ?? thread.title}</p>
|
|
@@ -157,26 +348,79 @@ function ConversationCard({ demo, now, onNavigate, reloadKey, references, seed,
|
|
|
157
348
|
</div>
|
|
158
349
|
) : null}
|
|
159
350
|
<ConversationThread
|
|
351
|
+
authorNames={messages.names}
|
|
160
352
|
error={messages.error}
|
|
161
353
|
heading={`Messages in ${thread.title}`}
|
|
162
354
|
hideHeading
|
|
163
|
-
latest={LATEST_MESSAGES}
|
|
164
355
|
messages={messages.messages}
|
|
165
356
|
now={now}
|
|
357
|
+
onDelete={messages.remove}
|
|
358
|
+
onEdit={messages.edit}
|
|
166
359
|
onPost={messages.post}
|
|
167
360
|
onRetry={messages.retry}
|
|
168
361
|
state={messages.state}
|
|
362
|
+
viewer={viewer}
|
|
169
363
|
/>
|
|
170
|
-
|
|
171
|
-
<ReferenceAttachment href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(lineageId)}`, demo)} now={now} onNavigate={onNavigate} thread={thread} view={references.get(lineageId)} />
|
|
172
|
-
) : null}
|
|
173
|
-
</article>
|
|
364
|
+
</div>
|
|
174
365
|
);
|
|
175
366
|
}
|
|
176
367
|
|
|
368
|
+
/** The New thread form, shown in the detail view: a required title and the first message. */
|
|
369
|
+
function NewConversation({ onCancel, onStart }: { onCancel: () => void; onStart: (title: string, body: string) => Promise<void> }) {
|
|
370
|
+
const [title, setTitle] = useState("");
|
|
371
|
+
return (
|
|
372
|
+
<section aria-label="New thread" className="space-y-3" data-conversation-new>
|
|
373
|
+
<div className="flex items-center justify-between gap-2">
|
|
374
|
+
<h2 className="text-lg font-semibold">New thread</h2>
|
|
375
|
+
<button className={cn(PILL, "bg-[var(--feed-control)]/45 text-muted-foreground hover:text-foreground")} data-conversation-new-cancel onClick={onCancel} type="button">
|
|
376
|
+
<XIcon aria-hidden="true" className="size-4" />
|
|
377
|
+
Cancel
|
|
378
|
+
</button>
|
|
379
|
+
</div>
|
|
380
|
+
<input
|
|
381
|
+
aria-label="Title"
|
|
382
|
+
className={CONTROL_FIELD}
|
|
383
|
+
data-conversation-new-title
|
|
384
|
+
maxLength={120}
|
|
385
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
386
|
+
placeholder="Title (required)"
|
|
387
|
+
required
|
|
388
|
+
value={title}
|
|
389
|
+
/>
|
|
390
|
+
<Composer
|
|
391
|
+
buttonLabel="Post"
|
|
392
|
+
icon="post"
|
|
393
|
+
label="New message"
|
|
394
|
+
onSubmit={async (body) => { await onStart(title.trim(), body); setTitle(""); }}
|
|
395
|
+
placeholder="The first message… (a thought that is not about any one Reference or Story)"
|
|
396
|
+
postBlockedReason={title.trim() ? null : "Add a title to post."}
|
|
397
|
+
/>
|
|
398
|
+
</section>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function readLayoutPreference(): "grid" | "list" {
|
|
403
|
+
try { return window.localStorage.getItem(LAYOUT_STORAGE_KEY) === "list" ? "list" : "grid"; } catch { return "grid"; }
|
|
404
|
+
}
|
|
405
|
+
function writeLayoutPreference(mode: "grid" | "list") {
|
|
406
|
+
try { window.localStorage.setItem(LAYOUT_STORAGE_KEY, mode); } catch { /* remembered for this visit only */ }
|
|
407
|
+
}
|
|
408
|
+
function readSelectedThread(): string | null {
|
|
409
|
+
try { return new URLSearchParams(window.location.search).get(THREAD_PARAM) || null; } catch { return null; }
|
|
410
|
+
}
|
|
411
|
+
function writeSelectedThread(threadId: string | null) {
|
|
412
|
+
try {
|
|
413
|
+
const url = new URL(window.location.href);
|
|
414
|
+
if (threadId) url.searchParams.set(THREAD_PARAM, threadId); else url.searchParams.delete(THREAD_PARAM);
|
|
415
|
+
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
|
|
416
|
+
} catch { /* the address stays as it is */ }
|
|
417
|
+
}
|
|
418
|
+
|
|
177
419
|
/**
|
|
178
|
-
* Every conversation, newest activity first,
|
|
179
|
-
*
|
|
420
|
+
* Every conversation, newest activity first, like a web forum: each thread is one card (grid) or one line (list) with its own title.
|
|
421
|
+
* Opening one shows its whole conversation: beside the list from 1024px, in its place (with Back) below it. The address holds the
|
|
422
|
+
* selection (`?thread=`). Sections are buttons. Reads are one page of thread records plus one batch of headers; messages are read
|
|
423
|
+
* only for the open thread.
|
|
180
424
|
*/
|
|
181
425
|
export function ConversationsView({ demo = false, onNavigate, source }: {
|
|
182
426
|
demo?: boolean;
|
|
@@ -185,6 +429,11 @@ export function ConversationsView({ demo = false, onNavigate, source }: {
|
|
|
185
429
|
source: ConversationsSource;
|
|
186
430
|
}) {
|
|
187
431
|
const [section, setSection] = useState<ConversationSection>("all");
|
|
432
|
+
const [mode, setMode] = useState<"grid" | "list">("grid");
|
|
433
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
434
|
+
const [fetched, setFetched] = useState<MessageThreadRecord | null>(null);
|
|
435
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
436
|
+
const [viewer, setViewer] = useState<ConversationViewer>(NO_VIEWER);
|
|
188
437
|
const [threads, setThreads] = useState<MessageThreadRecord[]>([]);
|
|
189
438
|
const [seeds, setSeeds] = useState<Record<string, ConversationMessage[]>>({});
|
|
190
439
|
const [loading, setLoading] = useState(true);
|
|
@@ -206,6 +455,21 @@ export function ConversationsView({ demo = false, onNavigate, source }: {
|
|
|
206
455
|
if (storyIds.length) void source.loadStories(storyIds).then((loaded) => setStories((current) => new Map([...current, ...loaded]))).catch(() => undefined);
|
|
207
456
|
}, [source]);
|
|
208
457
|
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
let active = true;
|
|
460
|
+
source.loadViewer().then((loaded) => { if (active) setViewer(loaded); }).catch(() => undefined);
|
|
461
|
+
return () => { active = false; };
|
|
462
|
+
}, [source]);
|
|
463
|
+
|
|
464
|
+
// Preferences and the address are read in the browser only, after the first render.
|
|
465
|
+
useEffect(() => {
|
|
466
|
+
setMode(readLayoutPreference());
|
|
467
|
+
setSelectedId(readSelectedThread());
|
|
468
|
+
const onPop = () => setSelectedId(readSelectedThread());
|
|
469
|
+
window.addEventListener("popstate", onPop);
|
|
470
|
+
return () => window.removeEventListener("popstate", onPop);
|
|
471
|
+
}, []);
|
|
472
|
+
|
|
209
473
|
useEffect(() => {
|
|
210
474
|
const mine = ++generation.current;
|
|
211
475
|
stream.current = createMergedThreadStream(threadKindsForSection(section), source.loadThreads);
|
|
@@ -234,6 +498,54 @@ export function ConversationsView({ demo = false, onNavigate, source }: {
|
|
|
234
498
|
return () => { window.clearInterval(timer); window.removeEventListener("focus", refocus); document.removeEventListener("visibilitychange", refocus); };
|
|
235
499
|
}, []);
|
|
236
500
|
|
|
501
|
+
// A linked thread that is not on the pages read so far is read by its id.
|
|
502
|
+
const inList = selectedId ? threads.find((thread) => thread.id === selectedId) : undefined;
|
|
503
|
+
useEffect(() => {
|
|
504
|
+
if (!selectedId || selectedId === NEW_THREAD || loading || inList || fetched?.id === selectedId) return;
|
|
505
|
+
let active = true;
|
|
506
|
+
const dropSelection = (message: string) => { setNotice(message); setSelectedId(null); writeSelectedThread(null); };
|
|
507
|
+
source.loadThread(selectedId).then((thread) => {
|
|
508
|
+
if (!active) return;
|
|
509
|
+
if (thread && selectableKind(thread.threadKind)) { setFetched(thread); enrich([thread]); return; }
|
|
510
|
+
dropSelection("That conversation could not be found.");
|
|
511
|
+
}).catch((failure) => {
|
|
512
|
+
if (active) dropSelection(`That conversation could not be opened. ${failure instanceof Error ? failure.message : ""}`.trim());
|
|
513
|
+
});
|
|
514
|
+
return () => { active = false; };
|
|
515
|
+
}, [enrich, fetched?.id, inList, loading, selectedId, source]);
|
|
516
|
+
|
|
517
|
+
const selected = selectedId ? (inList ?? (fetched?.id === selectedId ? fetched : undefined)) : undefined;
|
|
518
|
+
const openable = selected && selectableKind(selected.threadKind) ? selected : undefined;
|
|
519
|
+
|
|
520
|
+
const select = (threadId: string | null) => { setNotice(null); setSelectedId(threadId); writeSelectedThread(threadId); };
|
|
521
|
+
const chooseMode = (next: "grid" | "list") => { setMode(next); writeLayoutPreference(next); };
|
|
522
|
+
|
|
523
|
+
const patchThread = useCallback((id: string, change: (thread: MessageThreadRecord) => MessageThreadRecord, moveToTop = false) => {
|
|
524
|
+
setFetched((current) => (current?.id === id ? change(current) : current));
|
|
525
|
+
setThreads((current) => {
|
|
526
|
+
const index = current.findIndex((thread) => thread.id === id);
|
|
527
|
+
if (index < 0) return current;
|
|
528
|
+
const changed = change(current[index]);
|
|
529
|
+
return moveToTop ? [changed, ...current.filter((_, at) => at !== index)] : current.map((thread, at) => (at === index ? changed : thread));
|
|
530
|
+
});
|
|
531
|
+
}, []);
|
|
532
|
+
|
|
533
|
+
const onPosted = useCallback((thread: MessageThreadRecord, message: ConversationMessage) => {
|
|
534
|
+
patchThread(thread.id, (current) => ({
|
|
535
|
+
...current,
|
|
536
|
+
messageCount: (current.messageCount ?? 0) + 1,
|
|
537
|
+
lastMessageAt: message.createdAt,
|
|
538
|
+
updatedAt: message.createdAt,
|
|
539
|
+
summary: conversationPreviewLine(message.content),
|
|
540
|
+
metadata: threadMetadataWithLastAuthor(current.metadata, source.authorLabel),
|
|
541
|
+
}), true);
|
|
542
|
+
}, [patchThread, source.authorLabel]);
|
|
543
|
+
|
|
544
|
+
const rename = async (thread: MessageThreadRecord, title: string) => {
|
|
545
|
+
const saved = await source.rename(thread, title);
|
|
546
|
+
patchThread(thread.id, (current) => ({ ...current, title: saved.title }));
|
|
547
|
+
};
|
|
548
|
+
|
|
237
549
|
const loadMore = async () => {
|
|
238
550
|
if (!stream.current || loadingMore) return;
|
|
239
551
|
const mine = generation.current;
|
|
@@ -251,42 +563,74 @@ export function ConversationsView({ demo = false, onNavigate, source }: {
|
|
|
251
563
|
}
|
|
252
564
|
};
|
|
253
565
|
|
|
254
|
-
const startConversation = async (body: string) => {
|
|
255
|
-
const { thread, message } = await source.start(body);
|
|
566
|
+
const startConversation = async (title: string, body: string) => {
|
|
567
|
+
const { thread, message } = await source.start(title, body);
|
|
256
568
|
setSeeds((current) => ({ ...current, [thread.id]: [message] }));
|
|
257
569
|
setThreads((current) => [thread, ...current.filter((entry) => entry.id !== thread.id)]);
|
|
570
|
+
select(thread.id); // the new thread opens in the detail view
|
|
258
571
|
};
|
|
572
|
+
const composing = selectedId === NEW_THREAD;
|
|
259
573
|
|
|
260
574
|
const canStart = section === "all" || section === "other";
|
|
261
|
-
|
|
262
|
-
<div className="@container space-y-5" data-conversations-
|
|
263
|
-
<div
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
575
|
+
const list = (
|
|
576
|
+
<div className="@container space-y-5" data-conversations-list>
|
|
577
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
578
|
+
<div aria-label="Conversations" className="flex flex-wrap gap-1.5" data-conversation-sections role="group">
|
|
579
|
+
{CONVERSATION_SECTIONS.map(({ key, label }) => {
|
|
580
|
+
const Icon = SECTION_ICONS[key];
|
|
581
|
+
const on = section === key;
|
|
582
|
+
return (
|
|
583
|
+
<button
|
|
584
|
+
aria-pressed={on}
|
|
585
|
+
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")}
|
|
586
|
+
data-conversation-section={key}
|
|
587
|
+
key={key}
|
|
588
|
+
onClick={() => setSection(key)}
|
|
589
|
+
type="button"
|
|
590
|
+
>
|
|
591
|
+
<Icon aria-hidden="true" className="size-4" />
|
|
592
|
+
{label}
|
|
593
|
+
</button>
|
|
594
|
+
);
|
|
595
|
+
})}
|
|
596
|
+
</div>
|
|
597
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
598
|
+
{canStart ? (
|
|
599
|
+
<button
|
|
600
|
+
aria-pressed={composing}
|
|
601
|
+
className={cn(PILL, composing ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70")}
|
|
602
|
+
data-conversation-new-open
|
|
603
|
+
onClick={() => select(NEW_THREAD)}
|
|
604
|
+
type="button"
|
|
605
|
+
>
|
|
606
|
+
<SquarePenIcon aria-hidden="true" className="size-4" />
|
|
607
|
+
New thread
|
|
608
|
+
</button>
|
|
609
|
+
) : null}
|
|
610
|
+
<div aria-label="Layout" className="flex gap-1.5" data-conversation-layouts role="group">
|
|
611
|
+
{([["grid", "Grid", LayoutGridIcon], ["list", "List", ListIcon]] as const).map(([key, label, Icon]) => (
|
|
268
612
|
<button
|
|
269
|
-
aria-pressed={
|
|
270
|
-
className={cn(PILL,
|
|
271
|
-
data-conversation-
|
|
613
|
+
aria-pressed={mode === key}
|
|
614
|
+
className={cn(PILL, mode === key ? "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")}
|
|
615
|
+
data-conversation-layout={key}
|
|
272
616
|
key={key}
|
|
273
|
-
onClick={() =>
|
|
617
|
+
onClick={() => chooseMode(key)}
|
|
274
618
|
type="button"
|
|
275
619
|
>
|
|
276
620
|
<Icon aria-hidden="true" className="size-4" />
|
|
277
621
|
{label}
|
|
278
622
|
</button>
|
|
279
|
-
)
|
|
280
|
-
|
|
623
|
+
))}
|
|
624
|
+
</div>
|
|
625
|
+
</div>
|
|
281
626
|
</div>
|
|
282
627
|
|
|
283
|
-
{
|
|
284
|
-
<
|
|
285
|
-
<
|
|
286
|
-
<
|
|
287
|
-
</
|
|
628
|
+
{notice ? (
|
|
629
|
+
<p className="flex items-start gap-1.5 text-sm text-muted-foreground" data-conversations-notice role="status">
|
|
630
|
+
<TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
631
|
+
<span>{notice}</span>
|
|
632
|
+
</p>
|
|
288
633
|
) : null}
|
|
289
|
-
|
|
290
634
|
{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
635
|
{error ? (
|
|
292
636
|
<p className="flex items-start gap-1.5 text-sm text-destructive" data-conversations-error role="alert">
|
|
@@ -302,9 +646,10 @@ export function ConversationsView({ demo = false, onNavigate, source }: {
|
|
|
302
646
|
) : null}
|
|
303
647
|
|
|
304
648
|
{threads.length ? (
|
|
305
|
-
|
|
649
|
+
// p-1 leaves room for the selection ring, which sits 4px outside a card and would be cut off at the column's edge.
|
|
650
|
+
<div className={cn(mode === "grid" ? FEED_GRID : "flex flex-col gap-2", "p-1")} data-conversation-grid={mode === "grid" ? "" : undefined} data-conversation-lines={mode === "list" ? "" : undefined}>
|
|
306
651
|
{threads.map((thread) => (
|
|
307
|
-
<
|
|
652
|
+
<ThreadItem demo={demo} key={thread.id} mode={mode} now={now} onNavigate={onNavigate} onSelect={select} references={references} selected={thread.id === openable?.id} stories={stories} thread={thread} />
|
|
308
653
|
))}
|
|
309
654
|
</div>
|
|
310
655
|
) : null}
|
|
@@ -316,4 +661,20 @@ export function ConversationsView({ demo = false, onNavigate, source }: {
|
|
|
316
661
|
) : null}
|
|
317
662
|
</div>
|
|
318
663
|
);
|
|
664
|
+
|
|
665
|
+
return (
|
|
666
|
+
<div data-conversations-view data-conversations-mode={mode} {...FEED_SURFACE_PROPS}>
|
|
667
|
+
<ListDetailLayout
|
|
668
|
+
backLabel="Messages"
|
|
669
|
+
detail={composing ? (
|
|
670
|
+
<NewConversation onCancel={() => select(null)} onStart={startConversation} />
|
|
671
|
+
) : openable ? (
|
|
672
|
+
<ThreadDetail demo={demo} key={openable.id} now={now} onNavigate={onNavigate} onPosted={onPosted} onRename={rename} reloadKey={reloadKey} references={references} seed={seeds[openable.id]} source={source} stories={stories} thread={openable} viewer={viewer} />
|
|
673
|
+
) : null}
|
|
674
|
+
list={list}
|
|
675
|
+
onBack={() => select(null)}
|
|
676
|
+
open={composing || Boolean(openable)}
|
|
677
|
+
/>
|
|
678
|
+
</div>
|
|
679
|
+
);
|
|
319
680
|
}
|