Claude
Skills
Sign in
Back

group-chat

Included with Lifetime
$97 forever

Real-time group chat with threads, reactions, mentions, and file attachments — Drizzle for persistence, Liveblocks for real-time delivery. Use this skill when the user says "add group chat", "setup chat", "add messaging", "team chat", or "group chat".

General

What this skill does


# Group Chat

Full-featured real-time group chat with channels, threaded replies, emoji reactions, @mentions, file attachments, and typing indicators. Uses Drizzle ORM for persistence and Liveblocks for real-time message delivery and presence.

## Prerequisites

- Next.js app with `src/` directory and App Router
- `auth` skill installed (`withAuth` available at `@/lib/auth-guard`)
- `db` skill installed (Drizzle ORM + Postgres)
- `realtime` skill installed (Liveblocks configured with `@liveblocks/react`)
- `storage` skill installed (file upload support)
- shadcn/ui initialized

## Installation

No new packages required. Uses `@liveblocks/react` (from `realtime` skill) and existing dependencies.

Install shadcn components if not already present:

```bash
bunx shadcn@latest add card badge button input textarea avatar scroll-area popover
```

## Environment Variables

No additional environment variables required. Uses `LIVEBLOCKS_SECRET_KEY` from the `realtime` skill and `DATABASE_URL` from the `db` skill.

## What Gets Created

```
src/
├── lib/
│   ├── chat/
│   │   ├── types.ts                          # Channel, Message, Thread, Reaction types
│   │   ├── use-chat-channel.ts               # Real-time chat hook with Liveblocks
│   │   └── mentions.ts                       # @mention parsing and resolution
│   └── db/
│       └── schema/
│           └── chat-messages.ts              # channels, messages, reactions Drizzle tables
├── components/
│   └── chat/
│       ├── chat-channel.tsx                  # Full chat UI: messages + input + thread sidebar
│       ├── message-bubble.tsx                # Individual message with avatar, reactions, reply
│       ├── thread-panel.tsx                  # Slide-out threaded reply panel
│       ├── typing-indicator.tsx              # "Alice is typing..." presence indicator
│       ├── reaction-picker.tsx               # Emoji reaction popover
│       └── chat-input.tsx                    # Message input with attachments and mentions
└── app/
    └── api/
        └── chat/
            ├── channels/
            │   └── route.ts                  # POST create, GET list channels
            ├── messages/
            │   └── route.ts                  # POST send, GET list with pagination
            └── reactions/
                └── route.ts                  # POST add, DELETE remove reaction
```

## Database

After applying this skill, push the schema:

```bash
bunx drizzle-kit push
```

## Setup Steps

### Step 1: Create `src/lib/db/schema/chat-messages.ts`

```typescript
import {
  pgTable,
  text,
  timestamp,
  uuid,
} from "drizzle-orm/pg-core";

export const channels = pgTable("channels", {
  id: uuid("id").defaultRandom().primaryKey(),
  name: text("name").notNull(),
  description: text("description"),
  roomId: text("room_id").notNull(),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
  createdBy: text("created_by").notNull(),
});

export const messages = pgTable("messages", {
  id: uuid("id").defaultRandom().primaryKey(),
  channelId: uuid("channel_id")
    .notNull()
    .references(() => channels.id, { onDelete: "cascade" }),
  userId: text("user_id").notNull(),
  content: text("content").notNull(),
  parentId: uuid("parent_id"),
  attachmentUrl: text("attachment_url"),
  attachmentType: text("attachment_type"),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});

export const reactions = pgTable("reactions", {
  id: uuid("id").defaultRandom().primaryKey(),
  messageId: uuid("message_id")
    .notNull()
    .references(() => messages.id, { onDelete: "cascade" }),
  userId: text("user_id").notNull(),
  emoji: text("emoji").notNull(),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
```

### Step 2: Add exports to `src/lib/db/schema/index.ts`

```typescript
export * from "./chat-messages";
```

### Step 2a: Update `liveblocks.config.ts` — add `isTyping` to Presence and chat events to RoomEvent

