Kurs vibe coding z AI · Moduł 8: Slack Clone
Wzmianki i powiadomienia
W tej lekcji4
W kanale #general dzieje się dużo, a Ty chcesz wiedzieć tylko, kiedy ktoś czegoś od Ciebie potrzebuje. Slack rozwiązuje to wzmiankami: wpisujesz @ i imię, a ta osoba dostaje powiadomienie. To jak dzwonek, który wzywa na pokład konkretnego marynarza.
Rodzaje wzmianek
Wzmianki są cztery: @user woła jedną osobę, @channel wszystkich członków kanału, @here tylko aktywnych członków kanału, a @everyone wszystkich na kanale ogólnym (w Slacku #general), do którego należy cały workspace poza gośćmi. Funkcja parseMentions znajduje je w treści, a renderContent wyróżnia na ekranie:
interface Mention {
type: 'user' | 'channel' | 'here' | 'everyone';
userId?: string;
position: { start: number; end: number }; // w content string
}
// Parsing mentions z 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 };
}
// Render mentions w 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 szuka w treści wszystkich @username wyrażeniem /@(\w+)/g i porównuje je z imionami członków zapisanymi małymi literami i bez spacji, więc @redbeard pasuje do „Redbeard”. Dla każdego trafienia zapisuje userId i pozycję w tekście. \w obejmuje tylko litery łacińskie bez polskich znaków, więc @Łukasz nie zadziała, a pomoże wzorzec z flagą u, np. /@([\p{L}\d_]+)/gu. renderContent tnie tekst według pozycji i każdą wzmiankę owija w <span> z klasami bg-blue-100 i font-semibold. Treść wiadomości się nie zmienia, inaczej wygląda tylko na ekranie.
Podpowiedzi po wpisaniu @
Gdy ktoś wpisze @, nad polem pojawia się lista członków:
'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>
);
}Lista filtruje członków po fragmencie imienia, pokazuje najwyżej pięciu (slice(0, 5)) i status każdego. Na dole przypomina, że @channel powiadamia wszystkich członków, a @here tylko aktywnych. Kliknięcie osoby nie wysyła jeszcze wiadomości, tylko przekazuje wybranego członka do onSelect, a ten wstawia wzmiankę do pola. Stan selectedIndex przyda się, gdy dodasz wybieranie strzałkami.
Centrum powiadomień
Przepływ wygląda tak: ktoś pisze wiadomość z @mention, system parsuje wzmianki i tworzy Notification dla wspomnianej osoby, a ta widzi plakietkę i słyszy dźwięk. Powiadomienie i panel z listą zapiszesz tak:
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 'Odpowiedź';
case 'reaction': return 'Reakcja';
case 'dm': return 'Wiadomość';
}
};
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();
}Typy to mention, reply (odpowiedź w wątku), reaction i dm. Każde powiadomienie ma też messageId i channelName, czyli adres wiadomości, from z nadawcą, preview z fragmentem treści i flagę isRead. getNotificationIcon zwraca znak @ dla wzmianki, a dla pozostałych typów zostawia puste miejsca na własne ikony, np. dymek dla odpowiedzi, uśmiech dla reakcji i kopertę dla wiadomości prywatnej. unreadCount to notifications.filter(n => !n.isRead).length. Gdy jest większy od zera, pojawia się przycisk Mark all as read: wywołuje onMarkAllAsRead, a licznik spada do zera. Kliknięcie nieprzeczytanego powiadomienia wywołuje onNavigate do wiadomości i onMarkAsRead. Panel sam nie zmienia powiadomień, o oznaczenie prosi rodzica. Pusta lista pokazuje ikonę i napis No notifications yet. formatTimeAgo ma progi: poniżej 60 s „just now”, poniżej 3600 s minuty, poniżej 86400 s godziny i poniżej 604800 s dni.
Plakietka nieprzeczytanych
Ostatni element to licznik przy nazwie kanału:
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>
);
}Przy zerze plakietka znika (return null). Z isMention dostaje czerwone tło bg-red-500 zamiast szarego bg-gray-500, żeby wzmianki krzyczały głośniej niż zwykłe wiadomości. Powyżej 99 pokazuje 99+, żeby liczba nie rozsadziła paska, a prawdziwa wartość w danych zostaje bez zmian.
Rada Kapitana Redbearda: @channel i @everyone budzą całą załogę, więc używaj ich oszczędnie.
Zapamiętaj: wzmianka to imię z @, a powiadomienie prowadzi z powrotem do wiadomości. Pełny przepływ Ship's Comms to wybór kanału w pasku bocznym, wysłanie wiadomości, reakcja albo wzmianka od kogoś innego i powiadomienie w centrum powiadomień. W edytorze poniżej masz podpowiedzi wzmianek: wpisz @ w polu wiadomości i wybierz osobę z listy, a potem rozwiąż zadania.
Kod do tej lekcji: App.jsx
// Podpowiedzi wzmianek
import { useState } from 'react';
const members = [
{ id: 1, name: 'Jack Sparrow', avatar: '', status: 'online', role: 'Kapitan' },
{ id: 2, name: 'Will Turner', avatar: '', status: 'away', role: 'Szermierz' },
{ id: 3, name: 'Elizabeth Swan', avatar: '', status: 'online', role: 'Nawigatorka' },
{ id: 4, name: 'Barbossa', avatar: '', status: 'offline', role: 'Pierwszy oficer' },
{ id: 5, name: 'Davy Jones', avatar: '', status: 'online', role: 'Kapitan' }
];
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="Wpisz @, aby wspomnieć o kimś..."
className="w-full p-3 border rounded-lg resize-none"
rows={4}
/>
{/* Podpowiedzi wzmianek */}
{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">Brak wyników</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 - powiadom wszystkich członków</div>
<div>@here - powiadom aktywnych członków</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jakie typy mentions są w Slack?
2. Czym różni się @channel od @here?
To 2 z 12 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Klikanie w kolejności
Zbuduj wzmiankę użytkownika (mention) w treści wiadomości:
- Edytor kodu
Mention autocomplete popup
- Układanie w poziomie
Ułóż proces powiadomienia o mention:
- Układanie w pionie
Dopasuj kolejność: ułóż typy powiadomień zgodnie z opisem, od mention do dm:
- Edytor kodu
Notifications panel
- Klikanie w kolejności
Zbuduj special mention powiadamiający wszystkich members kanału:
- Układanie w pionie
Uporządkuj progi czasowe w formatTimeAgo od najmniejszego do największego:
- Klikanie w kolejności
Zbuduj special mention powiadamiający wszystkich:
- Układanie w poziomie
Ułóż tekst przycisku oznaczania powiadomień jako przeczytane (jedna linia, lewo-prawo):
- Układanie w poziomie
Ułóż kroki oznaczania wszystkich powiadomień jako przeczytane:
- Klikanie w kolejności
Zbuduj tekst badge gdy count > 99 (klikaj tokeny):
- Układanie w pionie
Uporządkuj pełny przepływ użytkownika w Ship's Comms od logowania do reakcji: