Kurs vibe coding z AI · Moduł 8: Slack Clone
Wiadomości i wątki - rozmowy
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. Co zawiera Message interface?
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):