@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
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { BookmarkIcon, CheckIcon, ChevronRightIcon, ExternalLinkIcon, InboxIcon, LayoutGridIcon, ListIcon, Loader2Icon, MessageCircleIcon, MessageSquareIcon, MessageSquareTextIcon, MessagesSquareIcon, NewspaperIcon, PencilIcon, SquarePenIcon, TriangleAlertIcon, XIcon } from "lucide-react";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import type { MessageThreadRecord } from "../lib/category-repository";
|
|
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";
|
|
9
|
+
import { CONVERSATION_SECTIONS, SECTION_EMPTY_COPY, createMergedThreadStream, sectionOfThreadKind, threadKindsForSection, type ConversationSection } from "../lib/conversation-feed";
|
|
10
|
+
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
11
|
+
import { cn } from "../lib/utils";
|
|
12
|
+
import { Composer, ConversationThread } from "./conversation-thread";
|
|
13
|
+
import { ListDetailLayout } from "./list-detail-layout";
|
|
14
|
+
import type { ConversationReferenceView, ConversationStoryView, ConversationsSource } from "./conversations-source";
|
|
15
|
+
import { FEED_GRID, FEED_SURFACE_PROPS, PILL, ReferenceCardHeader, SELECTED_RING } from "./reference-feed";
|
|
16
|
+
|
|
17
|
+
const PAGE_SIZE = 12;
|
|
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";
|
|
22
|
+
const STALE_AFTER_MS = 60_000;
|
|
23
|
+
const CONSOLE_SELECT_THREAD_EVENT = "papyrus:console-select-thread";
|
|
24
|
+
const LABEL = "font-[family-name:var(--font-sans)] text-[10px] font-extrabold tracking-[0.07em] uppercase";
|
|
25
|
+
|
|
26
|
+
const SECTION_ICONS = { all: InboxIcon, references: BookmarkIcon, stories: NewspaperIcon, chats: MessageCircleIcon, other: MessagesSquareIcon } as const;
|
|
27
|
+
|
|
28
|
+
type KindLabel = { icon: typeof BookmarkIcon; text: string };
|
|
29
|
+
function kindLabel(kind: string): KindLabel {
|
|
30
|
+
switch (sectionOfThreadKind(kind)) {
|
|
31
|
+
case "references": return { icon: BookmarkIcon, text: "Reference" };
|
|
32
|
+
case "stories": return { icon: NewspaperIcon, text: "Story" };
|
|
33
|
+
case "chats": return { icon: MessageCircleIcon, text: "Chat" };
|
|
34
|
+
default: return kind === "edition_forum" || kind === "section_forum"
|
|
35
|
+
? { icon: MessagesSquareIcon, text: kind === "edition_forum" ? "Edition forum" : "Section forum" }
|
|
36
|
+
: { icon: MessageSquareTextIcon, text: "Message" };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One conversation's Messages: loaded once, shown at once when posted, taken back when the save fails. */
|
|
41
|
+
function useThreadMessages(source: ConversationsSource, thread: MessageThreadRecord, seed: ConversationMessage[] | undefined, reloadKey: number, viewer: ConversationViewer, onSaved?: (message: ConversationMessage) => void) {
|
|
42
|
+
const [messages, setMessages] = useState<ConversationMessage[]>(seed ?? []);
|
|
43
|
+
const [state, setState] = useState<"loading" | "ready" | "error">(seed ? "ready" : "loading");
|
|
44
|
+
const [error, setError] = useState<string | null>(null);
|
|
45
|
+
const [attempt, setAttempt] = useState(0);
|
|
46
|
+
const counter = useRef(0);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
let active = true;
|
|
49
|
+
source.loadMessages(thread.id).then((loaded) => {
|
|
50
|
+
if (!active) return;
|
|
51
|
+
setMessages((current) => [...loaded, ...current.filter((message) => message.pending)]);
|
|
52
|
+
setState("ready");
|
|
53
|
+
setError(null);
|
|
54
|
+
}).catch((failure) => {
|
|
55
|
+
if (!active) return;
|
|
56
|
+
setError(failure instanceof Error ? failure.message : "The conversation could not be loaded.");
|
|
57
|
+
setState("error");
|
|
58
|
+
});
|
|
59
|
+
return () => { active = false; };
|
|
60
|
+
}, [source, thread.id, reloadKey, attempt]);
|
|
61
|
+
const post = useCallback(async (body: string, parentMessageId: string | null) => {
|
|
62
|
+
counter.current += 1;
|
|
63
|
+
const pendingId = `pending-${counter.current}`;
|
|
64
|
+
const pending: ConversationMessage = { id: pendingId, parentMessageId, authorLabel: source.authorLabel, content: body.trim(), createdAt: new Date().toISOString(), sequenceNumber: Number.MAX_SAFE_INTEGER - 1000 + counter.current, pending: true };
|
|
65
|
+
setMessages((current) => [...current, pending]);
|
|
66
|
+
try {
|
|
67
|
+
const saved = await source.post({ thread, body, parentMessageId });
|
|
68
|
+
setMessages((current) => current.map((message) => (message.id === pendingId ? saved : message)));
|
|
69
|
+
onSaved?.(saved);
|
|
70
|
+
} catch (failure) {
|
|
71
|
+
setMessages((current) => current.filter((message) => message.id !== pendingId));
|
|
72
|
+
throw failure instanceof Error ? failure : new Error("Your message could not be saved.");
|
|
73
|
+
}
|
|
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) };
|
|
98
|
+
}
|
|
99
|
+
|
|
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 {
|
|
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
|
+
}
|
|
208
|
+
return (
|
|
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>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The Reference a conversation is about, under its title: the standard header, no decision controls, and a link to the Reference. */
|
|
226
|
+
function ReferenceAttachment({ href, now, onNavigate, thread, view }: { href: string; now: number; onNavigate?: (href: string) => void; thread: MessageThreadRecord; view: ConversationReferenceView | undefined }) {
|
|
227
|
+
const open = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
228
|
+
if (!onNavigate || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
onNavigate(href);
|
|
231
|
+
};
|
|
232
|
+
return (
|
|
233
|
+
<a
|
|
234
|
+
aria-label={`Open the Reference: ${view ? referenceDisplayTitle(view.reference) : thread.title}`}
|
|
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"
|
|
236
|
+
data-conversation-attachment="reference"
|
|
237
|
+
href={href}
|
|
238
|
+
onClick={open}
|
|
239
|
+
>
|
|
240
|
+
{view ? <ReferenceCardHeader now={now} preview={view.preview} reference={view.reference} /> : (
|
|
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>
|
|
242
|
+
)}
|
|
243
|
+
</a>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
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 }: {
|
|
313
|
+
demo: boolean;
|
|
314
|
+
now: number;
|
|
315
|
+
onNavigate?: (href: string) => void;
|
|
316
|
+
onPosted: (thread: MessageThreadRecord, message: ConversationMessage) => void;
|
|
317
|
+
onRename: (thread: MessageThreadRecord, title: string) => Promise<void>;
|
|
318
|
+
reloadKey: number;
|
|
319
|
+
references: ReadonlyMap<string, ConversationReferenceView>;
|
|
320
|
+
seed: ConversationMessage[] | undefined;
|
|
321
|
+
source: ConversationsSource;
|
|
322
|
+
stories: ReadonlyMap<string, ConversationStoryView>;
|
|
323
|
+
thread: MessageThreadRecord;
|
|
324
|
+
viewer: ConversationViewer;
|
|
325
|
+
}) {
|
|
326
|
+
const section = sectionOfThreadKind(thread.threadKind);
|
|
327
|
+
const lineageId = thread.primaryAnchorLineageId ?? "";
|
|
328
|
+
const story = stories.get(lineageId);
|
|
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);
|
|
332
|
+
return (
|
|
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}
|
|
339
|
+
{section === "stories" ? (
|
|
340
|
+
<div className="space-y-1 rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-footer)] p-3" data-conversation-story>
|
|
341
|
+
<p className="text-base font-semibold leading-snug">{story?.title ?? thread.title}</p>
|
|
342
|
+
<p className="flex flex-wrap items-center gap-x-3 text-sm text-muted-foreground">
|
|
343
|
+
{story?.stage ? <span data-conversation-stage>Stage: {story.stage}</span> : null}
|
|
344
|
+
<a className="inline-flex items-center gap-1 underline-offset-2 hover:underline" href={getNewsroomNavHref(`/newsroom/articles/${encodeURIComponent(lineageId)}`, demo)}>
|
|
345
|
+
<ExternalLinkIcon aria-hidden="true" className="size-3.5" />Open the Story
|
|
346
|
+
</a>
|
|
347
|
+
</p>
|
|
348
|
+
</div>
|
|
349
|
+
) : null}
|
|
350
|
+
<ConversationThread
|
|
351
|
+
authorNames={messages.names}
|
|
352
|
+
error={messages.error}
|
|
353
|
+
heading={`Messages in ${thread.title}`}
|
|
354
|
+
hideHeading
|
|
355
|
+
messages={messages.messages}
|
|
356
|
+
now={now}
|
|
357
|
+
onDelete={messages.remove}
|
|
358
|
+
onEdit={messages.edit}
|
|
359
|
+
onPost={messages.post}
|
|
360
|
+
onRetry={messages.retry}
|
|
361
|
+
state={messages.state}
|
|
362
|
+
viewer={viewer}
|
|
363
|
+
/>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
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
|
+
|
|
419
|
+
/**
|
|
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.
|
|
424
|
+
*/
|
|
425
|
+
export function ConversationsView({ demo = false, onNavigate, source }: {
|
|
426
|
+
demo?: boolean;
|
|
427
|
+
/** Open a link inside the app without a page load. Without it links are ordinary links. */
|
|
428
|
+
onNavigate?: (href: string) => void;
|
|
429
|
+
source: ConversationsSource;
|
|
430
|
+
}) {
|
|
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);
|
|
437
|
+
const [threads, setThreads] = useState<MessageThreadRecord[]>([]);
|
|
438
|
+
const [seeds, setSeeds] = useState<Record<string, ConversationMessage[]>>({});
|
|
439
|
+
const [loading, setLoading] = useState(true);
|
|
440
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
441
|
+
const [error, setError] = useState<string | null>(null);
|
|
442
|
+
const [hasMore, setHasMore] = useState(false);
|
|
443
|
+
const [references, setReferences] = useState<ReadonlyMap<string, ConversationReferenceView>>(() => new Map());
|
|
444
|
+
const [stories, setStories] = useState<ReadonlyMap<string, ConversationStoryView>>(() => new Map());
|
|
445
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
446
|
+
const [now, setNow] = useState(() => Date.now());
|
|
447
|
+
const loadedAt = useRef(Date.now());
|
|
448
|
+
const stream = useRef<ReturnType<typeof createMergedThreadStream<MessageThreadRecord>> | null>(null);
|
|
449
|
+
const generation = useRef(0);
|
|
450
|
+
|
|
451
|
+
const enrich = useCallback((page: MessageThreadRecord[]) => {
|
|
452
|
+
const referenceIds = page.filter((thread) => thread.threadKind === "reference_conversation" && thread.primaryAnchorLineageId).map((thread) => thread.primaryAnchorLineageId!);
|
|
453
|
+
const storyIds = page.filter((thread) => thread.threadKind === "story_conversation" && thread.primaryAnchorLineageId).map((thread) => thread.primaryAnchorLineageId!);
|
|
454
|
+
if (referenceIds.length) void source.loadReferences(referenceIds).then((loaded) => setReferences((current) => new Map([...current, ...loaded]))).catch(() => undefined);
|
|
455
|
+
if (storyIds.length) void source.loadStories(storyIds).then((loaded) => setStories((current) => new Map([...current, ...loaded]))).catch(() => undefined);
|
|
456
|
+
}, [source]);
|
|
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
|
+
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
const mine = ++generation.current;
|
|
475
|
+
stream.current = createMergedThreadStream(threadKindsForSection(section), source.loadThreads);
|
|
476
|
+
setLoading(true);
|
|
477
|
+
setError(null);
|
|
478
|
+
stream.current.next(PAGE_SIZE).then((page) => {
|
|
479
|
+
if (generation.current !== mine) return;
|
|
480
|
+
setThreads(page.items);
|
|
481
|
+
setHasMore(page.hasMore);
|
|
482
|
+
setLoading(false);
|
|
483
|
+
loadedAt.current = Date.now();
|
|
484
|
+
enrich(page.items);
|
|
485
|
+
}).catch((failure) => {
|
|
486
|
+
if (generation.current !== mine) return;
|
|
487
|
+
setError(failure instanceof Error ? failure.message : "Conversations could not be loaded.");
|
|
488
|
+
setLoading(false);
|
|
489
|
+
});
|
|
490
|
+
}, [enrich, section, source, reloadKey]);
|
|
491
|
+
|
|
492
|
+
useEffect(() => {
|
|
493
|
+
const timer = window.setInterval(() => setNow(Date.now()), 60_000);
|
|
494
|
+
// Nothing subscribes: the page catches up when it is looked at again.
|
|
495
|
+
const refocus = () => { if (document.visibilityState !== "hidden" && Date.now() - loadedAt.current > STALE_AFTER_MS) setReloadKey((current) => current + 1); };
|
|
496
|
+
window.addEventListener("focus", refocus);
|
|
497
|
+
document.addEventListener("visibilitychange", refocus);
|
|
498
|
+
return () => { window.clearInterval(timer); window.removeEventListener("focus", refocus); document.removeEventListener("visibilitychange", refocus); };
|
|
499
|
+
}, []);
|
|
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
|
+
|
|
549
|
+
const loadMore = async () => {
|
|
550
|
+
if (!stream.current || loadingMore) return;
|
|
551
|
+
const mine = generation.current;
|
|
552
|
+
setLoadingMore(true);
|
|
553
|
+
try {
|
|
554
|
+
const page = await stream.current.next(PAGE_SIZE);
|
|
555
|
+
if (generation.current !== mine) return;
|
|
556
|
+
setThreads((current) => [...current, ...page.items.filter((thread) => !current.some((entry) => entry.id === thread.id))]);
|
|
557
|
+
setHasMore(page.hasMore);
|
|
558
|
+
enrich(page.items);
|
|
559
|
+
} catch (failure) {
|
|
560
|
+
setError(failure instanceof Error ? failure.message : "More conversations could not be loaded.");
|
|
561
|
+
} finally {
|
|
562
|
+
setLoadingMore(false);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const startConversation = async (title: string, body: string) => {
|
|
567
|
+
const { thread, message } = await source.start(title, body);
|
|
568
|
+
setSeeds((current) => ({ ...current, [thread.id]: [message] }));
|
|
569
|
+
setThreads((current) => [thread, ...current.filter((entry) => entry.id !== thread.id)]);
|
|
570
|
+
select(thread.id); // the new thread opens in the detail view
|
|
571
|
+
};
|
|
572
|
+
const composing = selectedId === NEW_THREAD;
|
|
573
|
+
|
|
574
|
+
const canStart = section === "all" || section === "other";
|
|
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]) => (
|
|
612
|
+
<button
|
|
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}
|
|
616
|
+
key={key}
|
|
617
|
+
onClick={() => chooseMode(key)}
|
|
618
|
+
type="button"
|
|
619
|
+
>
|
|
620
|
+
<Icon aria-hidden="true" className="size-4" />
|
|
621
|
+
{label}
|
|
622
|
+
</button>
|
|
623
|
+
))}
|
|
624
|
+
</div>
|
|
625
|
+
</div>
|
|
626
|
+
</div>
|
|
627
|
+
|
|
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>
|
|
633
|
+
) : null}
|
|
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}
|
|
635
|
+
{error ? (
|
|
636
|
+
<p className="flex items-start gap-1.5 text-sm text-destructive" data-conversations-error role="alert">
|
|
637
|
+
<TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
638
|
+
<span>Conversations could not be loaded. {error}</span>
|
|
639
|
+
</p>
|
|
640
|
+
) : null}
|
|
641
|
+
{!loading && !error && !threads.length ? (
|
|
642
|
+
<p className="flex items-center gap-2 text-sm text-muted-foreground" data-conversations-empty>
|
|
643
|
+
<InboxIcon aria-hidden="true" className="size-4" />
|
|
644
|
+
{SECTION_EMPTY_COPY[section]}
|
|
645
|
+
</p>
|
|
646
|
+
) : null}
|
|
647
|
+
|
|
648
|
+
{threads.length ? (
|
|
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}>
|
|
651
|
+
{threads.map((thread) => (
|
|
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} />
|
|
653
|
+
))}
|
|
654
|
+
</div>
|
|
655
|
+
) : null}
|
|
656
|
+
{hasMore ? (
|
|
657
|
+
<button className={cn(PILL, "bg-[var(--feed-control)] text-foreground")} data-conversations-more disabled={loadingMore} onClick={() => void loadMore()} type="button">
|
|
658
|
+
{loadingMore ? <Loader2Icon aria-hidden="true" className="size-4 animate-spin" /> : null}
|
|
659
|
+
Show more conversations
|
|
660
|
+
</button>
|
|
661
|
+
) : null}
|
|
662
|
+
</div>
|
|
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
|
+
);
|
|
680
|
+
}
|