Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
164 lines
6.5 KiB
TypeScript
164 lines
6.5 KiB
TypeScript
import { riskLabel } from "../../i18n/labels";
|
|
import { useState, type ReactNode } from "react";
|
|
import { Button, Icon, Pill } from "../../shared/ui";
|
|
import type {
|
|
ApprovalRecord,
|
|
ChatAttachmentRecord,
|
|
ChatMentionRecord,
|
|
ChatMessageRecord,
|
|
ChatSlashInvocationRecord,
|
|
FileObjectRecord,
|
|
} from "../../../../shared/domain";
|
|
import { ChatMessageRow } from "./ChatMessageRow";
|
|
import type { ThreadLink } from "./thread-links";
|
|
import type { ChatReplyStatus } from "../../../../shared/chat-domain";
|
|
import { formatDay, type MessageContext } from "./chat-helpers";
|
|
import { t } from "../../i18n/i18n";
|
|
|
|
const WINDOW_SIZE = 150;
|
|
const EMPTY_ATTACHMENTS: ChatAttachmentRecord[] = [];
|
|
const EMPTY_MENTIONS: ChatMentionRecord[] = [];
|
|
const EMPTY_INVOCATIONS: ChatSlashInvocationRecord[] = [];
|
|
|
|
function groupBy<T>(items: T[], keyOf: (item: T) => string): Map<string, T[]> {
|
|
const groups = new Map<string, T[]>();
|
|
for (const item of items) {
|
|
const key = keyOf(item);
|
|
const group = groups.get(key);
|
|
if (group) group.push(item);
|
|
else groups.set(key, [item]);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
export function ChatTimeline({
|
|
messagesInSpace,
|
|
pendingApprovals,
|
|
attachments,
|
|
mentions,
|
|
slashInvocations,
|
|
files,
|
|
context,
|
|
currentEmployeeId,
|
|
historyStart,
|
|
historyError,
|
|
onLoadOlder,
|
|
threadLinks,
|
|
replyStatuses,
|
|
onOpenThread,
|
|
onApprove,
|
|
onDecline,
|
|
onEditMessage,
|
|
onDeleteMessage,
|
|
onOpenFile,
|
|
}: {
|
|
messagesInSpace: ChatMessageRecord[];
|
|
pendingApprovals: ApprovalRecord[];
|
|
attachments: ChatAttachmentRecord[];
|
|
mentions: ChatMentionRecord[];
|
|
slashInvocations: ChatSlashInvocationRecord[];
|
|
files: FileObjectRecord[];
|
|
context: MessageContext;
|
|
currentEmployeeId: string;
|
|
/** True once the database said no older message exists. */
|
|
historyStart: boolean;
|
|
historyError: string | null;
|
|
onLoadOlder: () => void;
|
|
threadLinks: Map<string, ThreadLink>;
|
|
replyStatuses: ChatReplyStatus[];
|
|
onOpenThread: (threadId: string) => void;
|
|
onApprove: (approvalId: string) => void;
|
|
onDecline: (approvalId: string) => void;
|
|
onEditMessage: (messageId: string, bodyMd: string) => Promise<void>;
|
|
onDeleteMessage: (messageId: string) => void;
|
|
onOpenFile: (fileId: string) => void;
|
|
}) {
|
|
const [visibleCount, setVisibleCount] = useState(WINDOW_SIZE);
|
|
|
|
const attachmentsByMessage = groupBy(attachments, (item) => item.messageId);
|
|
const repliesByMessage = groupBy(replyStatuses, (item) => item.messageId);
|
|
const mentionsByMessage = groupBy(mentions, (item) => item.messageId);
|
|
const invocationsByMessage = groupBy(slashInvocations, (item) => item.messageId);
|
|
|
|
const hiddenCount = Math.max(0, messagesInSpace.length - visibleCount);
|
|
const visible = hiddenCount > 0 ? messagesInSpace.slice(hiddenCount) : messagesInSpace;
|
|
|
|
const elements: ReactNode[] = [];
|
|
if (historyError) elements.push(<div key="history-error" className="form-error" role="alert">{t("Earlier messages could not load: {reason}", { reason: historyError })}</div>);
|
|
if (hiddenCount > 0 || !historyStart) {
|
|
// Showing earlier first reveals loaded messages, then asks the database for the next page.
|
|
const showEarlier = () => {
|
|
setVisibleCount((count) => count + WINDOW_SIZE);
|
|
if (hiddenCount === 0) onLoadOlder();
|
|
};
|
|
elements.push(
|
|
<div key="show-earlier" style={{ display: "flex", justifyContent: "center", marginBottom: 16 }}>
|
|
<Button size="small" onClick={showEarlier}>{t("Show earlier messages")}</Button>
|
|
</div>,
|
|
);
|
|
} else {
|
|
elements.push(<div key="history-start" style={{ textAlign: "center", fontSize: 12, color: "var(--text-3)", marginBottom: 16 }}>{t("This is the start of the conversation.")}</div>);
|
|
}
|
|
let lastDay = "";
|
|
for (const message of visible) {
|
|
const day = formatDay(message.createdAt);
|
|
if (day !== lastDay) {
|
|
lastDay = day;
|
|
elements.push(<DayDivider key={`day-${day}`} label={day} />);
|
|
}
|
|
const threadId = message.threadId;
|
|
elements.push(
|
|
<ChatMessageRow
|
|
key={message.id}
|
|
message={message}
|
|
attachments={attachmentsByMessage.get(message.id) ?? EMPTY_ATTACHMENTS}
|
|
mentions={mentionsByMessage.get(message.id) ?? EMPTY_MENTIONS}
|
|
slashInvocations={invocationsByMessage.get(message.id) ?? EMPTY_INVOCATIONS}
|
|
files={files}
|
|
context={context}
|
|
canModify={message.senderType === "employee" && message.senderId === currentEmployeeId && !message.deletedAt}
|
|
thread={threadLinks.get(message.id)}
|
|
replyStatuses={repliesByMessage.get(message.id)}
|
|
onOpenThread={threadId ? () => onOpenThread(threadId) : undefined}
|
|
onEdit={(bodyMd) => onEditMessage(message.id, bodyMd)}
|
|
onDelete={() => onDeleteMessage(message.id)}
|
|
onOpenFile={onOpenFile}
|
|
/>,
|
|
);
|
|
}
|
|
for (const approval of pendingApprovals) {
|
|
elements.push(
|
|
<ApprovalInline key={`approval-${approval.id}`} approval={approval} onApprove={() => onApprove(approval.id)} onDecline={() => onDecline(approval.id)} />,
|
|
);
|
|
}
|
|
return <>{elements}</>;
|
|
}
|
|
|
|
function DayDivider({ label }: { label: string }) {
|
|
return (
|
|
<div className="row" style={{ gap: 12, margin: "8px 0 16px" }}>
|
|
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
|
|
<div style={{ fontSize: 12, color: "var(--text-3)", fontWeight: 540 }}>{label}</div>
|
|
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ApprovalInline({ approval, onApprove, onDecline }: { approval: ApprovalRecord; onApprove: () => void; onDecline: () => void }) {
|
|
return (
|
|
<div style={{ margin: "14px 0 18px 44px", maxWidth: 480, border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: 14 }}>
|
|
<div className="row" style={{ marginBottom: 8 }}>
|
|
<Icon name="approve" size={14} color="var(--amber)" />
|
|
<span style={{ fontSize: 12, color: "var(--amber)", fontWeight: 540 }}>{t("Approval needed")}</span>
|
|
<Pill tone={approval.risk === "high" ? "red" : approval.risk === "medium" ? "amber" : undefined}>{riskLabel(approval.risk)}</Pill>
|
|
</div>
|
|
<div style={{ fontSize: 14, fontWeight: 540, marginBottom: 4 }}>{approval.title}</div>
|
|
<div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 12, lineHeight: 1.5 }}>{approval.details}</div>
|
|
<div className="row" style={{ gap: 6 }}>
|
|
<Button tone="primary" size="small" onClick={onApprove}>{t("Approve")}</Button>
|
|
<Button size="small" onClick={onDecline}>{t("Decline")}</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|