Cours Vibe coding avec l'IA · Module 8: Clone de Slack

Messages et fils de discussion

8 min de lecture
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. 1. Que contient l'interface Message ?

  2. 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) :

Articles utiles