Vibe coding with AI course · Module 8: Slack Clone

Messages and Threads - Conversations

8 min read
In this lesson3

A channel without messages is an empty cabin. In Slack the conversation is what matters most: messages with reactions and attachments, and threads that don't clutter the main channel. We'll build a message component, a field for writing and a thread panel.

The message component

MessageItem receives the message, the author and functions for reacting, replying, editing and deleting:

'use client';

import { useState } from 'react';

interface MessageItemProps {
  message: Message;
  author: Member;
  currentUserId: string;
  onReact: (messageId: string, emoji: string) => void;
  onReply: (messageId: string) => void;
  onEdit: (messageId: string, newContent: string) => void;
  onDelete: (messageId: string) => void;
}

export default function MessageItem({
  message,
  author,
  currentUserId,
  onReact,
  onReply,
  onEdit,
  onDelete
}: MessageItemProps) {
  const [showActions, setShowActions] = useState(false);
  const [isEditing, setIsEditing] = useState(false);
  const [editContent, setEditContent] = useState(message.content);

  const isOwnMessage = author.id === currentUserId;

  const formatTime = (date: Date) => {
    const now = new Date();
    const isToday = date.toDateString() === now.toDateString();

    if (isToday) {
      return date.toLocaleTimeString('en-US', {
        hour: '2-digit',
        minute: '2-digit'
      });
    }
    return date.toLocaleDateString('en-US', {
      month: 'short',
      day: 'numeric'
    }) + ' at ' + date.toLocaleTimeString('en-US', {
      hour: '2-digit',
      minute: '2-digit'
    });
  };

  const handleSaveEdit = () => {
    if (editContent.trim() && editContent !== message.content) {
      onEdit(message.id, editContent);
    }
    setIsEditing(false);
  };

  return (
    <div
      className="group hover:bg-gray-50 px-6 py-2 relative"
      onMouseEnter={() => setShowActions(true)}
      onMouseLeave={() => setShowActions(false)}
    >
      <div className="flex gap-3">
        {/* Avatar */}
        <img
          src={author.avatar}
          alt={author.name}
          className="w-10 h-10 rounded mt-1"
        />

        <div className="flex-1 min-w-0">
          {/* Header */}
          <div className="flex items-baseline gap-2 mb-1">
            <span className="font-bold text-gray-900">{author.name}</span>
            <span className="text-xs text-gray-500">{formatTime(message.timestamp)}</span>
            {message.isEdited && (
              <span className="text-xs text-gray-500">(edited)</span>
            )}
            {message.isPinned && (
              <span className="text-xs">Pinned</span>
            )}
          </div>

          {/* Content */}
          {isEditing ? (
            <div>
              <textarea
                value={editContent}
                onChange={(e) => setEditContent(e.target.value)}
                className="w-full p-2 border rounded resize-none"
                rows={3}
                autoFocus
              />
              <div className="flex gap-2 mt-2">
                <button
                  onClick={handleSaveEdit}
                  className="px-4 py-1.5 bg-green-600 text-white rounded text-sm hover:bg-green-700"
                >
                  Save
                </button>
                <button
                  onClick={() => {
                    setEditContent(message.content);
                    setIsEditing(false);
                  }}
                  className="px-4 py-1.5 border rounded text-sm hover:bg-gray-50"
                >
                  Cancel
                </button>
              </div>
            </div>
          ) : (
            <>
              <p className="text-gray-900 break-words whitespace-pre-wrap">
                {message.content}
              </p>

              {/* Attachments */}
              {message.attachments && message.attachments.length > 0 && (
                <div className="mt-2 space-y-2">
                  {message.attachments.map(attachment => (
                    <div
                      key={attachment.id}
                      className="border rounded p-3 hover:bg-gray-50 cursor-pointer"
                    >
                      <div className="flex items-center gap-3">
                        {attachment.type === 'image' ? (
                          <img
                            src={attachment.url}
                            alt={attachment.name}
                            className="max-w-sm max-h-64 rounded"
                          />
                        ) : (
                          <>
                            <span className="text-sm font-semibold">Document</span>
                            <div>
                              <p className="font-medium text-blue-600">
                                {attachment.name}
                              </p>
                              <p className="text-xs text-gray-500">
                                {(attachment.size / 1024).toFixed(1)} KB
                              </p>
                            </div>
                          </>
                        )}
                      </div>
                    </div>
                  ))}
                </div>
              )}

              {/* Reactions */}
              {message.reactions.length > 0 && (
                <div className="flex flex-wrap gap-1 mt-2">
                  {message.reactions.map((reaction, idx) => (
                    <button
                      key={idx}
                      onClick={() => onReact(message.id, reaction.emoji)}
                      className="px-2 py-1 border rounded-full hover:border-blue-500 text-sm flex items-center gap-1"
                    >
                      <span>{reaction.emoji}</span>
                      <span className="text-xs font-semibold">
                        {reaction.count}
                      </span>
                    </button>
                  ))}
                  <button className="px-2 py-1 border rounded-full hover:border-blue-500 text-sm opacity-60 hover:opacity-100">
                    +
                  </button>
                </div>
              )}

              {/* Thread Replies */}
              {message.replyCount > 0 && (
                <button
                  onClick={() => onReply(message.id)}
                  className="mt-2 text-blue-600 hover:underline text-sm font-semibold flex items-center gap-2"
                >
                  <span>Comment</span>
                  {message.replyCount} {message.replyCount === 1 ? 'reply' : 'replies'}
                </button>
              )}
            </>
          )}
        </div>
      </div>

      {/* Hover Actions */}
      {showActions && !isEditing && (
        <div className="absolute top-0 right-6 bg-white border rounded shadow-lg flex items-center">
          <button
            onClick={() => onReact(message.id, '')}
            className="p-2 hover:bg-gray-100"
            title="Add reaction"
          >
            :)
          </button>
          <button
            onClick={() => onReply(message.id)}
            className="p-2 hover:bg-gray-100"
            title="Reply in thread"
          >
            Comment
          </button>
          {isOwnMessage && (
            <>
              <button
                onClick={() => setIsEditing(true)}
                className="p-2 hover:bg-gray-100"
                title="Edit message"
              >
                Edit
              </button>
              <button
                onClick={() => onDelete(message.id)}
                className="p-2 hover:bg-gray-100 text-red-600"
                title="Delete message"
              >
                Delete
              </button>
            </>
          )}
          <button className="p-2 hover:bg-gray-100" title="More">
            •••
          </button>
        </div>
      )}
    </div>
  );
}

