Powiadomienia (w aplikacji i mailowe)

View on GitHub

Dodaj obsługę @mention do pola tekstowego

Przećwiczony przykład: system @mention już zbudowany dla komentarzy do zadań — wpisz @ w polu komentarza, wybierz członka projektu z pływającej listy podpowiedzi, a otrzyma powiadomienie "You were mentioned" (NotificationType::IssueMentioned, poprzez nowy event IssueMentioned). Ten przewodnik dokumentuje ten system od początku do końca, zarówno jako "jak to działa", jak i jako szablon do dodania tej samej funkcjonalności do innego pola tekstowego (opisu projektu, tytułu zadania, dowolnego innego pola opartego na zwykłym <textarea>).

Wzmianki w opisie issue (edytor Tiptap, a nie textarea) używają tych samych tokenów @[Name](id) / #[Title](id:number) i tego samego zdarzenia IssueMentioned — z null zamiast komentarza — i są opisane w ../rich-text-editor/04-add-mentions-to-a-tiptap-surface.mdEdytor rich textDodaj wzmianki do powierzchni TiptapPrzećwiczony przykład: wzmianki @member i #issue w opisie issue, czyli w edytorze Tiptap za EditableMarkdown. Komentarze do issue miały już oba rodzaje (zo…. Ten przewodnik opisuje wariant z textareą.

NotificationType::IssueMentioned oraz jego wiersz w ustawieniach konta (tablica defaultNotificationTypes w AccountSettingsNotificationsTab.tsx) istniały już przed tą funkcjonalnością — ten przewodnik dotyczy podłączenia nowego eventu domenowego do pipeline'u za istniejącym typem powiadomienia, a nie dodania samego typu (zobacz ./01-add-a-new-notification-type.mdPowiadomienia (w aplikacji i mailowe)Dodaj nowy typ powiadomieniaPrzećwiczony przykład: powiadamianie członka, gdy zmieni się jego rola w projekcie — NotificationType::MemberRoleChanged. Dziś ProjectMemberService::update… po to drugie).

Najważniejsza zasada w tym przewodniku

Śledź wzmiankę po id użytkownika i dokładnym zakresie znaków — nigdy przez dopasowywanie wpisanego tekstu @Name do listy nazw. Dwóch członków projektu może mieć tę samą nazwę wyświetlaną. Jeśli rozwiązujesz "kto został wspomniany" przez wyszukiwanie w treści komentarza @Jane Cooper, nie da się stwierdzić, o którą Jane Cooper chodzi, a klient mógłby — celowo lub nie — powiadomić niewłaściwą osobę. Wszędzie poniżej (wykrywanie, śledzenie przez kolejne edycje, zapis i renderowanie) operuje na id, nie na ciągach znaków z nazwami.

Jak działa komponowanie wzmianki (frontend)

Krok 1 — Wykryj wpisywane w trakcie zapytanie @query

Plik: resources/js/utils/mentions.ts

export interface ActiveMention {
    query: string;
    start: number;
}

/**
 * Looks backward from the cursor for an in-progress "@mention": an "@"
 * preceded by start-of-text or whitespace, followed by up to two
 * space-separated words (enough to filter by first or first+last name) with
 * no line break in between. Returns the partial name typed so far and the
 * index of the "@" itself, or null if the cursor isn't inside a mention.
 */
export function findActiveMention(
    text: string,
    cursor: number,
): ActiveMention | null {
    const upToCursor = text.slice(0, cursor);
    const match = upToCursor.match(/(?:^|\s)@([^\s@\n]*(?:\s[^\s@\n]+)?)$/);

    if (!match) return null;

    const query = match[1];

    return { query, start: cursor - query.length - 1 };
}

/**
 * Ranks project members by how well their name matches the in-progress
 * mention query, limited to a handful of results so the floating menu stays
 * short. Matching is by name only (it's just filtering candidates to show),
 * identity of whichever one gets picked is always tracked by id afterwards.
 */
