Cours Vibe coding avec l'IA · Module 8: Clone de Slack
Messages et fils de discussion
Dans cette leçon3
Un canal sans messages, c'est une cabine vide. Dans Slack, l'essentiel est la conversation : des messages avec réactions et pièces jointes, et des fils qui n'encombrent pas le canal principal. Construisons un composant de message, un champ de saisie et un panneau de fil.
Le composant de message
MessageItem reçoit le message, l'auteur et des fonctions pour réagir, répondre, modifier et supprimer :
1'use client';
2
3import { useState } from 'react';
4
5interface MessageItemProps {
6 message: Message;
7 author: Member;
8 currentUserId: string;
9 onReact: (messageId: string, emoji: string) => void;
10 onReply: (messageId: string) => void;
11 onEdit: (messageId: string, newContent: string) => void;
12 onDelete: (messageId: string) => void;
13}
14
15export default function MessageItem({
16 message,
17 author,
18 currentUserId,
19 onReact,
20 onReply,
21 onEdit,
22 onDelete
23}: MessageItemProps) {
24 const [showActions, setShowActions] = useState(false);
25 const [isEditing, setIsEditing] = useState(false);
26 const [editContent, setEditContent] = useState(message.content);
27
28 const isOwnMessage = author.id === currentUserId;
29
30 const formatTime = (date: Date) => {
31 const now = new Date();
32 const isToday = date.toDateString() === now.toDateString();
33
34 if (isToday) {
35 return date.toLocaleTimeString('en-US', {
36 hour: '2-digit',
37 minute: '2-digit'
38 });
39 }
40 return date.toLocaleDateString('en-US', {
41 month: 'short',
42 day: 'numeric'
43 }) + ' at ' + date.toLocaleTimeString('en-US', {
44 hour: '2-digit',
45 minute: '2-digit'
46 });
47 };
48
49 const handleSaveEdit = () => {
50 if (editContent.trim() && editContent !== message.content) {
51 onEdit(message.id, editContent);
52 }
53 setIsEditing(false);
54 };
55
56 return (
57 <div
58 className="group hover:bg-gray-50 px-6 py-2 relative"
59 onMouseEnter={() => setShowActions(true)}
60 onMouseLeave={() => setShowActions(false)}
61 >
62 <div className="flex gap-3">
63 {/* Avatar */}
64 <img
65 src={author.avatar}
66 alt={author.name}
67 className="w-10 h-10 rounded mt-1"
68 />
69
70 <div className="flex-1 min-w-0">
71 {/* En-tête */}
72 <div className="flex items-baseline gap-2 mb-1">
73 <span className="font-bold text-gray-900">{author.name}</span>
74 <span className="text-xs text-gray-500">{formatTime(message.timestamp)}</span>
75 {message.isEdited && (
76 <span className="text-xs text-gray-500">(edited)</span>
77 )}
78 {message.isPinned && (
79 <span className="text-xs">Épinglé</span>
80 )}
81 </div>
82
83 {/* Contenu */}
84 {isEditing ? (
85 <div>
86 <textarea
87 value={editContent}
88 onChange={(e) => setEditContent(e.target.value)}
89 className="w-full p-2 border rounded resize-none"
90 rows={3}
91 autoFocus
92 />
93 <div className="flex gap-2 mt-2">
94 <button
95 onClick={handleSaveEdit}
96 className="px-4 py-1.5 bg-green-600 text-white rounded text-sm hover:bg-green-700"
97 >
98 Save
99 </button>
100 <button
101 onClick={() => {
102 setEditContent(message.content);
103 setIsEditing(false);
104 }}
105 className="px-4 py-1.5 border rounded text-sm hover:bg-gray-50"
106 >
107 Cancel
108 </button>
109 </div>
110 </div>
111 ) : (
112 <>
113 <p className="text-gray-900 break-words whitespace-pre-wrap">
114 {message.content}
115 </p>
116
117 {/* Pièces jointes */}
118 {message.attachments && message.attachments.length > 0 && (
119 <div className="mt-2 space-y-2">
120 {message.attachments.map(attachment => (
121 <div
122 key={attachment.id}
123 className="border rounded p-3 hover:bg-gray-50 cursor-pointer"
124 >
125 <div className="flex items-center gap-3">
126 {attachment.type === 'image' ? (
127 <img
128 src={attachment.url}
129 alt={attachment.name}
130 className="max-w-sm max-h-64 rounded"
131 />
132 ) : (
133 <>
134 <span className="text-sm font-semibold">Document</span>
135 <div>
136 <p className="font-medium text-blue-600">
137 {attachment.name}
138 </p>
139 <p className="text-xs text-gray-500">
140 {(attachment.size / 1024).toFixed(1)} KB
141 </p>
142 </div>
143 </>
144 )}
145 </div>
146 </div>
147 ))}
148 </div>
149 )}
150
151 {/* Réactions */}
152 {message.reactions.length > 0 && (
153 <div className="flex flex-wrap gap-1 mt-2">
154 {message.reactions.map((reaction, idx) => (
155 <button
156 key={idx}
157 onClick={() => onReact(message.id, reaction.emoji)}
158 className="px-2 py-1 border rounded-full hover:border-blue-500 text-sm flex items-center gap-1"
159 >
160 <span>{reaction.emoji}</span>
161 <span className="text-xs font-semibold">
162 {reaction.count}
163 </span>
164 </button>
165 ))}
166 <button className="px-2 py-1 border rounded-full hover:border-blue-500 text-sm opacity-60 hover:opacity-100">
167 +
168 </button>
169 </div>
170 )}
171
172 {/* Réponses du fil */}
173 {message.replyCount > 0 && (
174 <button
175 onClick={() => onReply(message.id)}
176 className="mt-2 text-blue-600 hover:underline text-sm font-semibold flex items-center gap-2"
177 >
178 <span>Commenter</span>
179 {message.replyCount} {message.replyCount === 1 ? 'reply' : 'replies'}
180 </button>
181 )}
182 </>
183 )}
184 </div>
185 </div>
186
187 {/* Actions au survol */}
188 {showActions && !isEditing && (
189 <div className="absolute top-0 right-6 bg-white border rounded shadow-lg flex items-center">
190 <button
191 onClick={() => onReact(message.id, '')}
192 className="p-2 hover:bg-gray-100"
193 title="Add reaction"
194 >
195 :)
196 </button>
197 <button
198 onClick={() => onReply(message.id)}
199 className="p-2 hover:bg-gray-100"
200 title="Reply in thread"
201 >
202 Commenter
203 </button>
204 {isOwnMessage && (
205 <>
206 <button
207 onClick={() => setIsEditing(true)}
208 className="p-2 hover:bg-gray-100"
209 title="Edit message"
210 >
211 Modifier
212 </button>
213 <button
214 onClick={() => onDelete(message.id)}
215 className="p-2 hover:bg-gray-100 text-red-600"
216 title="Delete message"
217 >
218 Supprimer
219 </button>
220 </>
221 )}
222 <button className="p-2 hover:bg-gray-100" title="More">
223 •••
224 </button>
225 </div>
226 )}
227 </div>
228 );
229}De haut en bas : en-tête (auteur et heure), contenu, réactions et nombre de réponses du fil. formatTime n'affiche que l'heure pour les messages du jour, et aussi la date pour les autres. Quand isEdited est vrai, (edited) apparaît près de l'heure (le badge « modifié »), et isPinned affiche une icône d'épingle. Le composant ne modifie pas le message lui-même, il appelle des fonctions du parent comme onReact ou onEdit. Une pièce jointe a le type image, video, file ou link : les images s'affichent directement, le reste comme nom de fichier avec sa taille. replyCount, le nombre de réponses du fil, s'affiche par exemple « 3 replies » (3 réponses) ou « 1 reply ».
Au survol, des actions apparaissent dans cet ordre : react, reply, edit, delete. Seul l'auteur voit la modification et la suppression (isOwnMessage). Pour modifier : clic sur Edit message (Modifier le message), changement du texte, clic sur Save (Enregistrer), et handleSaveEdit appelle onEdit si le texte a changé. Le parent définit alors isEdited, et (edited) apparaît.
Une réaction, c'est un emoji avec une liste d'utilisateurs et un compteur. Un clic ajoute ta réaction, ou la retire si elle y était déjà. Le parent peut écrire cette logique en une petite fonction :
1function toggleReaction(reaction: Reaction, userId: string): Reaction {
2 const users = reaction.users.includes(userId)
3 ? reaction.users.filter(id => id !== userId)
4 : [...reaction.users, userId];
5 return { ...reaction, users, count: users.length };
6}La fonction renvoie une nouvelle réaction au lieu de modifier l'ancienne, et le compteur correspond toujours à la longueur de la liste.
Le champ de message
MessageInput gère le texte, les pièces jointes et les raccourcis clavier :
1'use client';
2
3import { useState, useRef } from 'react';
4
5interface MessageInputProps {
6 channelName: string;
7 onSend: (content: string, attachments?: File[]) => void;
8}
9
10export default function MessageInput({
11 channelName,
12 onSend
13}: MessageInputProps) {
14 const [content, setContent] = useState('');
15 const [attachments, setAttachments] = useState<File[]>([]);
16 const fileInputRef = useRef<HTMLInputElement>(null);
17
18 const handleSubmit = (e: React.FormEvent) => {
19 e.preventDefault();
20
21 if (content.trim() || attachments.length > 0) {
22 onSend(content, attachments);
23 setContent('');
24 setAttachments([]);
25 }
26 };
27
28 const handleKeyDown = (e: React.KeyboardEvent) => {
29 if (e.key === 'Enter' && !e.shiftKey) {
30 e.preventDefault();
31 handleSubmit(e);
32 }
33 };
34
35 return (
36 <div className="p-4 border-t bg-white">
37 <form onSubmit={handleSubmit}>
38 <div className="border rounded-lg overflow-hidden">
39 {/* Aperçu des pièces jointes */}
40 {attachments.length > 0 && (
41 <div className="bg-gray-50 p-2 border-b flex flex-wrap gap-2">
42 {attachments.map((file, idx) => (
43 <div
44 key={idx}
45 className="flex items-center gap-2 bg-white border rounded px-3 py-1"
46 >
47 <span className="text-sm">{file.name}</span>
48 <button
49 type="button"
50 onClick={() => setAttachments(attachments.filter((_, i) => i !== idx))}
51 className="text-gray-500 hover:text-gray-700"
52 >
53 ×
54 </button>
55 </div>
56 ))}
57 </div>
58 )}
59
60 {/* Saisie */}
61 <textarea
62 value={content}
63 onChange={(e) => setContent(e.target.value)}
64 onKeyDown={handleKeyDown}
65 placeholder={`Message #${channelName}`}
66 className="w-full p-3 resize-none outline-none"
67 rows={3}
68 />
69
70 {/* Barre d'outils */}
71 <div className="flex items-center justify-between px-3 py-2 bg-gray-50">
72 <div className="flex items-center gap-2">
73 <button
74 type="button"
75 onClick={() => fileInputRef.current?.click()}
76 className="p-1.5 hover:bg-gray-200 rounded"
77 title="Attach file"
78 >
79 Joindre
80 </button>
81 <button
82 type="button"
83 className="p-1.5 hover:bg-gray-200 rounded"
84 title="Emoji"
85 >
86 :)
87 </button>
88 <button
89 type="button"
90 className="p-1.5 hover:bg-gray-200 rounded"
91 title="Mention"
92 >
93 @
94 </button>
95 <button
96 type="button"
97 className="p-1.5 hover:bg-gray-200 rounded"
98 title="Code block"
99 >
100 {'</>'}
101 </button>
102 </div>
103
104 <button
105 type="submit"
106 disabled={!content.trim() && attachments.length === 0}
107 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"
108 >
109 Send
110 </button>
111 </div>
112 </div>
113
114 <input
115 ref={fileInputRef}
116 type="file"
117 multiple
118 className="hidden"
119 onChange={(e) => {
120 if (e.target.files) {
121 setAttachments([...attachments, ...Array.from(e.target.files)]);
122 }
123 }}
124 />
125 </form>
126 </div>
127 );
128}L'envoi se passe ainsi : tu tapes du texte, appuies sur Entrée sans Shift, handleSubmit appelle onSend(content), puis vide le champ avec setContent(''). Shift+Entrée ajoute une nouvelle ligne au lieu d'envoyer, car handleKeyDown n'intercepte qu'Entrée seule. Slack fait de même, réglage inversable dans ses préférences. Le champ de fichier caché (attribut multiple) s'ouvre via fileInputRef.current?.click(), et les fichiers choisis rejoignent la liste d'aperçu. La croix près d'un fichier le retire avec filter, et le bouton Send est désactivé s'il n'y a ni texte ni pièce jointe.
Le panneau de fil
Répondre dans un fil se passe ainsi : clic sur Reply in thread sur un message, ouverture du ThreadPanel sur le côté, rédaction de la réponse, envoi.
1'use client';
2
3interface ThreadPanelProps {
4 parentMessage: Message;
5 parentAuthor: Member;
6 replies: Message[];
7 onClose: () => void;
8 onSendReply: (content: string) => void;
9}
10
11export default function ThreadPanel({
12 parentMessage,
13 parentAuthor,
14 replies,
15 onClose,
16 onSendReply
17}: ThreadPanelProps) {
18 return (
19 <div className="w-96 border-l bg-white flex flex-col h-screen">
20 {/* En-tête */}
21 <div className="p-4 border-b flex items-center justify-between">
22 <h3 className="font-bold">Thread</h3>
23 <button
24 onClick={onClose}
25 className="hover:bg-gray-100 p-2 rounded"
26 >
27 ×
28 </button>
29 </div>
30
31 {/* Message parent */}
32 <div className="p-4 border-b">
33 <div className="flex gap-3">
34 <img
35 src={parentAuthor.avatar}
36 alt={parentAuthor.name}
37 className="w-10 h-10 rounded"
38 />
39 <div className="flex-1">
40 <div className="font-bold mb-1">{parentAuthor.name}</div>
41 <p className="text-gray-900 whitespace-pre-wrap">
42 {parentMessage.content}
43 </p>
44 </div>
45 </div>
46 <div className="mt-3 text-sm text-gray-600">
47 {replies.length} {replies.length === 1 ? 'reply' : 'replies'}
48 </div>
49 </div>
50
51 {/* Réponses */}
52 <div className="flex-1 overflow-y-auto">
53 {replies.map(reply => {
54 return (
55 <div key={reply.id} className="p-4 border-b">
56 <div className="flex gap-3">
57 <img
58 src={reply.author.avatar}
59 alt={reply.author.name}
60 className="w-8 h-8 rounded"
61 />
62 <div className="flex-1">
63 <div className="flex items-baseline gap-2 mb-1">
64 <span className="font-bold text-sm">{reply.author.name}</span>
65 <span className="text-xs text-gray-500">
66 {reply.timestamp.toLocaleTimeString()}
67 </span>
68 </div>
69 <p className="text-sm text-gray-900">{reply.content}</p>
70 </div>
71 </div>
72 </div>
73 );
74 })}
75 </div>
76
77 {/* Champ de réponse */}
78 <div className="p-3 border-t">
79 <MessageInput
80 channelName="thread"
81 onSend={onSendReply}
82 />
83 </div>
84 </div>
85 );
86}Le panneau affiche le message parent, le nombre de réponses et leur liste, et réutilise MessageInput en bas. Il n'enregistre rien lui-même : il transmet la nouvelle réponse au parent via onSendReply. Attention : Message n'a que authorId, donc avant d'utiliser reply.author, joins les données de l'auteur, par exemple avec members.find(...).
Conseil du Capitaine Barbe-Rouge : déplace une longue discussion dans un fil. Le canal reste lisible, et qui veut les détails ouvre le panneau latéral.
À retenir : un message, c'est un contenu, des réactions et un fil ; Entrée envoie, Shift+Entrée passe à la ligne. Dans l'éditeur ci-dessous, tu as un message avec des réactions et des actions au survol, et les exercices porteront sur les champs du message, les actions et l'envoi.
Code de cette leçon : App.jsx
1// Élément de message avec réactions
2import { useState } from 'react';
3
4const message = {
5 id: 1,
6 author: { name: 'Jack Sparrow', avatar: '' },
7 content: `Found a promising lead on the Treasure of Cortez!
8
9According to the ancient map, it's located on Isla de Muerta. We should set sail at dawn.`,
10 timestamp: new Date('2024-01-15T10:30:00'),
11 isEdited: false,
12 reactions: [
13 { emoji: '', count: 5, hasReacted: false },
14 { emoji: '', count: 3, hasReacted: false },
15 { emoji: '', count: 2, hasReacted: false }
16 ],
17 replyCount: 4
18};
19
20export default function App() {
21 const [showActions, setShowActions] = useState(false);
22 const [reactions, setReactions] = useState(message.reactions);
23
24 const handleReact = (emoji) => {
25 setReactions(reactions.map(r => {
26 if (r.emoji === emoji) {
27 return {
28 ...r,
29 count: r.hasReacted ? r.count - 1 : r.count + 1,
30 hasReacted: !r.hasReacted
31 };
32 }
33 return r;
34 }));
35 };
36
37 return (
38 <div className="min-h-screen bg-gray-100 p-8">
39 <div className="max-w-4xl mx-auto bg-white rounded-lg shadow">
40 <div
41 className="group hover:bg-gray-50 px-6 py-3 relative"
42 onMouseEnter={() => setShowActions(true)}
43 onMouseLeave={() => setShowActions(false)}
44 >
45 <div className="flex gap-3">
46 {/* Avatar */}
47 <div className="text-4xl">{message.author.avatar}</div>
48
49 <div className="flex-1 min-w-0">
50 {/* En-tête */}
51 <div className="flex items-baseline gap-2 mb-1">
52 <span className="font-bold text-gray-900">{message.author.name}</span>
53 <span className="text-xs text-gray-500">
54 {message.timestamp.toLocaleTimeString()}
55 </span>
56 {message.isEdited && (
57 <span className="text-xs text-gray-500">(edited)</span>
58 )}
59 </div>
60
61 {/* Contenu */}
62 <p className="text-gray-900 whitespace-pre-wrap mb-2">
63 {message.content}
64 </p>
65
66 {/* Réactions */}
67 <div className="flex flex-wrap gap-1 mb-2">
68 {reactions.map((reaction, idx) => (
69 <button
70 key={idx}
71 onClick={() => handleReact(reaction.emoji)}
72 className={`px-2 py-1 border rounded-full text-sm flex items-center gap-1 transition ${
73 reaction.hasReacted
74 ? 'border-blue-500 bg-blue-50'
75 : 'hover:border-blue-500'
76 }`}
77 >
78 <span>{reaction.emoji}</span>
79 <span className="text-xs font-semibold">{reaction.count}</span>
80 </button>
81 ))}
82 <button className="px-2 py-1 border rounded-full hover:border-blue-500 text-sm opacity-60 hover:opacity-100">
83 +
84 </button>
85 </div>
86
87 {/* Fil de discussion */}
88 {message.replyCount > 0 && (
89 <button className="text-blue-600 hover:underline text-sm font-semibold flex items-center gap-2">
90 <span>Commenter</span>
91 {message.replyCount} replies
92 </button>
93 )}
94 </div>
95 </div>
96
97 {/* Actions au survol */}
98 {showActions && (
99 <div className="absolute top-2 right-6 bg-white border rounded shadow-lg flex items-center">
100 <button
101 onClick={() => handleReact('')}
102 className="p-2 hover:bg-gray-100"
103 title="React"
104 >
105 :)
106 </button>
107 <button className="p-2 hover:bg-gray-100" title="Reply in thread">Commenter</button>
108 <button className="p-2 hover:bg-gray-100" title="More">•••</button>
109 </div>
110 )}
111 </div>
112 </div>
113 </div>
114 );
115}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que contient l'interface Message ?
2. À quoi sert le champ isEdited dans Message ?
Voici 2 des 8 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne les étapes de la création d'une réponse dans un fil :
- Éditeur de code
Composant message de chat
- Tri vertical
Ordonne le processus d'envoi d'un message dans MessageInput :
- Éditeur de code
Champ de saisie du chat
- Tri vertical
Ordonne les éléments du message dans MessageItem de haut en bas :
- Tri horizontal
Ordonne l'affichage du nombre de réponses du fil (une ligne, de gauche à droite) :
- Tri vertical
Ordonne les étapes pour modifier ton propre message :
- Tri horizontal
Ordonne les boutons d'action au survol (une ligne, de gauche à droite) :