Kurs vibe coding z AI · Moduł 8: Slack Clone

Wiadomości i wątki - rozmowy

8 min czytania
W tej lekcji3

Kanał bez wiadomości to pusta kajuta. W Slacku najważniejsza jest rozmowa: wiadomości z reakcjami i załącznikami oraz wątki, które nie zaśmiecają głównego kanału. Zbudujemy komponent wiadomości, pole do pisania i panel wątku.

Komponent wiadomości

MessageItem dostaje wiadomość, autora i funkcje do reagowania, odpowiadania, edycji i usuwania:

'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">Przypięte</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">Dokument</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>Komentarz</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"
          >
            Komentarz
          </button>
          {isOwnMessage && (
            <>
              <button
                onClick={() => setIsEditing(true)}
                className="p-2 hover:bg-gray-100"
                title="Edit message"
              >
                Edytuj
              </button>
              <button
                onClick={() => onDelete(message.id)}
                className="p-2 hover:bg-gray-100 text-red-600"
                title="Delete message"
              >
                Usuń
              </button>
            </>
          )}
          <button className="p-2 hover:bg-gray-100" title="More">
            •••
          </button>
        </div>
      )}
    </div>
  );
}

Od góry wiadomość ma nagłówek z autorem i godziną, treść, reakcje i liczbę odpowiedzi w wątku. formatTime pokazuje samą godzinę dla wiadomości z dzisiaj, a dla starszych także datę. Gdy isEdited jest prawdą, przy godzinie pojawia się (edited), a isPinned pokazuje ikonę przypięcia. Komponent nie zmienia wiadomości sam, tylko wywołuje funkcje od rodzica, takie jak onReact czy onEdit. Załącznik ma typ image, video, file albo link: obrazki wyświetlamy od razu, resztę jako nazwę pliku z rozmiarem. replyCount to liczba odpowiedzi w wątku, wyświetlana np. jako „3 replies”, a dla jednej odpowiedzi jako „1 reply”.

Po najechaniu kursorem pojawiają się akcje w kolejności react, reply, edit, delete. Edycję i usuwanie widzi tylko autor (isOwnMessage). Edycja przebiega tak: klikasz Edit message, zmieniasz tekst w polu, klikasz Save, a handleSaveEdit wywołuje onEdit, jeśli tekst się zmienił. Rodzic ustawia wtedy isEdited, więc pojawia się (edited).

Reakcja to emoji z listą użytkowników i licznikiem. Kliknięcie dodaje Twoją reakcję albo ją usuwa, jeśli już tam była. Tę logikę rodzic może zapisać jako małą funkcję:

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 };
}

Funkcja zwraca nową reakcję zamiast zmieniać starą, a licznik zawsze zgadza się z długością listy.

Pole wiadomości

MessageInput obsługuje tekst, załączniki i skróty klawiszowe:

'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"
              >
                Załącz
              </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>
  );
}

Wysyłanie przebiega tak: wpisujesz tekst, naciskasz Enter bez Shift, handleSubmit wywołuje onSend(content), a potem czyści pole przez setContent(''). Shift+Enter dodaje nową linię zamiast wysyłać wiadomość, bo handleKeyDown przechwytuje tylko sam Enter. Tak samo działa Slack, choć tam można to odwrócić w ustawieniach. Ukryte pole pliku z atrybutem multiple otwierasz przez fileInputRef.current?.click(), a wybrane pliki trafiają na listę podglądu. Krzyżyk przy pliku usuwa go przez filter, a przycisk Send jest wyłączony, gdy nie ma ani tekstu, ani załącznika.

Panel wątku

Odpowiedź w wątku wygląda tak: klikasz Reply in thread przy wiadomości, z boku otwiera się ThreadPanel, piszesz odpowiedź i ją wysyłasz.

'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 => {
          // Render reply messages
          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>
  );
}

Panel pokazuje wiadomość nadrzędną, liczbę odpowiedzi i ich listę, a na dole ponownie używa MessageInput. Sam niczego nie zapisuje, nową odpowiedź przekazuje rodzicowi przez onSendReply. Uwaga: Message ma tylko authorId, więc zanim użyjesz reply.author, dołącz dane autora, np. przez members.find(...).

Rada Kapitana Redbearda: długą dyskusję przenoś do wątku. Kanał zostaje czytelny, a kto chce szczegółów, otworzy panel z boku.

Zapamiętaj: wiadomość to treść, reakcje i wątek, a Enter wysyła, Shift+Enter łamie linię. W edytorze poniżej masz wiadomość z reakcjami i akcjami po najechaniu kursorem, a zadania sprawdzą pola wiadomości, akcje i wysyłanie.

Kod do tej lekcji: App.jsx
// Wiadomość z reakcjami
import { useState } from 'react';

const message = {
  id: 1,
  author: { name: 'Jack Sparrow', avatar: '' },
  content: `Mam obiecujący trop w sprawie Skarbu Corteza!

Według starożytnej mapy leży na Isla de Muerta. Powinniśmy wypłynąć o świcie.`,
  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">
            {/* Awatar */}
            <div className="text-4xl">{message.author.avatar}</div>

            <div className="flex-1 min-w-0">
              {/* Nagłówek */}
              <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">(edytowano)</span>
                )}
              </div>

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

              {/* Reakcje */}
              <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>

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

          {/* Akcje po najechaniu kursorem */}
          {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="Zareaguj"
              >
                :)
              </button>
              <button className="p-2 hover:bg-gray-100" title="Odpowiedz w wątku">Komentarz</button>
              <button className="p-2 hover:bg-gray-100" title="Więcej">•••</button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Co zawiera Message interface?

  2. 2. Do czego służy pole isEdited w Message?

To 2 z 8 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż kroki tworzenia thread reply:

  • Edytor kodu

    Chat message component

  • Układanie w pionie

    Uporządkuj proces wysłania wiadomości w MessageInput:

  • Edytor kodu

    Chat message input

  • Układanie w pionie

    Ułóż elementy wiadomości w MessageItem od góry do dołu:

  • Układanie w poziomie

    Ułóż wyświetlanie liczby odpowiedzi w wątku (jedna linia, lewo-prawo):

  • Układanie w pionie

    Uporządkuj kroki edycji własnej wiadomości:

  • Układanie w poziomie

    Ułóż przyciski hover actions (jeden rząd, lewo-prawo):

Przydatne artykuły