Cours Vibe coding avec l'IA · Module 8: Clone de Slack
Mentions et notifications
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. Quels types de mentions existe-t-il dans Slack ?
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 :