From the top, a message has a header with the author and the time, the content, the reactions and the number of replies in the thread. formatTime shows only the time for today's messages, and for older ones the date as well. When isEdited is true, (edited) appears next to the time, and isPinned shows a pin icon. The component doesn't change the message itself, it only calls functions from the parent, such as onReact or onEdit. An attachment has the type image, video, file or link: we show images right away, and the rest as a file name with its size. replyCount is the number of replies in the thread, shown e.g. as "3 replies", and as "1 reply" for a single one.

On hover, actions appear in this order: react, reply, edit, delete. Only the author sees edit and delete (isOwnMessage). Editing goes like this: you click Edit message, change the text in the field, click Save, and handleSaveEdit calls onEdit if the text has changed. The parent then sets isEdited, so (edited) appears.

A reaction is an emoji with a list of users and a count. A click adds your reaction, or removes it if it was already there. The parent can write this logic as a small function:

function toggleReaction(reaction: Reaction, userId: string): Reaction {
  const users = reaction.users.includes(userId)
    ? reaction.users.filter(id => id !== userId)
    : [...reaction.users, userId];
  return { ...reaction, users, count: users.length };
}

The function returns a new reaction instead of changing the old one, and the count always matches the length of the list.

The message field

MessageInput handles text, attachments and keyboard shortcuts:

'use client';

import { useState, useRef } from 'react';

interface MessageInputProps {
  channelName: string;
  onSend: (content: string, attachments?: File[]) => void;
}

