179 lines
5.2 KiB
TypeScript
179 lines
5.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|