Submatter/apps/web/components/chat/chat-message-list.tsx

180 lines
5.2 KiB
TypeScript
Raw Normal View History

"use client";
import { useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type ChatMessageView = {
id: string;
sequence: string;
body: string;
authorUserId: string | null;
createdAt: Date | string;
editedAt?: Date | string | null;
deletedAt?: Date | string | null;
status?: string;
revision?: number;
replyCount?: number;
parentMessageId?: string | null;
};
export function ChatMessageList({
messages,
onViewed,
onThread,
onEdit,
onDelete,
canModerate = false,
currentUserId,
readOnly = false,
pending = [],
}: {
messages: ChatMessageView[];
onViewed?: (sequence: string) => void;
onThread?: (id: string) => void;
onEdit?: (message: ChatMessageView) => void;
onDelete?: (message: ChatMessageView) => void;
canModerate?: boolean;
currentUserId?: string;
readOnly?: boolean;
pending?: {
key: string;
body: string;
failed: boolean;
error?: string;
retry: () => void;
}[];
}) {
const viewed = useRef(onViewed);
useEffect(() => {
viewed.current = onViewed;
}, [onViewed]);
const list = useRef<HTMLOListElement>(null);
useEffect(() => {
if (
!viewed.current ||
!list.current ||
typeof IntersectionObserver === "undefined"
)
return;
const observer = new IntersectionObserver(
(entries) => {
if (document.visibilityState !== "visible") return;
const visible = entries
.filter((entry) => entry.isIntersecting)
.map((entry) => (entry.target as HTMLElement).dataset.sequence)
.filter((sequence): sequence is string => !!sequence);
const highest = visible.reduce<string | null>(
(max, sequence) =>
max === null || BigInt(sequence) > BigInt(max) ? sequence : max,
null,
);
if (highest) viewed.current?.(highest);
},
{ threshold: 0.5 },
);
list.current
.querySelectorAll("[data-sequence]")
.forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, [messages, pending]);
return (
<ol ref={list} aria-label="Messages" className="space-y-3 p-4">
{messages.map((message) => (
<li
key={message.id}
data-sequence={message.sequence}
className="rounded-md border p-3 text-sm"
>
<div className="flex flex-wrap items-baseline gap-2 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">
{message.authorUserId}
</span>
<time dateTime={new Date(message.createdAt).toISOString()}>
{new Date(message.createdAt).toLocaleString()}
</time>
{message.editedAt && (
<span className="ml-1 text-xs text-muted-foreground">
(edited)
</span>
)}
</div>
<p className="mt-1 whitespace-pre-wrap break-words">
{message.deletedAt || message.status === "deleted"
? "Message deleted"
: message.body}
</p>
{!readOnly && !message.deletedAt && message.status !== "deleted" && (
<div className="flex gap-2">
{onEdit && message.authorUserId === currentUserId && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onEdit(message)}
aria-label={`Edit message ${message.id}`}
>
Edit
</Button>
)}
{onDelete &&
(message.authorUserId === currentUserId || canModerate) && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onDelete(message)}
aria-label={`Delete message ${message.id}`}
>
Delete
</Button>
)}
</div>
)}
{onThread && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onThread(message.id)}
aria-label={`Open replies to message by ${message.authorUserId}`}
>
{message.replyCount ? `${message.replyCount} replies` : "Reply"}
</Button>
)}
</li>
))}
{pending.map((message) => (
<li
key={message.key}
className={cn(
"rounded-md border p-3 text-sm",
message.failed && "border-destructive",
)}
>
<p className="whitespace-pre-wrap break-words">{message.body}</p>
<p
role={message.failed ? "alert" : "status"}
className="text-xs text-muted-foreground"
>
{message.failed ? message.error : "Sending…"}
</p>
{message.failed && (
<Button
size="sm"
variant="outline"
type="button"
onClick={message.retry}
>
Retry send
</Button>
)}
</li>
))}
</ol>
);
}