130 lines
3.8 KiB
TypeScript
130 lines
3.8 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* One row of the chat inbox: avatar, title, the last message (truncated, with
|
|
* its sender), the time, and an unread badge.
|
|
*
|
|
* `highlighted` is driven by realtime `chat_activity` — a new message flashes
|
|
* its row here rather than raising a notification, which is what keeps chat out
|
|
* of the notification tray.
|
|
*/
|
|
|
|
import Link from "next/link";
|
|
import { BellOff } from "lucide-react";
|
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
|
import { KautilyaIcon } from "@/components/selection-toolbar/shared";
|
|
import { timeAgo } from "@/lib/time";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
conversationTitle,
|
|
groupAvatarUrl,
|
|
isMuted,
|
|
type InboxItem,
|
|
} from "@/lib/chat";
|
|
|
|
/** "You: ..." for your own messages, "Name: ..." in groups, bare in a DM. */
|
|
function previewText(item: InboxItem, selfId: string): string {
|
|
const m = item.last_message;
|
|
if (!m) return "No messages yet";
|
|
if (m.deleted_at) return "This message was deleted";
|
|
|
|
const body = m.body || "Attachment";
|
|
if (m.sender_id === selfId) return `You: ${body}`;
|
|
// A DM's peer is already named by the row title, so repeating it wastes the
|
|
// line. Groups need the name to tell speakers apart.
|
|
if (item.kind === "group" && m.sender_name) return `${m.sender_name}: ${body}`;
|
|
return body;
|
|
}
|
|
|
|
export function ChatListItem({
|
|
item,
|
|
selfId,
|
|
highlighted = false,
|
|
}: {
|
|
item: InboxItem;
|
|
selfId: string;
|
|
highlighted?: boolean;
|
|
}) {
|
|
const muted = isMuted(item);
|
|
const unread = item.unread > 0;
|
|
|
|
return (
|
|
<li>
|
|
<Link
|
|
href={`/chats/${item.id}`}
|
|
className={cn(
|
|
"flex items-center gap-3 px-4 py-3 transition-colors hover:bg-muted/60",
|
|
highlighted && "bg-primary/5",
|
|
)}
|
|
>
|
|
{item.kind === "ai" ? (
|
|
<span
|
|
aria-hidden
|
|
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"
|
|
>
|
|
<KautilyaIcon className="size-5" />
|
|
</span>
|
|
) : (
|
|
<UserAvatar
|
|
src={
|
|
item.kind === "group"
|
|
? groupAvatarUrl(item.id, item.avatar_key)
|
|
: item.peer?.avatar_url
|
|
}
|
|
name={conversationTitle(item)}
|
|
className="size-10 shrink-0"
|
|
/>
|
|
)}
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={cn(
|
|
"truncate text-sm text-foreground",
|
|
unread ? "font-semibold" : "font-medium",
|
|
)}
|
|
>
|
|
{conversationTitle(item)}
|
|
</span>
|
|
{muted && (
|
|
<BellOff
|
|
className="size-3.5 shrink-0 text-muted-foreground"
|
|
aria-label="Muted"
|
|
/>
|
|
)}
|
|
</div>
|
|
<p
|
|
className={cn(
|
|
"truncate text-sm",
|
|
unread ? "text-foreground" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{previewText(item, selfId)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
|
{item.last_message_at && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{timeAgo(item.last_message_at)}
|
|
</span>
|
|
)}
|
|
{unread && (
|
|
<span
|
|
className={cn(
|
|
"inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-semibold",
|
|
// A muted thread still counts, but shouldn't shout.
|
|
muted
|
|
? "bg-muted text-muted-foreground"
|
|
: "bg-primary text-primary-foreground dark:text-white",
|
|
)}
|
|
>
|
|
{item.unread > 99 ? "99+" : item.unread}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
</li>
|
|
);
|
|
}
|