export function filterUsersByMention(
    users: AssignableUser[],
    query: string,
): AssignableUser[] {
    const normalized = query.trim().toLowerCase();

    if (!normalized) return users.slice(0, 5);

    return users
        .filter((user) => user.name.toLowerCase().includes(normalized))
        .slice(0, 5);
}

findActiveMention patrzy tylko wstecz od kursora — jest wywoływana przy każdym naciśnięciu klawisza, kliknięciu i zmianie zaznaczenia (syncMentionState w CommentForm.tsx), niczego nie mutuje i zwraca null w momencie, gdy kursor wyjdzie poza @słowo (np. adres email jak foo@bar nigdy tego nie wyzwala, ponieważ @ nie jest tam poprzedzone białym znakiem ani początkiem tekstu). filterUsersByMention jest celowo oparte na nazwie — decyduje wyłącznie, które kandydatury pokazać, a osoba, którą użytkownik faktycznie wybierze, jest od tego momentu identyfikowana po id.

Krok 2 — Pokaż pływające menu podpowiedzi

Plik: resources/js/Components/Molecules/MentionSuggestions/MentionSuggestions.tsx

import Avatar from '@/Components/Atoms/Avatar/Avatar';
import DropdownOption from '@/Components/Atoms/DropdownOption/DropdownOption';
import DropdownPanel from '@/Components/Atoms/DropdownPanel/DropdownPanel';
import { MentionSuggestionsProps } from '@/types/Components';
import { createPortal } from 'react-dom';

/**
 * Floating "@mention" suggestion list, positioned at an arbitrary viewport
 * coordinate (the typed "@" inside a textarea) rather than anchored to a
 * trigger element, so it's rendered through a portal with its own fixed
 * position instead of reusing Dropdown (which anchors to a trigger
 * element). It uses the same DropdownPanel / DropdownOption atoms.
 */
export default function MentionSuggestions({
    users,
    activeIndex,
    position,
    onSelect,
    onHover,
}: MentionSuggestionsProps) {
    if (users.length === 0) return null;

    // Two project members can share a display name - without this, the list
    // would show two identical-looking rows with no way to tell which is
    // which before picking one.
    const nameCounts = new Map<string, number>();
    users.forEach((user) => {
        nameCounts.set(user.name, (nameCounts.get(user.name) ?? 0) + 1);
    });

    return createPortal(
        <DropdownPanel
            style={{
                position: 'fixed',
                top: position.top,
                left: position.left,
                minWidth: 200,
                zIndex: 9999,
            }}
        >
            {users.map((user, index) => (
                <DropdownOption
                    key={user.id}
                    indicator="none"
                    isSelected={index === activeIndex}
                    onMouseDown={(e) => e.preventDefault()}
                    onMouseEnter={() => onHover(index)}
                    onClick={() => onSelect(user)}
                    label={
                        <>
                            <Avatar
                                src={user.avatar ?? undefined}
                                initials={user.name.charAt(0)}
                                size="sm"
                            />
                            <span className="truncate">{user.name}</span>
                            {(nameCounts.get(user.name) ?? 0) > 1 && (
                                <span className="shrink-0 text-xs text-[var(--text-muted-color)]">
                                    #{user.id}
                                </span>
                            )}
                        </>
                    }
                />
            ))}
        </DropdownPanel>,
        document.body,
    );
}

onMouseDown={(e) => e.preventDefault()} ma znaczenie: bez tego kliknięcie podpowiedzi odebrałoby fokus polu tekstowemu zanim zadziała onClick tego kliknięcia, tracąc pozycję kursora, której potrzebuje selectMention (krok 3). Odznaka #id obok zduplikowanej nazwy to jedyna rzecz odróżniająca dwa identycznie wyglądające wiersze — użyj ponownie tego wzorca nameCounts przy każdym pickerze listującym encje, które mogą dzielić etykietę.

Krok 3 — Śledź każdą wstawioną wzmiankę po dokładnym zakresie znaków, nie po nazwie

Plik: resources/js/utils/mentions.ts

/**
 * A mention inserted into a comment being composed, tracked by its exact
 * character range in the textarea's plain-text value rather than by name —
 * this is what lets the compose box show clean "@Full Name" text while still
 * being able to tell apart two project members who happen to share a name.
 */
