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

Mentions et notifications

8 min de lecture
Dans cette leçon4

#general bouillonne, et tu veux seulement savoir quand quelqu'un a besoin de toi. Slack règle cela avec les mentions : tu tapes @ et un nom, et la personne reçoit une notification, comme une cloche qui appelle un marin précis sur le pont.

Les types de mentions

Quatre mentions existent : @user appelle une personne, @channel tous les membres du canal, @here seulement ses membres actifs, et @everyone tout le canal général (#general dans Slack), qui réunit tout l'espace sauf les invités. parseMentions les trouve dans le contenu, et renderContent les met en évidence :

1interface Mention {
2  type: 'user' | 'channel' | 'here' | 'everyone';
3  userId?: string;
4  position: { start: number; end: number }; // dans la chaîne de contenu
5}
6
7// Analyse des mentions dans le contenu du message
8function parseMentions(content: string, members: Member[]): {
9  parsed: string;
10  mentions: Mention[];
11} {
12  const mentions: Mention[] = [];
13  let parsed = content;
14
15  // mentions @user
16  const userMentionRegex = /@(\w+)/g;
17  let match;
18
19  while ((match = userMentionRegex.exec(content)) !== null) {
20    const username = match[1];
21    const member = members.find(m =>
22      m.name.toLowerCase().replace(/\s+/g, '') === username.toLowerCase()
23    );
24
25    if (member) {
26      mentions.push({
27        type: 'user',
28        userId: member.id,
29        position: { start: match.index, end: match.index + match[0].length }
30      });
31    }
32  }
33
34  // @channel, @here, @everyone
35  if (content.includes('@channel')) {
36    mentions.push({ type: 'channel', position: { start: 0, end: 0 } });
37  }
38  if (content.includes('@here')) {
39    mentions.push({ type: 'here', position: { start: 0, end: 0 } });
40  }
41  if (content.includes('@everyone')) {
42    mentions.push({ type: 'everyone', position: { start: 0, end: 0 } });
43  }
44
45  return { parsed, mentions };
46}
47
48// Rendu des mentions dans l'interface
49function renderContent(content: string, mentions: Mention[], members: Member[]) {
50  if (mentions.length === 0) return content;
51
52  const parts: React.ReactNode[] = [];
53  let lastIndex = 0;
54
55  // Trie les mentions par position
56  const sortedMentions = [...mentions].sort((a, b) => a.position.start - b.position.start);
57
58  sortedMentions.forEach((mention, idx) => {
59    // Ajoute le texte avant la mention
60    if (mention.position.start > lastIndex) {
61      parts.push(content.substring(lastIndex, mention.position.start));
62    }
63
64    // Ajoute la mention
65    const mentionText = content.substring(mention.position.start, mention.position.end);
66    parts.push(
67      <span key={idx} className="bg-blue-100 text-blue-700 px-1 rounded font-semibold">
68        {mentionText}
69      </span>
70    );
71
72    lastIndex = mention.position.end;
73  });
74
75  // Ajoute le texte restant
76  if (lastIndex < content.length) {
77    parts.push(content.substring(lastIndex));
78  }
79
80  return <>{parts}</>;
81}

parseMentions cherche chaque @username avec l'expression /@(\w+)/g et le compare aux noms des membres en minuscules et sans espaces : @redbeard correspond donc à « Redbeard ». Pour chaque correspondance, elle stocke le userId et la position dans le texte. \w ne couvre que les lettres latines sans accents : @Élodie échoue, mais un motif avec le drapeau u aide, par exemple /@([\p{L}\d_]+)/gu. renderContent découpe le texte selon les positions et enveloppe chaque mention dans un <span> avec les classes bg-blue-100 et font-semibold. Le contenu du message ne change pas, seul son affichage diffère.

Les suggestions après @

Quand quelqu'un tape @, une liste de membres apparaît au-dessus du champ :

1'use client';
2
3import { useState, useEffect, useRef } from 'react';
4
5interface MentionAutocompleteProps {
6  members: Member[];
7  onSelect: (member: Member) => void;
8  trigger: string; // position du caractère "@"
9  cursorPosition: number;
10}
11
12export default function MentionAutocomplete({
13  members,
14  onSelect,
15  trigger,
16  cursorPosition
17}: MentionAutocompleteProps) {
18  const [query, setQuery] = useState('');
19  const [selectedIndex, setSelectedIndex] = useState(0);
20
21  const filteredMembers = members.filter(member =>
22    member.name.toLowerCase().includes(query.toLowerCase())
23  ).slice(0, 5);
24
25  return (
26    <div className="absolute bottom-full left-0 mb-2 bg-white border rounded-lg shadow-lg w-64 max-h-64 overflow-y-auto">
27      {filteredMembers.length === 0 ? (
28        <div className="p-3 text-sm text-gray-500">No matches found</div>
29      ) : (
30        filteredMembers.map((member, idx) => (
31          <button
32            key={member.id}
33            onClick={() => onSelect(member)}
34            className={`w-full flex items-center gap-3 px-3 py-2 hover:bg-blue-50 ${
35              idx === selectedIndex ? 'bg-blue-50' : ''
36            }`}
37          >
38            <div className="relative">
39              <img
40                src={member.avatar}
41                alt={member.name}
42                className="w-8 h-8 rounded"
43              />
44              <div
45                className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border border-white ${
46                  member.status === 'online' ? 'bg-green-400' :
47                  member.status === 'away' ? 'bg-yellow-400' : 'bg-gray-400'
48                }`}
49              />
50            </div>
51            <div className="flex-1 text-left">
52              <div className="font-semibold text-sm">{member.name}</div>
53              <div className="text-xs text-gray-600 truncate">
54                {member.statusText || member.role}
55              </div>
56            </div>
57          </button>
58        ))
59      )}
60
61      <div className="border-t p-2 text-xs text-gray-500">
62        <div className="flex items-center gap-2">
63          <span>@channel</span>
64          <span className="text-gray-400">•</span>
65          <span>notify all members</span>
66        </div>
67        <div className="flex items-center gap-2 mt-1">
68          <span>@here</span>
69          <span className="text-gray-400">•</span>
70          <span>notify active members</span>
71        </div>
72      </div>
73    </div>
74  );
75}

La liste filtre les membres selon une partie du nom, en montre cinq au plus (slice(0, 5)) avec le statut de chacun. En bas, elle rappelle que @channel notifie tous les membres et @here les actifs. Un clic sur une personne n'envoie pas de message : il passe le membre choisi à onSelect, qui insère la mention. L'état selectedIndex servira quand tu ajouteras la sélection aux flèches.

Le centre de notifications

Le parcours : quelqu'un écrit un message avec une @mention, le système analyse les mentions et crée une Notification pour la personne mentionnée, qui voit un badge et entend un son. La notification et le panneau avec la liste s'écrivent ainsi :

1interface Notification {
2  id: string;
3  type: 'mention' | 'reply' | 'reaction' | 'dm';
4
5  messageId: string;
6  channelId: string;
7  channelName: string;
8
9  from: {
10    id: string;
11    name: string;
12    avatar: string;
13  };
14
15  preview: string; // aperçu du message
16  timestamp: Date;
17  isRead: boolean;
18}
19
20interface NotificationCenterProps {
21  notifications: Notification[];
22  onMarkAsRead: (notificationId: string) => void;
23  onMarkAllAsRead: () => void;
24  onNavigate: (channelId: string, messageId: string) => void;
25}
26
27export default function NotificationCenter({
28  notifications,
29  onMarkAsRead,
30  onMarkAllAsRead,
31  onNavigate
32}: NotificationCenterProps) {
33  const unreadCount = notifications.filter(n => !n.isRead).length;
34
35  const getNotificationIcon = (type: Notification['type']) => {
36    switch (type) {
37      case 'mention': return '@';
38      case 'reply': return 'Réponse';
39      case 'reaction': return 'Réaction';
40      case 'dm': return 'Message';
41    }
42  };
43
44  return (
45    <div className="w-96 bg-white border-l flex flex-col h-screen">
46      {/* En-tête */}
47      <div className="p-4 border-b">
48        <div className="flex items-center justify-between mb-3">
49          <h2 className="text-xl font-bold">Notifications</h2>
50          {unreadCount > 0 && (
51            <button
52              onClick={onMarkAllAsRead}
53              className="text-sm text-blue-600 hover:underline"
54            >
55              Mark all as read
56            </button>
57          )}
58        </div>
59
60        {unreadCount > 0 && (
61          <div className="text-sm text-gray-600">
62            {unreadCount} unread {unreadCount === 1 ? 'notification' : 'notifications'}
63          </div>
64        )}
65      </div>
66
67      {/* Liste des notifications */}
68      <div className="flex-1 overflow-y-auto">
69        {notifications.length === 0 ? (
70          <div className="p-8 text-center text-gray-500">
71            <div className="text-4xl mb-2">◆</div>
72            <p>No notifications yet</p>
73          </div>
74        ) : (
75          notifications.map(notification => (
76            <div
77              key={notification.id}
78              onClick={() => {
79                onNavigate(notification.channelId, notification.messageId);
80                if (!notification.isRead) {
81                  onMarkAsRead(notification.id);
82                }
83              }}
84              className={`p-4 border-b cursor-pointer hover:bg-gray-50 ${
85                !notification.isRead ? 'bg-blue-50' : ''
86              }`}
87            >
88              <div className="flex gap-3">
89                <div className="relative">
90                  <img
91                    src={notification.from.avatar}
92                    alt={notification.from.name}
93                    className="w-10 h-10 rounded"
94                  />
95                  <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-white rounded-full flex items-center justify-center border">
96                    <span className="text-xs">
97                      {getNotificationIcon(notification.type)}
98                    </span>
99                  </div>
100                </div>
101
102                <div className="flex-1 min-w-0">
103                  <div className="flex items-start justify-between mb-1">
104                    <div className="font-semibold text-sm">
105                      {notification.from.name}
106                      {notification.type === 'mention' && ' mentioned you'}
107                      {notification.type === 'reply' && ' replied to your thread'}
108                      {notification.type === 'reaction' && ' reacted to your message'}
109                      {notification.type === 'dm' && ' sent you a message'}
110                    </div>
111                    {!notification.isRead && (
112                      <div className="w-2 h-2 bg-blue-600 rounded-full shrink-0 mt-1"></div>
113                    )}
114                  </div>
115
116                  <div className="text-sm text-gray-600 mb-1">
117                    #{notification.channelName}
118                  </div>
119
120                  <div className="text-sm text-gray-700 truncate">
121                    {notification.preview}
122                  </div>
123
124                  <div className="text-xs text-gray-500 mt-1">
125                    {formatTimeAgo(notification.timestamp)}
126                  </div>
127                </div>
128              </div>
129            </div>
130          ))
131        )}
132      </div>
133    </div>
134  );
135}
136
137function formatTimeAgo(date: Date): string {
138  const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
139
140  if (seconds < 60) return 'just now';
141  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
142  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
143  if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
144
145  return date.toLocaleDateString();
146}

Les types sont mention, reply (réponse dans un fil), reaction et dm. Chaque notification a aussi messageId et channelName, l'adresse du message, from avec l'expéditeur, preview avec un extrait du contenu et l'indicateur isRead. getNotificationIcon renvoie @ pour une mention et laisse aux autres types des emplacements vides pour tes icônes : bulle pour les réponses, sourire pour les réactions, enveloppe pour les messages privés. unreadCount vaut notifications.filter(n => !n.isRead).length. S'il dépasse zéro, le bouton Mark all as read (« Tout marquer comme lu ») apparaît : il appelle onMarkAllAsRead, et le compteur retombe à zéro. Un clic sur une notification non lue appelle onNavigate vers le message et onMarkAsRead. Le panneau ne modifie pas les notifications lui-même : il le demande au parent. Une liste vide affiche une icône et No notifications yet (« Aucune notification pour le moment »). formatTimeAgo a des seuils : moins de 60 s « just now » (à l'instant), puis les minutes sous 3600 s, les heures sous 86400 s et les jours sous 604800 s.

Le badge de non-lus

Dernier élément : le compteur près du nom d'un canal.

1interface UnreadBadgeProps {
2  count: number;
3  isMention?: boolean; // style spécial pour les mentions
4}
5
6export default function UnreadBadge({ count, isMention }: UnreadBadgeProps) {
7  if (count === 0) return null;
8
9  return (
10    <span
11      className={`ml-auto px-2 py-0.5 rounded-full text-xs font-bold ${
12        isMention
13          ? 'bg-red-500 text-white'
14          : 'bg-gray-500 text-white'
15      }`}
16    >
17      {count > 99 ? '99+' : count}
18    </span>
19  );
20}

À zéro, le badge disparaît (return null). Avec isMention, il prend un fond rouge bg-red-500 au lieu du gris bg-gray-500, pour que les mentions crient plus fort que les messages ordinaires. Au-delà de 99, il affiche 99+ pour ne pas déborder de la barre, et la vraie valeur reste inchangée dans les données.

Conseil du Capitaine Barbe-Rouge : @channel et @everyone réveillent tout l'équipage, alors utilise-les avec parcimonie.

À retenir : une mention est un nom précédé de @, et une notification ramène au message. Le parcours complet de Communications du navire : choisir un canal dans la sidebar, envoyer un message, recevoir une réaction ou une mention, puis voir la notification dans le centre de notifications. Dans l'éditeur ci-dessous, tu as les suggestions de mentions : tape @ dans le champ du message et choisis une personne dans la liste, puis fais les exercices.

Code de cette leçon : App.jsx
1// Autocomplétion des mentions
2import { useState } from 'react';
3
4const members = [
5  { id: 1, name: 'Jack Sparrow', avatar: '', status: 'online', role: 'Captain' },
6  { id: 2, name: 'Will Turner', avatar: '', status: 'away', role: 'Swordsman' },
7  { id: 3, name: 'Elizabeth Swan', avatar: '', status: 'online', role: 'Navigator' },
8  { id: 4, name: 'Barbossa', avatar: '', status: 'offline', role: 'First Mate' },
9  { id: 5, name: 'Davy Jones', avatar: '', status: 'online', role: 'Captain' }
10];
11
12export default function App() {
13  const [content, setContent] = useState('');
14  const [showMentions, setShowMentions] = useState(false);
15  const [mentionQuery, setMentionQuery] = useState('');
16  const [selectedIndex, setSelectedIndex] = useState(0);
17
18  const handleChange = (e) => {
19    const value = e.target.value;
20    setContent(value);
21
22    const lastAtIndex = value.lastIndexOf('@');
23    if (lastAtIndex !== -1 && lastAtIndex === value.length - 1) {
24      setShowMentions(true);
25      setMentionQuery('');
26      setSelectedIndex(0);
27    } else if (lastAtIndex !== -1 && lastAtIndex < value.length - 1) {
28      const query = value.substring(lastAtIndex + 1);
29      if (!query.includes(' ')) {
30        setShowMentions(true);
31        setMentionQuery(query);
32      } else {
33        setShowMentions(false);
34      }
35    } else {
36      setShowMentions(false);
37    }
38  };
39
40  const selectMember = (member) => {
41    const lastAtIndex = content.lastIndexOf('@');
42    const newContent = content.substring(0, lastAtIndex) + `@${member.name.replace(/\s+/g, '')} `;
43    setContent(newContent);
44    setShowMentions(false);
45  };
46
47  const filteredMembers = members.filter(m =>
48    m.name.toLowerCase().includes(mentionQuery.toLowerCase())
49  );
50
51  return (
52    <div className="min-h-screen bg-gray-100 p-8">
53      <div className="max-w-4xl mx-auto">
54        <div className="bg-white rounded-lg shadow p-4">
55          <div className="relative">
56            <textarea
57              value={content}
58              onChange={handleChange}
59              placeholder="Tape @ pour mentionner quelqu'un..."
60              className="w-full p-3 border rounded-lg resize-none"
61              rows={4}
62            />
63
64            {/* Autocomplétion des mentions */}
65            {showMentions && (
66              <div className="absolute bottom-full left-0 mb-2 bg-white border rounded-lg shadow-lg w-64 max-h-64 overflow-y-auto">
67                {filteredMembers.length === 0 ? (
68                  <div className="p-3 text-sm text-gray-500">No matches found</div>
69                ) : (
70                  filteredMembers.map((member, idx) => (
71                    <button
72                      key={member.id}
73                      onClick={() => selectMember(member)}
74                      className={`w-full flex items-center gap-3 px-3 py-2 hover:bg-blue-50 ${
75                        idx === selectedIndex ? 'bg-blue-50' : ''
76                      }`}
77                    >
78                      <div className="relative text-2xl">
79                        {member.avatar}
80                        <div
81                          className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border border-white ${
82                            member.status === 'online' ? 'bg-green-400' :
83                            member.status === 'away' ? 'bg-yellow-400' : 'bg-gray-400'
84                          }`}
85                        />
86                      </div>
87                      <div className="flex-1 text-left">
88                        <div className="font-semibold text-sm">{member.name}</div>
89                        <div className="text-xs text-gray-600">{member.role}</div>
90                      </div>
91                    </button>
92                  ))
93                )}
94
95                <div className="border-t p-2 text-xs text-gray-500">
96                  <div>@channel - notify all members</div>
97                  <div>@here - notify active members</div>
98                </div>
99              </div>
100            )}
101          </div>
102        </div>
103      </div>
104    </div>
105  );
106}

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. Quels types de mentions existe-t-il dans Slack ?

  2. 2. En quoi @channel diffère-t-il de @here ?

Voici 2 des 12 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Clique dans l'ordre

    Construis une mention d'utilisateur dans le contenu du message :

  • Éditeur de code

    Fenêtre d'autocomplétion des mentions

  • Tri horizontal

    Ordonne le processus de notification d'une mention :

  • Tri vertical

    Respecte l'ordre : range les types de notification selon leur description, de mention à dm :

  • Éditeur de code

    Panneau de notifications

  • Clique dans l'ordre

    Construis la mention spéciale qui notifie tous les membres du canal :

  • Tri vertical

    Ordonne les seuils de temps de formatTimeAgo du plus petit au plus grand :

  • Clique dans l'ordre

    Construis la mention spéciale qui notifie tout le monde :

  • Tri horizontal

    Ordonne le texte du bouton qui marque les notifications comme lues (une ligne, de gauche à droite) :

  • Tri horizontal

    Ordonne les étapes pour marquer toutes les notifications comme lues :

  • Clique dans l'ordre

    Construis le texte du badge quand count > 99 (clique sur les éléments) :

  • Tri vertical

    Ordonne le parcours complet dans Communications du navire, du choix d'un canal jusqu'à une réaction :

Articles utiles