Add `isTyping: boolean` to the `Presence` type and add the chat broadcast event types to `RoomEvent`:

```typescript
declare global {
  interface Liveblocks {
    Presence: {
      cursor: { x: number; y: number } | null;
      name: string;
      avatar: string;
      color: string;
      isTyping: boolean;
    };
    // ... keep other fields ...
    RoomEvent:
      | { type: "new-message"; message: { id: string; channelId: string; content: string; author: { id: string; name: string; image: string | null }; parentId: string | null; attachmentUrl: string | null; attachmentType: string | null; reactions: Array<{ id: string; emoji: string; userId: string; userName: string }>; replyCount: number; createdAt: string; updatedAt: string } }
      | { type: "new-reaction"; messageId: string; reaction: { id: string; emoji: string; userId: string; userName: string } }
      | { type: "remove-reaction"; messageId: string; reactionId: string };
  }
}
```

### Step 2b: Update `src/components/realtime/room-provider.tsx` — add `isTyping: false` to initial presence

In the `RealtimeRoom` component, add `isTyping: false` to the default `initialPresence`:

```typescript
initialPresence={{
  cursor: null,
  name: "",
  avatar: "",
  color: "#3b82f6",
  isTyping: false,
  ...initialPresence,
}}
```

### Step 3: Create `src/lib/chat/types.ts`

```typescript
export type Channel = {
  id: string;
  name: string;
  description: string | null;
  roomId: string;
  createdAt: string;
  createdBy: string;
};

export type MessageAuthor = {
  id: string;
  name: string;
  image: string | null;
};

export type Reaction = {
  id: string;
  emoji: string;
  userId: string;
  userName: string;
};

export type Message = {
  id: string;
  channelId: string;
  content: string;
  author: MessageAuthor;
  parentId: string | null;
  attachmentUrl: string | null;
  attachmentType: string | null;
  reactions: Reaction[];
  replyCount: number;
  createdAt: string;
  updatedAt: string;
};

export type Thread = {
  parentMessage: Message;
  replies: Message[];
};

export type ChannelMember = {
  userId: string;
  name: string;
  image: string | null;
  isOnline: boolean;
};

export type ChatPresence = {
  userId: string;
  name: string;
  isTyping: boolean;
};
```

### Step 4: Create `src/lib/chat/use-chat-channel.ts`

```typescript
"use client";

import { useState, useEffect, useCallback, useRef } from "react";
import { useBroadcastEvent, useEventListener } from "@liveblocks/react";
import type { Message, Reaction } from "./types";

type UseChatChannelOptions = {
  channelId: string;
  initialMessages?: Message[];
};

type BroadcastPayload =
  | { type: "new-message"; message: Message }
  | { type: "new-reaction"; messageId: string; reaction: Reaction }
  | { type: "remove-reaction"; messageId: string; reactionId: string };

export function useChatChannel({ channelId, initialMessages = [] }: UseChatChannelOptions) {
  const [messages, setMessages] = useState<Message[]>(initialMessages);
  const [isLoading, setIsLoading] = useState(!initialMessages.length);
  const cursorRef = useRef<string | null>(null);
  const [hasMore, setHasMore] = useState(true);

  const broadcast = useBroadcastEvent();

  // Load initial messages
  useEffect(() => {
    if (initialMessages.length > 0) return;

    let cancelled = false;
    setIsLoading(true);

    fetch(`/api/chat/messages?channelId=${channelId}&limit=50`)
      .then((res) => {
        if (!res.ok) throw new Error("Failed to load messages");
        return res.json();
      })
      .then((data: { messages: Message[]; nextCursor: string | null }) => {
        if (cancelled) return;
        setMessages(data.messages);
        cursorRef.current = data.nextCursor;
        setHasMore(data.nextCursor !== null);
      })
      .catch(() => {
        // Failed to load — start with empty
      })
      .finally(() => {
       
Files: 1
Size: 49.5 KB
Complexity: 39/100
Category: General

Related in General