export interface MentionRange {
    start: number;
    length: number;
    userId: number;
    name: string;
}

/**
 * Re-anchors tracked mention ranges against a known, exact edit: the region
 * `[editStart, editEnd)` of the text was replaced by `insertedLength`
 * characters. A range entirely before that region is kept as-is, one
 * entirely after it is shifted by the resulting length delta, and one that
 * overlaps it is dropped — its "@Name" text is no longer guaranteed intact,
 * so it can no longer be trusted to mean that user.
 *
 * The edit's boundaries must be known exactly (from the selection right
 * before the edit was applied — see CommentForm's editRangeRef), not
 * inferred by diffing the before/after text: a naive common-prefix/suffix
 * walk can misjudge the boundary whenever the text right at the edit point
 * happens to coincide with the inserted text, which for mentions is
 * common — every mention starts with "@", so inserting one right before
 * another is exactly this case.
 */
export function applyRangeEdit(
    ranges: MentionRange[],
    editStart: number,
    editEnd: number,
    insertedLength: number,
): MentionRange[] {
    if (ranges.length === 0) return ranges;

    const delta = insertedLength - (editEnd - editStart);
    const survivors: MentionRange[] = [];

    for (const range of ranges) {
        const end = range.start + range.length;

        if (end <= editStart) {
            survivors.push(range);
        } else if (range.start >= editEnd) {
            survivors.push({ ...range, start: range.start + delta });
        }
        // else: the edit overlaps this range - drop it.
    }

    return survivors;
}

applyRangeEdit potrzebuje dokładnych granic edycji. CommentForm.tsx uzyskuje je, robiąc migawkę zaznaczenia tuż przed tym, jak DOM zastosuje edycję — przy keydown (z korektą dla Backspace/Delete, ponieważ te klawisze rozszerzają zwinięty kursor odpowiednio wstecz/w przód o jeden znak przed usunięciem), oraz przy paste/cut:

const editRangeRef = useRef<{ start: number; end: number }>({
    start: 0,
    end: 0,
});

const captureEditRange = (
    el: HTMLTextAreaElement,
    deleteDirection?: 'backward' | 'forward',
) => {
    let { selectionStart: start, selectionEnd: end } = el;

    if (start === end) {
        if (deleteDirection === 'backward' && start > 0) start -= 1;
        else if (deleteDirection === 'forward') end += 1;
    }

    editRangeRef.current = { start, end };
};

const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    const newBody = e.target.value;
    const { start, end } = editRangeRef.current;
    const insertedLength = newBody.length - body.length + (end - start);

    setMentionRanges((prev) =>
        applyRangeEdit(prev, start, end, insertedLength),
    );
    setBody(newBody);
    syncMentionState(e.target);
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    // ... mention-menu navigation (ArrowUp/Down/Enter/Tab/Escape) goes
    // here first and `return`s before reaching this - see the full file.

    if (e.key === 'Backspace') {
        captureEditRange(e.currentTarget, 'backward');
    } else if (e.key === 'Delete') {
        captureEditRange(e.currentTarget, 'forward');
    } else {
        captureEditRange(e.currentTarget);
    }
};

const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
    captureEditRange(e.currentTarget);
};

const handleCut = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
    captureEditRange(e.currentTarget);
};

selectMention (wstawienie wybranej podpowiedzi) już zna dokładnie swoje własne granice edycji — mention.start oraz aktualny selectionStart pola tekstowego — więc wywołuje applyRangeEdit bezpośrednio z tymi wartościami, uzgadniając każdy już śledzony zakres z tym nowym wstawieniem, zanim doda ten świeżo wybrany:

const selectMention = (user: AssignableUser) => {
    if (!mention || !textareaRef.current) return;

    const cursor = textareaRef.current.selectionStart;
    const mentionText = `@${user.name}`;
    const newBody =
        body.slice(0, mention.start) +
        mentionText +
        ' ' +
        body.slice(cursor);

    setBody(newBody);
    setMentionRanges((prev) => [
        // Reconciles existing ranges against this exact, known edit -
        // inserting text at `mention.start` shifts any mention that
        // comes after it.
        ...applyRangeEdit(
            prev,
            mention.start,
            cursor,
            mentionText.length + 1,
        ),
        {
            start: mention.start,
            length: mentionText.length,
            userId: user.id,
            name: user.name,
        },
    ]);
    setMention(null);
    setPendingCaret(mention.start + mentionText.length + 1);
};

Jeśli dodajesz wzmianki do innego pola, cały ten mechanizm śledzenia zakresów (MentionRange + applyRangeEdit + przechwytywanie editRangeRef) jest częścią wielokrotnego użytku — skopiuj go bez zmian; zmienia się tylko otaczające podłączenie pola tekstowego.

Krok 4 — Przepisz zwykły tekst na jednoznaczny token przed wysłaniem

Plik: resources/js/utils/mentions.ts

/**
 * Rewrites a comment's plain-text body into the persisted form, replacing
 * each tracked mention's plain "@Name" with an "@[Name](id)" token so it can
 * be resolved back to the exact user unambiguously later, regardless of any
 * other project member sharing that same display name. Applied right-to-left
 * so earlier ranges' offsets stay valid while later ones are rewritten.
 */
export function tokenizeMentionRanges(
    body: string,
    ranges: MentionRange[],
): string {
    const sorted = [...ranges].sort((a, b) => b.start - a.start);

    return sorted.reduce((text, range) => {
        const token = `@[${range.name}](${range.userId})`;

        return (
            text.slice(0, range.start) +
            token +
            text.slice(range.start + range.length)
        );
    }, body);
}

handleSubmit w CommentForm.tsx wywołuje to tuż przed wysłaniem, więc samo pole tekstowe pokazuje podczas komponowania zawsze czysty tekst @Full Name (nigdy surowy token):

const handleSubmit = (e: SyntheticEvent) => {
    e.preventDefault();
    if (!body.trim()) return;

    const finalBody = tokenizeMentionRanges(body, mentionRanges);
    const mentionedUserIds = [
        ...new Set(mentionRanges.map((range) => range.userId)),
    ];

    onSubmit(finalBody, mentionedUserIds);
    setBody('');
    setMentionRanges([]);
    setMention(null);
};

Zarówno stokenizowane body, jak i zwykła tablica mentionedUserIds trafiają do backendu — backend na nowo wyprowadza wiarygodną wersję tego drugiego z tego pierwszego (krok 5), nigdy po prostu nie ufa tej tablicy.

Krok 5 — Wyrenderuj token z powrotem jako chip wzmianki

Plik: resources/js/utils/mentions.ts

const MENTION_TOKEN_PATTERN = /([@#])\[([^\]]+)\]\((\d+)\)/g; // '#' = issue, patrz niżej

export type MentionSegment =
    | { type: 'text'; value: string }
    | {
          type: 'mention';
          value: string;
          userId: number;
          name: string;
          avatar?: string | null;
      }
    | { type: 'issue'; value: string; issueId: number; title: string };

/**
 * Splits a persisted comment body into plain-text and mention segments,
 * parsing the "@[Name](id)" token format so each mention resolves back to an
 * exact user id — never ambiguous even when two project members share a
 * display name. The rendered name/avatar are looked up live by id (falling
 * back to the name captured in the token if that member is no longer in the
 * project), so a later rename still shows correctly.
 */
export function splitMentionText(
    body: string,
    users: AssignableUser[] = [],
): MentionSegment[] {
    if (!body) return [{ type: 'text', value: body }];

    const usersById = new Map(users.map((user) => [user.id, user]));
    const segments: MentionSegment[] = [];
    let lastIndex = 0;
    let match: RegExpExecArray | null;

    MENTION_TOKEN_PATTERN.lastIndex = 0;
    while ((match = MENTION_TOKEN_PATTERN.exec(body)) !== null) {
        const [full, sigil, tokenName, tokenId] = match;
        const userId = Number(tokenId);
        const user = usersById.get(userId);

        if (match.index > lastIndex) {
            segments.push({
                type: 'text',
                value: body.slice(lastIndex, match.index),
            });
        }

        if (sigil === '#') {
            segments.push({
                type: 'issue',
                value: `#${userId}`,
                issueId: userId,
                title: tokenName,
            });
            lastIndex = match.index + full.length;
            continue;
        }

        segments.push({
            type: 'mention',
            value: `@${user?.name ?? tokenName}`,
            userId,
            name: user?.name ?? tokenName,
            avatar: user?.avatar,
        });

        lastIndex = match.index + full.length;
    }

    if (lastIndex < body.length) {
        segments.push({ type: 'text', value: body.slice(lastIndex) });
    }

    if (segments.length === 0) {
        segments.push({ type: 'text', value: body });
    }

    return segments;
}

CommentItem.tsx używa tego przez prop renderDisplay komponentu EditableText, żeby wyrenderować każdą wzmiankę jako chip z awatarem i nazwą:

const renderBody = (value: string) =>
    splitMentionText(value, users).map((segment, index) =>
        segment.type === 'mention' ? (
            <span
                key={index}
                className="bg-[var(--accent-color)]/10 mx-0.5 inline-flex items-center gap-1 rounded px-1 align-middle font-medium text-[var(--accent-color)]"
            >
                <Avatar
                    src={segment.avatar ?? undefined}
                    initials={segment.name.charAt(0)}
                    size="sm"
                />
                {segment.value}
            </span>
        ) : (
            <React.Fragment key={index}>{segment.value}</React.Fragment>
        ),
    );

Edycja istniejącego komentarza pokazuje surowy token @[Name](id) w zwykłym <textarea> (nie ma bogatego, ponownego skomponowania już stokenizowanej wzmianki) — to akceptowalne, ponieważ tryb edycji EditableText już pokazuje dosłowną zapisaną wartość dla każdego pola, wzmianki włącznie.

Wzmianki o zgłoszeniach (#id)

To samo pole obsługuje też odwołania w stylu #2 do zgłoszeń tego samego projektu. Służą tylko do wyświetlania: w przeciwieństwie do wzmianek @user nie tworzą powiadomień, więc kroki backendowe poniżej ich nie dotyczą.

Wykrywanie i wyszukiwanie. findActiveIssueMention(text, cursor) w resources/js/utils/mentions.ts znajduje wpisywane # z cyframi. CommentForm wywołuje wtedy route('projects.issues.search', projectId) (IssueController::search -> IssueService::searchProjectIssuesById -> IssueRepository::searchByIdPrefix, zawsze ograniczone przez project_id i autoryzowane przez view na projekcie). Dopasowanie prefiksu rzutuje issues.id na tekst zależnie od sterownika bazy, bo PostgreSQL nie ma niejawnej konwersji bigint na tekst dla LIKE. Bez propsa projectId formularz nigdy nie pokazuje podpowiedzi zgłoszeń.

Wstawianie i śledzenie. Wybranie podpowiedzi wstawia #<id> i zapisuje MentionRange z kind: 'issue' (id w userId, tytuł zgłoszenia w name). Nawiasy kwadratowe są usuwane z tytułu, a pusty wynik zamieniany na #<id>, więc token zawsze pasuje do parsera.

Token. Przy wysyłaniu zakres jest przepisywany na #[Tytuł](id); wzmianki użytkowników zostają @[Name](id). MENTION_TOKEN_PATTERN w mentions.ts to /([@#])\[([^\]]+)\]\((\d+)\)/g, a splitMentionText zwraca segment { type: 'issue', issueId, title } dla znaku #.

Renderowanie i podgląd. CommentItem renderuje segmenty zgłoszeń przez IssueMentionLink, który linkuje do issues.show, a po najechaniu lub fokusie pobiera route('projects.issues.preview', [projectId, issueId]) (IssueController::preview, 404 dla zgłoszenia z innego projektu) i pokazuje IssuePreviewCard. Podglądy są cache'owane w pamięci przez 30 sekund (PREVIEW_TTL_MS), więc zmieniony tytuł lub przypisanie nie zostają nieaktualne do końca sesji strony.

Testy. mentions.test.ts (parsowanie znaku), CommentForm.test.tsx (podpowiedzi, wstawianie, tytuł z samych nawiasów, brak projectId), IssueMentionLink.test.tsx (podgląd, wygaśnięcie cache) i IssueControllerTest.php (zakres search i preview).

Jak backend rozwiązuje i powiadamia o wzmiankach

Krok 6 — Zwaliduj i rozwiąż mentioned_user_ids

Plik: app/Http/Controllers/CommentController.php

public function store(Request $request, Issue $issue): RedirectResponse
{
    $this->authorize('create', [Comment::class, $issue]);

    $data = $request->validate([
        'body' => 'required|string',
        'mentioned_user_ids' => 'sometimes|array',
        'mentioned_user_ids.*' => 'integer|exists:users,id',
    ]);

    $this->commentService->addComment($issue, $data);

    return redirect()->back()
        ->with('success', 'Comment added.')
        ->with('action_url', route('issues.show', [$issue->project_id, $issue->id]));
}

Walidacja potwierdza tylko, że każde id to prawdziwy użytkownik — nic nie mówi o tym, czy ten użytkownik faktycznie należy do tego projektu, ani czy faktycznie jest wymieniony w komentarzu. CommentService sam wykonuje obie te kontrole, ponieważ spreparowane żądanie mogłoby w przeciwnym razie sprawić, że niezwiązana osoba zostanie powiadomiona jako "wspomniana", mimo że jej nazwa nigdy nie pojawia się w widocznej treści komentarza:

Plik: app/Services/CommentService.php

/**
 * Keeps only the mentioned user ids that are (a) actually members of the
 * issue's project, and (b) actually referenced by an "@[Name](id)"
 * mention token in the comment body — a client can't claim
 * mentioned_user_ids for someone the visible comment text never
 * actually mentions.
 *
 * @return list<int>
 */
private function resolveMentionedUserIds(Issue $issue, array $data): array
{
    $requestedIds = array_unique($data['mentioned_user_ids'] ?? []);
    $requestedIds = array_filter($requestedIds, fn ($id) => $id !== auth()->id());

    if (empty($requestedIds)) {
        return [];
    }

    $tokenIds = $this->extractMentionTokenIds($data['body'] ?? '');
    $requestedIds = array_intersect($requestedIds, $tokenIds);

    if (empty($requestedIds)) {
        return [];
    }

    $memberIds = $this->projectRepository->getMemberIds($issue->project);

    return array_values(array_intersect($requestedIds, $memberIds));
}

/**
 * @return list<int>
 */
private function extractMentionTokenIds(string $body): array
{
    preg_match_all('/@\[[^\]]+\]\((\d+)\)/', $body, $matches);

    return array_map('intval', $matches[1] ?? []);
}

ProjectRepository::getMemberIds() to metoda repozytorium stojąca za sprawdzeniem członkostwa — kod zapytań należy tam, nie inline w serwisie:

Plik: app/Repositories/ProjectRepository.php

/**
 * @return list<int>
 */
public function getMemberIds(Project $project): array
{
    return $project->users()->pluck('users.id')->all();
}

Krok 7 — Stwórz event i wystrzel go

Plik: app/Events/IssueMentioned.php

<?php

namespace App\Events;

use App\Models\Comment;
use App\Models\Issue;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;

final class IssueMentioned
{
    use Dispatchable;

    public function __construct(
        public readonly Issue $issue,
        public readonly Comment $comment,
        public readonly User $mentionedUser,
        public readonly ?User $actor,
    ) {}
}

Ta sama konwencja co CommentAdded: niesie prawdziwe modele (Issue, Comment, wspomnianego User, opcjonalnego aktora User), nie surowe id — zobacz ../integrations/03-add-a-new-event-type.mdIntegracjeDodaj nowy typ eventuPrzećwiczony przykład: dodanie IssueCreated — rodzaju aktywności, który jeszcze nie istnieje. Dziś stworzenie issue odpala tylko IssueAssigned, i to tylko… po pełne uzasadnienie. CommentService wystrzeliwuje jeden IssueMentioned na każde rozwiązane id, używając UserRepository::findById() (nie User::find() inline) do wczytania każdego wspomnianego użytkownika:

Plik: app/Services/CommentService.php

/**
 * @param  list<int>  $mentionedUserIds
 */
private function fireMentionEvents(Issue $issue, Comment $comment, array $mentionedUserIds): void
{
    foreach ($mentionedUserIds as $userId) {
        $mentionedUser = $this->userRepository->findById($userId);

        if (! $mentionedUser) {
            continue;
        }

        event(new IssueMentioned($issue, $comment, $mentionedUser, auth()->user()));
    }
}

Zarówno addComment(), jak i updateComment() wywołują resolveMentionedUserIds(), a potem fireMentionEvents() — edycja komentarza w celu dodania nowej wzmianki powiadamia tę osobę tak samo, jak stworzenie komentarza, który ją wspomina.

Krok 8 — Zarejestruj go i obsłuż w SendNotificationListener

Plik: app/Providers/AppServiceProvider.php, boot() — zarejestrowany wyłącznie dla SendNotificationListener. To osobiste powiadomienie dla jednego odbiorcy, a nie aktywność w całym projekcie, więc nie jest dodawany do listy eventów NotifyProjectIntegrationsListener:

Event::listen([
    IssueAssigned::class,
    IssueUnassigned::class,
    IssueUpdated::class,
    CommentAdded::class,
    IssueMentioned::class,
    ProjectInvited::class,
    IssuesImported::class,
], SendNotificationListener::class);

Plik: app/Listeners/SendNotificationListener.php:

public function handle(object $event): void
{
    match (true) {
        $event instanceof IssueAssigned => $this->handleIssueAssigned($event),
        $event instanceof IssueUnassigned => $this->handleIssueUnassigned($event),
        $event instanceof IssueUpdated => $this->handleIssueUpdated($event),
        $event instanceof CommentAdded => $this->handleCommentAdded($event),
        $event instanceof IssueMentioned => $this->handleIssueMentioned($event),
        $event instanceof ProjectInvited => $this->handleProjectInvited($event),
        $event instanceof IssuesImported => $this->handleIssuesImported($event),
        default => null,
    };
}

private function handleIssueMentioned(IssueMentioned $event): void
{
    if ($event->actor && $event->actor->id === $event->mentionedUser->id) {
        return;
    }

    $issue = $event->issue;
    $actorName = $event->actor?->name ?? 'Someone';

    $this->notificationService->notify(
        $event->mentionedUser->id,
        NotificationType::IssueMentioned,
        'info',
        'You were mentioned',
        "$actorName mentioned you in a comment on \"$issue->title\" (#$issue->id).",
        route('issues.show', [$issue->project_id, $issue->id])
    );
}

Zabezpieczenie aktor/wspomniany-użytkownik odzwierciedla wzorzec "nie powiadamiaj kogoś o jego własnym działaniu" z handleCommentAdded() — istotne, ponieważ resolveMentionedUserIds() już usuwa auth()->id() z żądanych id, ale to jest ostatnia linia obrony, gdyby to kiedyś się zmieniło. NotificationType::IssueMentioned oraz jego wiersz w ustawieniach konta istniały już przed tą funkcjonalnością (zobacz ./01-add-a-new-notification-type.mdPowiadomienia (w aplikacji i mailowe)Dodaj nowy typ powiadomieniaPrzećwiczony przykład: powiadamianie członka, gdy zmieni się jego rola w projekcie — NotificationType::MemberRoleChanged. Dziś ProjectMemberService::update… krok 5, jeśli dodajesz nowy typ zamiast używać istniejącego).

Krok 9 — Testy

  • resources/js/utils/mentions.test.ts — pokrywa findActiveMention, filterUsersByMention, applyRangeEdit (włącznie z przypadkiem regresyjnym wstawienia jednej wzmianki bezpośrednio przed inną, obie zaczynające się od @), tokenizeMentionRanges oraz splitMentionText (włącznie z odróżnieniem dwóch użytkowników o tej samej nazwie po id w tokenie).
  • resources/js/Components/Molecules/CommentForm/CommentForm.test.tsx — pokrywa wpisanie @ pokazujące podpowiedzi, wybranie jednej myszką i klawiaturą, wysłanie stokenizowanej treści, usunięcie wybranej wzmianki (usuwa ją z wysłanych id) oraz wstawienie wzmianki przed już wybraną (obie przetrwają z poprawnymi offsetami):
    test('inserting a mention before an already-selected one keeps both intact', async () => {
        const handleSubmit = vi.fn();
        render(<CommentForm onSubmit={handleSubmit} users={users} />);
    
        const textarea = screen.getByPlaceholderText(
            'Leave a comment...',
        ) as HTMLTextAreaElement;
    
        await userEvent.type(textarea, '@b');
        await userEvent.click(screen.getByText('Bob Smith'));
        expect(textarea).toHaveValue('@Bob Smith ');
    
        textarea.focus();
        textarea.setSelectionRange(0, 0);
        await userEvent.keyboard('@jane');
        await userEvent.click(screen.getByText('Jane Cooper'));
        expect(textarea).toHaveValue('@Jane Cooper @Bob Smith ');
    
        await userEvent.click(
            screen.getByRole('button', { name: 'Post comment' }),
        );
    
        expect(handleSubmit).toHaveBeenCalledWith(
            '@[Jane Cooper](1) @[Bob Smith](2) ',
            expect.arrayContaining([1, 2]),
        );
    });
    
  • resources/js/Components/Molecules/CommentItem/CommentItem.test.tsx — pokrywa podświetlenie tokena wzmianki oraz odróżnienie dwóch użytkowników o tej samej nazwie po id w tokenie.
  • resources/js/Components/Molecules/MentionSuggestions/MentionSuggestions.test.tsx — pokrywa renderowanie, wybieranie i najechanie na podpowiedź.
  • tests/Feature/CommentServiceTest.php — 'addComment fires IssueMentioned for each mentioned project member', 'addComment ignores a mentioned id with no matching mention token in the body' (przypadek istotny pod kątem bezpieczeństwa — treść, która nigdy faktycznie nie wymienia tej osoby), 'addComment ignores mentioned ids that are not project members', 'addComment does not fire IssueMentioned for mentioning yourself' oraz odpowiednik dla updateComment:
    test('addComment ignores a mentioned id with no matching mention token in the body', function () {
        // A client can't get someone notified as "mentioned" just by listing
        // their id - the visible comment text has to actually name them.
        $author = User::factory()->create();
        $project = Project::factory()->create();
        $mentioned = User::factory()->create();
        addProjectMember($project, $author->id, 'member');
        addProjectMember($project, $mentioned->id, 'member');
        $issue = Issue::factory()->create(['project_id' => $project->id, 'assignee_id' => null]);
        $comment = Comment::factory()->make(['issue_id' => $issue->id, 'user_id' => $author->id]);
    
        $this->actingAs($author);
    
        $this->commentRepository->shouldReceive('store')->once()->andReturn($comment);
        $this->activityLogService->shouldReceive('log')->once();
    
        $this->service->addComment($issue, [
            'body' => 'Hi there, nothing mentioned here',
            'mentioned_user_ids' => [$mentioned->id],
        ]);
    
        Event::assertNotDispatched(IssueMentioned::class);
    });
    
  • tests/Feature/CommentControllerTest.php — 'commenting with mentioned_user_ids fires IssueMentioned for that member' oraz 'commenting ignores a mentioned_user_ids entry with no matching mention token in the body', plus istniejący 'commenting rejects a mentioned_user_ids entry that is not a real user' dla samej reguły walidacji.
  • tests/Feature/SendNotificationListenerTest.php — 'IssueMentioned notifies the mentioned user about the actor\'s comment' oraz 'IssueMentioned does not notify when the actor mentions themself', odzwierciedlające kształt testów ProjectInvited.
  • tests/Feature/ProjectRepositoryTest.php — 'it can get the member ids of a project', pokrywający getMemberIds() bezpośrednio.