feat: chat
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user