Kurs vibe coding z AI · Moduł 8: Slack Clone

Wzmianki i powiadomienia

8 min czytania
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. 1. Jakie typy mentions są w Slack?

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

Przydatne artykuły