export default function MessageInput({
  channelName,
  onSend
}: MessageInputProps) {
  const [content, setContent] = useState('');
  const [attachments, setAttachments] = useState<File[]>([]);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    if (content.trim() || attachments.length > 0) {
      onSend(content, attachments);
      setContent('');
      setAttachments([]);
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSubmit(e);
    }
  };

  return (
    <div className="p-4 border-t bg-white">
      <form onSubmit={handleSubmit}>
        <div className="border rounded-lg overflow-hidden">
          {/* Attachments Preview */}
          {attachments.length > 0 && (
            <div className="bg-gray-50 p-2 border-b flex flex-wrap gap-2">
              {attachments.map((file, idx) => (
                <div
                  key={idx}
                  className="flex items-center gap-2 bg-white border rounded px-3 py-1"
                >
                  <span className="text-sm">{file.name}</span>
                  <button
                    type="button"
                    onClick={() => setAttachments(attachments.filter((_, i) => i !== idx))}
                    className="text-gray-500 hover:text-gray-700"
                  >
                    ×
                  </button>
                </div>
              ))}
            </div>
          )}

          {/* Input */}
          <textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder={`Message #${channelName}`}
            className="w-full p-3 resize-none outline-none"
            rows={3}
          />

          {/* Toolbar */}
          <div className="flex items-center justify-between px-3 py-2 bg-gray-50">
            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => fileInputRef.current?.click()}
                className="p-1.5 hover:bg-gray-200 rounded"
                title="Attach file"
              >
                Attach
              </button>
              <button
                type="button"
                className="p-1.5 hover:bg-gray-200 rounded"
                title="Emoji"
              >
                :)
              </button>
              <button
                type="button"
                className="p-1.5 hover:bg-gray-200 rounded"
                title="Mention"
              >
                @
              </button>
              <button
                type="button"
                className="p-1.5 hover:bg-gray-200 rounded"
                title="Code block"
              >
                {'</>'}
              </button>
            </div>

            <button
              type="submit"
              disabled={!content.trim() && attachments.length === 0}
              className="px-4 py-1.5 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-sm font-semibold"
            >
              Send
            </button>
          </div>
        </div>

        <input
          ref={fileInputRef}
          type="file"
          multiple
          className="hidden"
          onChange={(e) => {
            if (e.target.files) {
              setAttachments([...attachments, ...Array.from(e.target.files)]);
            }
          }}
        />
      </form>
    </div>
  );
}

Sending works like this: you type text, press Enter without Shift, handleSubmit calls onSend(content), and then clears the field with setContent(''). Shift+Enter adds a new line instead of sending the message, because handleKeyDown only intercepts Enter on its own. Slack works the same way, though there you can flip it in the preferences. You open the hidden file field with the multiple attribute through fileInputRef.current?.click(), and the chosen files land in the preview list. The cross next to a file removes it with filter, and the Send button is disabled when there is neither text nor an attachment.

The thread panel

Replying in a thread goes like this: you click Reply in thread on a message, ThreadPanel opens on the side, you write a reply and send it.

'use client';

interface ThreadPanelProps {
  parentMessage: Message;
  parentAuthor: Member;
  replies: Message[];
  onClose: () => void;
  onSendReply: (content: string) => void;
}

export default function ThreadPanel({
  parentMessage,
  parentAuthor,
  replies,
  onClose,
  onSendReply
}: ThreadPanelProps) {
  return (
    <div className="w-96 border-l bg-white flex flex-col h-screen">
      {/* Header */}
      <div className="p-4 border-b flex items-center justify-between">
        <h3 className="font-bold">Thread</h3>
        <button
          onClick={onClose}
          className="hover:bg-gray-100 p-2 rounded"
        >
          ×
        </button>
      </div>

      {/* Parent Message */}
      <div className="p-4 border-b">
        <div className="flex gap-3">
          <img
            src={parentAuthor.avatar}
            alt={parentAuthor.name}
            className="w-10 h-10 rounded"
          />
          <div className="flex-1">
            <div className="font-bold mb-1">{parentAuthor.name}</div>
            <p className="text-gray-900 whitespace-pre-wrap">
              {parentMessage.content}
            </p>
          </div>
        </div>
        <div className="mt-3 text-sm text-gray-600">
          {replies.length} {replies.length === 1 ? 'reply' : 'replies'}
        </div>
      </div>

      {/* Replies */}
      <div className="flex-1 overflow-y-auto">
        {replies.map(reply => {
          return (
            <div key={reply.id} className="p-4 border-b">
              <div className="flex gap-3">
                <img
                  src={reply.author.avatar}
                  alt={reply.author.name}
                  className="w-8 h-8 rounded"
                />
                <div className="flex-1">
                  <div className="flex items-baseline gap-2 mb-1">
                    <span className="font-bold text-sm">{reply.author.name}</span>
                    <span className="text-xs text-gray-500">
                      {reply.timestamp.toLocaleTimeString()}
                    </span>
                  </div>
                  <p className="text-sm text-gray-900">{reply.content}</p>
                </div>
              </div>
            </div>
          );
        })}
      </div>

      {/* Reply Input */}
      <div className="p-3 border-t">
        <MessageInput
          channelName="thread"
          onSend={onSendReply}
        />
      </div>
    </div>
  );
}

The panel shows the parent message, the number of replies and their list, and at the bottom it reuses MessageInput. It doesn't save anything itself, it passes the new reply to the parent through onSendReply. Note: Message only has authorId, so before you use reply.author, attach the author's data, e.g. with members.find(...).

Captain Redbeard's tip: move a long discussion into a thread. The channel stays readable, and whoever wants the details opens the side panel.

Remember: a message is content, reactions and a thread, and Enter sends while Shift+Enter breaks the line. In the editor below you have a message with reactions and hover actions, and the tasks will check message fields, actions and sending.

Code for this lesson: App.jsx
// Message Item with Reactions
import { useState } from 'react';

const message = {
  id: 1,
  author: { name: 'Jack Sparrow', avatar: '' },
  content: `Found a promising lead on the Treasure of Cortez!

According to the ancient map, it's located on Isla de Muerta. We should set sail at dawn.`,
  timestamp: new Date('2024-01-15T10:30:00'),
  isEdited: false,
  reactions: [
    { emoji: '', count: 5, hasReacted: false },
    { emoji: '', count: 3, hasReacted: false },
    { emoji: '', count: 2, hasReacted: false }
  ],
  replyCount: 4
};

export default function App() {
  const [showActions, setShowActions] = useState(false);
  const [reactions, setReactions] = useState(message.reactions);

  const handleReact = (emoji) => {
    setReactions(reactions.map(r => {
      if (r.emoji === emoji) {
        return {
          ...r,
          count: r.hasReacted ? r.count - 1 : r.count + 1,
          hasReacted: !r.hasReacted
        };
      }
      return r;
    }));
  };

  return (
    <div className="min-h-screen bg-gray-100 p-8">
      <div className="max-w-4xl mx-auto bg-white rounded-lg shadow">
        <div
          className="group hover:bg-gray-50 px-6 py-3 relative"
          onMouseEnter={() => setShowActions(true)}
          onMouseLeave={() => setShowActions(false)}
        >
          <div className="flex gap-3">
            {/* Avatar */}
            <div className="text-4xl">{message.author.avatar}</div>

            <div className="flex-1 min-w-0">
              {/* Header */}
              <div className="flex items-baseline gap-2 mb-1">
                <span className="font-bold text-gray-900">{message.author.name}</span>
                <span className="text-xs text-gray-500">
                  {message.timestamp.toLocaleTimeString()}
                </span>
                {message.isEdited && (
                  <span className="text-xs text-gray-500">(edited)</span>
                )}
              </div>

              {/* Content */}
              <p className="text-gray-900 whitespace-pre-wrap mb-2">
                {message.content}
              </p>

              {/* Reactions */}
              <div className="flex flex-wrap gap-1 mb-2">
                {reactions.map((reaction, idx) => (
                  <button
                    key={idx}
                    onClick={() => handleReact(reaction.emoji)}
                    className={`px-2 py-1 border rounded-full text-sm flex items-center gap-1 transition ${
                      reaction.hasReacted
                        ? 'border-blue-500 bg-blue-50'
                        : 'hover:border-blue-500'
                    }`}
                  >
                    <span>{reaction.emoji}</span>
                    <span className="text-xs font-semibold">{reaction.count}</span>
                  </button>
                ))}
                <button className="px-2 py-1 border rounded-full hover:border-blue-500 text-sm opacity-60 hover:opacity-100">
                  +
                </button>
              </div>

              {/* Thread */}
              {message.replyCount > 0 && (
                <button className="text-blue-600 hover:underline text-sm font-semibold flex items-center gap-2">
                  <span>Comment</span>
                  {message.replyCount} replies
                </button>
              )}
            </div>
          </div>

          {/* Hover Actions */}
          {showActions && (
            <div className="absolute top-2 right-6 bg-white border rounded shadow-lg flex items-center">
              <button
                onClick={() => handleReact('')}
                className="p-2 hover:bg-gray-100"
                title="React"
              >
                :)
              </button>
              <button className="p-2 hover:bg-gray-100" title="Reply in thread">Comment</button>
              <button className="p-2 hover:bg-gray-100" title="More">•••</button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does the Message interface contain?

  2. 2. What is the isEdited field used for in Message?

These are 2 of 8 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the steps for creating a thread reply:

  • Code editor

    Chat message component

  • Vertical ordering

    Order the process of sending a message in MessageInput:

  • Code editor

    Chat message input

  • Vertical ordering

    Arrange the message elements in MessageItem from top to bottom:

  • Horizontal ordering

    Arrange the display of the thread reply count (one line, left to right):

  • Vertical ordering

    Order the steps for editing your own message:

  • Horizontal ordering

    Arrange the hover action buttons (one row, left to right):

Useful articles