Vibe coding with AI course · Module 8: Slack Clone
Mentions and Notifications
In this lesson4
A lot happens in #general, and you only want to know when someone needs something from you. Slack solves this with mentions: you type @ and a name, and that person gets a notification. It's like a bell that calls one particular sailor on deck.
Kinds of mentions
There are four mentions: @user calls one person, @channel all members of the channel, @here only the active members of the channel, and @everyone reaches the whole general channel (#general in Slack), to which everyone in the workspace except guests belongs. The parseMentions function finds them in the content, and renderContent highlights them on screen:
interface Mention {
type: 'user' | 'channel' | 'here' | 'everyone';
userId?: string;
position: { start: number; end: number }; // in content string
}
// Parsing mentions from message content
function parseMentions(content: string, members: Member[]): {
parsed: string;
mentions: Mention[];
} {
const mentions: Mention[] = [];
let parsed = content;
// @user mentions
const userMentionRegex = /@(\w+)/g;
let match;
while ((match = userMentionRegex.exec(content)) !== null) {
const username = match[1];
const member = members.find(m =>
m.name.toLowerCase().replace(/\s+/g, '') === username.toLowerCase()
);
if (member) {
mentions.push({
type: 'user',
userId: member.id,
position: { start: match.index, end: match.index + match[0].length }
});
}
}
// @channel, @here, @everyone
if (content.includes('@channel')) {
mentions.push({ type: 'channel', position: { start: 0, end: 0 } });
}
if (content.includes('@here')) {
mentions.push({ type: 'here', position: { start: 0, end: 0 } });
}
if (content.includes('@everyone')) {
mentions.push({ type: 'everyone', position: { start: 0, end: 0 } });
}
return { parsed, mentions };
}
// Rendering mentions in the UI
function renderContent(content: string, mentions: Mention[], members: Member[]) {
if (mentions.length === 0) return content;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
// Sort mentions by position
const sortedMentions = [...mentions].sort((a, b) => a.position.start - b.position.start);
sortedMentions.forEach((mention, idx) => {
// Add text before mention
if (mention.position.start > lastIndex) {
parts.push(content.substring(lastIndex, mention.position.start));
}
// Add mention
const mentionText = content.substring(mention.position.start, mention.position.end);
parts.push(
<span key={idx} className="bg-blue-100 text-blue-700 px-1 rounded font-semibold">
{mentionText}
</span>
);
lastIndex = mention.position.end;
});
// Add remaining text
if (lastIndex < content.length) {
parts.push(content.substring(lastIndex));
}
return <>{parts}</>;
}parseMentions looks for every @username in the content with the expression /@(\w+)/g and compares them with member names written in lowercase and without spaces, so @redbeard matches "Redbeard". For each match it stores the userId and the position in the text. \w only covers basic Latin letters, so a name with accents or Polish letters, such as @Łukasz, won't match, while a pattern with the u flag helps, e.g. /@([\p{L}\d_]+)/gu. renderContent cuts the text by position and wraps each mention in a <span> with the bg-blue-100 and font-semibold classes. The message content doesn't change, it only looks different on screen.
Suggestions after typing @
When someone types @, a list of members appears above the field:
'use client';
import { useState, useEffect, useRef } from 'react';
interface MentionAutocompleteProps {
members: Member[];
onSelect: (member: Member) => void;
trigger: string; // "@" character position
cursorPosition: number;
}
export default function MentionAutocomplete({
members,
onSelect,
trigger,
cursorPosition
}: MentionAutocompleteProps) {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const filteredMembers = members.filter(member =>
member.name.toLowerCase().includes(query.toLowerCase())
).slice(0, 5);
return (
<div className="absolute bottom-full left-0 mb-2 bg-white border rounded-lg shadow-lg w-64 max-h-64 overflow-y-auto">
{filteredMembers.length === 0 ? (
<div className="p-3 text-sm text-gray-500">No matches found</div>
) : (
filteredMembers.map((member, idx) => (
<button
key={member.id}
onClick={() => onSelect(member)}
className={`w-full flex items-center gap-3 px-3 py-2 hover:bg-blue-50 ${
idx === selectedIndex ? 'bg-blue-50' : ''
}`}
>
<div className="relative">
<img
src={member.avatar}
alt={member.name}
className="w-8 h-8 rounded"
/>
<div
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border border-white ${
member.status === 'online' ? 'bg-green-400' :
member.status === 'away' ? 'bg-yellow-400' : 'bg-gray-400'
}`}
/>
</div>
<div className="flex-1 text-left">
<div className="font-semibold text-sm">{member.name}</div>
<div className="text-xs text-gray-600 truncate">
{member.statusText || member.role}
</div>
</div>
</button>
))
)}
<div className="border-t p-2 text-xs text-gray-500">
<div className="flex items-center gap-2">
<span>@channel</span>
<span className="text-gray-400">•</span>
<span>notify all members</span>
</div>
<div className="flex items-center gap-2 mt-1">
<span>@here</span>
<span className="text-gray-400">•</span>
<span>notify active members</span>
</div>
</div>
</div>
);
}The list filters members by part of their name, shows at most five (slice(0, 5)) and the status of each one. At the bottom it reminds you that @channel notifies all members and @here only the active ones. Clicking a person doesn't send a message yet, it only passes the chosen member to onSelect, which inserts the mention into the field. The selectedIndex state will come in handy when you add selection with the arrow keys.
The notification center
The flow looks like this: someone writes a message with an @mention, the system parses the mentions and creates a Notification for the mentioned person, who sees a badge and hears a sound. You can write the notification and the panel with the list like this:
interface Notification {
id: string;
type: 'mention' | 'reply' | 'reaction' | 'dm';
messageId: string;
channelId: string;
channelName: string;
from: {
id: string;
name: string;
avatar: string;
};
preview: string; // message preview
timestamp: Date;
isRead: boolean;
}
interface NotificationCenterProps {
notifications: Notification[];
onMarkAsRead: (notificationId: string) => void;
onMarkAllAsRead: () => void;
onNavigate: (channelId: string, messageId: string) => void;
}
export default function NotificationCenter({
notifications,
onMarkAsRead,
onMarkAllAsRead,
onNavigate
}: NotificationCenterProps) {
const unreadCount = notifications.filter(n => !n.isRead).length;
const getNotificationIcon = (type: Notification['type']) => {
switch (type) {
case 'mention': return '@';
case 'reply': return 'Reply';
case 'reaction': return 'Reaction';
case 'dm': return 'Message';
}
};
return (
<div className="w-96 bg-white border-l flex flex-col h-screen">
{/* Header */}
<div className="p-4 border-b">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-bold">Notifications</h2>
{unreadCount > 0 && (
<button
onClick={onMarkAllAsRead}
className="text-sm text-blue-600 hover:underline"
>
Mark all as read
</button>
)}
</div>
{unreadCount > 0 && (
<div className="text-sm text-gray-600">
{unreadCount} unread {unreadCount === 1 ? 'notification' : 'notifications'}
</div>
)}
</div>
{/* Notifications List */}
<div className="flex-1 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<div className="text-4xl mb-2">◆</div>
<p>No notifications yet</p>
</div>
) : (
notifications.map(notification => (
<div
key={notification.id}
onClick={() => {
onNavigate(notification.channelId, notification.messageId);
if (!notification.isRead) {
onMarkAsRead(notification.id);
}
}}
className={`p-4 border-b cursor-pointer hover:bg-gray-50 ${
!notification.isRead ? 'bg-blue-50' : ''
}`}
>
<div className="flex gap-3">
<div className="relative">
<img
src={notification.from.avatar}
alt={notification.from.name}
className="w-10 h-10 rounded"
/>
<div className="absolute -bottom-1 -right-1 w-5 h-5 bg-white rounded-full flex items-center justify-center border">
<span className="text-xs">
{getNotificationIcon(notification.type)}
</span>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-1">
<div className="font-semibold text-sm">
{notification.from.name}
{notification.type === 'mention' && ' mentioned you'}
{notification.type === 'reply' && ' replied to your thread'}
{notification.type === 'reaction' && ' reacted to your message'}
{notification.type === 'dm' && ' sent you a message'}
</div>
{!notification.isRead && (
<div className="w-2 h-2 bg-blue-600 rounded-full shrink-0 mt-1"></div>
)}
</div>
<div className="text-sm text-gray-600 mb-1">
#{notification.channelName}
</div>
<div className="text-sm text-gray-700 truncate">
{notification.preview}
</div>
<div className="text-xs text-gray-500 mt-1">
{formatTimeAgo(notification.timestamp)}
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
);
}
function formatTimeAgo(date: Date): string {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
return date.toLocaleDateString();
}The types are mention, reply (a reply in a thread), reaction and dm. Every notification also has messageId and channelName, that is the message's address, from with the sender, preview with a fragment of the content, and the isRead flag. getNotificationIcon returns the @ sign for a mention, and for the other types it leaves empty slots for your own icons, e.g. a speech bubble for replies, a smile for reactions and an envelope for direct messages. unreadCount is notifications.filter(n => !n.isRead).length. When it's greater than zero, the Mark all as read button appears: it calls onMarkAllAsRead, and the count drops to zero. Clicking an unread notification calls onNavigate to the message and onMarkAsRead. The panel doesn't change notifications itself, it asks the parent to mark them. An empty list shows an icon and the text No notifications yet. formatTimeAgo has thresholds: under 60 s "just now", under 3600 s minutes, under 86400 s hours, and under 604800 s days.
The unread badge
The last piece is the counter next to a channel name:
interface UnreadBadgeProps {
count: number;
isMention?: boolean; // special styling for mentions
}
export default function UnreadBadge({ count, isMention }: UnreadBadgeProps) {
if (count === 0) return null;
return (
<span
className={`ml-auto px-2 py-0.5 rounded-full text-xs font-bold ${
isMention
? 'bg-red-500 text-white'
: 'bg-gray-500 text-white'
}`}
>
{count > 99 ? '99+' : count}
</span>
);
}At zero the badge disappears (return null). With isMention it gets a red bg-red-500 background instead of the gray bg-gray-500, so mentions shout louder than ordinary messages. Above 99 it shows 99+, so the number doesn't burst the bar, while the real value in the data stays unchanged.
Captain Redbeard's tip: @channel and @everyone wake the whole crew, so use them sparingly.
Remember: a mention is a name with @, and a notification leads back to the message. The full Ship's Comms flow is choosing a channel in the sidebar, sending a message, a reaction or mention from someone else, and a notification in the notification center. In the editor below you have mention suggestions: type @ in the message field and pick a person from the list, then solve the tasks.
Code for this lesson: App.jsx
// Mention Autocomplete
import { useState } from 'react';
const members = [
{ id: 1, name: 'Jack Sparrow', avatar: '', status: 'online', role: 'Captain' },
{ id: 2, name: 'Will Turner', avatar: '', status: 'away', role: 'Swordsman' },
{ id: 3, name: 'Elizabeth Swan', avatar: '', status: 'online', role: 'Navigator' },
{ id: 4, name: 'Barbossa', avatar: '', status: 'offline', role: 'First Mate' },
{ id: 5, name: 'Davy Jones', avatar: '', status: 'online', role: 'Captain' }
];
export default function App() {
const [content, setContent] = useState('');
const [showMentions, setShowMentions] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const handleChange = (e) => {
const value = e.target.value;
setContent(value);
const lastAtIndex = value.lastIndexOf('@');
if (lastAtIndex !== -1 && lastAtIndex === value.length - 1) {
setShowMentions(true);
setMentionQuery('');
setSelectedIndex(0);
} else if (lastAtIndex !== -1 && lastAtIndex < value.length - 1) {
const query = value.substring(lastAtIndex + 1);
if (!query.includes(' ')) {
setShowMentions(true);
setMentionQuery(query);
} else {
setShowMentions(false);
}
} else {
setShowMentions(false);
}
};
const selectMember = (member) => {
const lastAtIndex = content.lastIndexOf('@');
const newContent = content.substring(0, lastAtIndex) + `@${member.name.replace(/\s+/g, '')} `;
setContent(newContent);
setShowMentions(false);
};
const filteredMembers = members.filter(m =>
m.name.toLowerCase().includes(mentionQuery.toLowerCase())
);
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow p-4">
<div className="relative">
<textarea
value={content}
onChange={handleChange}
placeholder="Type @ to mention someone..."
className="w-full p-3 border rounded-lg resize-none"
rows={4}
/>
{/* Mention Autocomplete */}
{showMentions && (
<div className="absolute bottom-full left-0 mb-2 bg-white border rounded-lg shadow-lg w-64 max-h-64 overflow-y-auto">
{filteredMembers.length === 0 ? (
<div className="p-3 text-sm text-gray-500">No matches found</div>
) : (
filteredMembers.map((member, idx) => (
<button
key={member.id}
onClick={() => selectMember(member)}
className={`w-full flex items-center gap-3 px-3 py-2 hover:bg-blue-50 ${
idx === selectedIndex ? 'bg-blue-50' : ''
}`}
>
<div className="relative text-2xl">
{member.avatar}
<div
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border border-white ${
member.status === 'online' ? 'bg-green-400' :
member.status === 'away' ? 'bg-yellow-400' : 'bg-gray-400'
}`}
/>
</div>
<div className="flex-1 text-left">
<div className="font-semibold text-sm">{member.name}</div>
<div className="text-xs text-gray-600">{member.role}</div>
</div>
</button>
))
)}
<div className="border-t p-2 text-xs text-gray-500">
<div>@channel - notify all members</div>
<div>@here - notify active members</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What types of mentions are there in Slack?
2. How does @channel differ from @here?
These are 2 of 12 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Click in order
Build a user mention inside the message content:
- Code editor
Mention autocomplete popup
- Horizontal ordering
Arrange the mention notification process:
- Vertical ordering
Match the order: arrange the notification types per their description from mention to dm:
- Code editor
Notifications panel
- Click in order
Build the special mention that notifies all channel members:
- Vertical ordering
Order the time thresholds in formatTimeAgo from smallest to largest:
- Click in order
Build the special mention that notifies everyone:
- Horizontal ordering
Arrange the text of the button that marks notifications as read (one line, left to right):
- Horizontal ordering
Arrange the steps for marking all notifications as read:
- Click in order
Build the badge text when count > 99 (click the tokens):
- Vertical ordering
Order the full user flow in Ship's Comms from picking a channel to a reaction: