fix(announcements): name the creator in the mute confirmation (#4877)

* fix(announcements): name the creator in the mute confirmation

A success toast already fired on mute, but it read "Announcements from this
creator are muted" — it named neither the action nor whom it applied to, and
mute sits next to dismiss on the same card, so it did not read as a
confirmation of the thing just clicked. Testers reported the mute giving no
confirmation with that toast already in production.

Threads the creator name through both mute surfaces (the panel menu item
already had it; the profile bell now passes the username) so the toast reads
"Muted announcements from X" / "Unmuted announcements from X", falling back to
"this creator" where no name is in hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(announcements): pin that the mute control names the creator it renders for

The hook tests call useToggleAnnouncementMute directly, so nothing pinned the
wiring either side of it: a component that stopped forwarding creatorName falls
back to "...from this creator" — the vague message the report was about — with
every hook test still green.

Mounts both mute surfaces and asserts the name reaches the hook, plus the menu
label that derives the same "who" a second time. The label is read synchronously
off the committed tree rather than awaited, so a revert prints the label it
actually rendered instead of a 15s matcher timeout.

The ProfileSidebar -> bell prop is deliberately left unpinned; the file says so
and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(announcements): pin the unmute label, and the toggle's call count

Two gaps the first version left. The muted={true} arm was never rendered, so
nothing pinned that unmute names the creator either. And toHaveBeenCalledWith
alone is satisfied by a doubled handler, which toggles back to where it started.

Controls: dropping the name from the unmute arm prints
"expected 'Unmute announcements' to be 'Unmute announcements from Kolors'";
a doubled toggle prints "expected vi.fn() to be called 1 times, but got 2 times".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Justin Maier
2026-09-16 14:04:37 -06:00
committed by GitHub
parent 4a1535b2c7
commit b441199dc6
5 changed files with 204 additions and 10 deletions
@@ -0,0 +1,132 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { page, userEvent } from 'vitest/browser';
import { Menu } from '@mantine/core';
import { renderWithProviders } from '../../../test/component-setup';
import type * as CreatorUtils from '~/components/Announcements/creator-announcements.utils';
import type * as CurrentUser from '~/hooks/useCurrentUser';
/**
* The creator's name reaches the mute confirmation from the COMPONENT layer.
*
* `__tests__/creator-announcement-mutations.test.ts` pins the message the hook builds, by
* calling the hook directly. That leaves the wiring either side of it unpinned: a component
* that stops forwarding `creatorName` falls back to "…from this creator", which is the vague
* message the ticket was about, and every one of those hook tests stays green. So the claim
* here is a RELATIONSHIP — this control names the creator it is rendered for — not a
* restatement of the message.
*
* 🔴 ONE HOP IS DELIBERATELY NOT PINNED: `ProfileSidebar` passing `creatorName={user.username}`
* to the bell. Mounting a page-level sidebar to assert one prop costs a large mocked graph
* that would then need maintaining for every unrelated sidebar change. Dropping that prop
* degrades the profile bell's toast to the fallback rather than breaking it, and it reddens
* nothing anywhere — if you are here because you removed it, that is why nothing told you.
*/
const mocks = vi.hoisted(() => ({
toggleArgs: [] as Array<[number, string | null | undefined]>,
toggle: vi.fn(),
}));
vi.mock('~/hooks/useCurrentUser', async (importOriginal) => ({
...(await importOriginal<typeof CurrentUser>()),
useCurrentUser: () => ({ id: 1, isModerator: false }),
}));
vi.mock('~/components/Announcements/creator-announcements.utils', async (importOriginal) => ({
...(await importOriginal<typeof CreatorUtils>()),
useCreatorAnnouncementsFeature: () => true,
useIsCreatorMuted: () => false,
useToggleAnnouncementMute: (creatorId: number, creatorName?: string | null) => {
mocks.toggleArgs.push([creatorId, creatorName]);
return { toggle: mocks.toggle, isLoading: false };
},
}));
const CREATOR = 99;
/** What the hook was handed on the LAST render — the name the toast will be built from. */
function lastToggleArgs() {
return mocks.toggleArgs.at(-1);
}
async function renderBell(creatorName?: string | null) {
const { AnnouncementMuteToggle } = await import(
'~/components/Announcements/AnnouncementMuteToggle'
);
// Same marker rule as `DeleteCreatorAnnouncement.browser.test.tsx`: a read taken straight
// after render sees an uncommitted tree, so an absent control and a not-yet-rendered one
// are indistinguishable.
renderWithProviders(
<>
<span>bell rendered</span>
<AnnouncementMuteToggle creatorId={CREATOR} creatorName={creatorName} />
</>
);
await expect.element(page.getByText('bell rendered')).toBeInTheDocument();
}
async function renderMenuItem(creatorName?: string | null, muted = false) {
const { AnnouncementMuteMenuItem } = await import(
'~/components/Announcements/AnnouncementMuteToggle'
);
renderWithProviders(
<>
<span>menu rendered</span>
<Menu opened>
<Menu.Dropdown>
<AnnouncementMuteMenuItem creatorId={CREATOR} creatorName={creatorName} muted={muted} />
</Menu.Dropdown>
</Menu>
</>
);
await expect.element(page.getByText('menu rendered')).toBeInTheDocument();
}
describe('the mute control names the creator it is rendered for', () => {
beforeEach(() => {
mocks.toggleArgs = [];
mocks.toggle.mockClear();
});
test('the profile bell hands the creator name to the hook that builds the toast', async () => {
await renderBell('Kolors');
expect(lastToggleArgs()).toEqual([CREATOR, 'Kolors']);
});
test('the panel menu item hands it over too', async () => {
await renderMenuItem('Kolors');
// Read synchronously off the committed tree rather than awaiting the text: a matcher that
// never matches fails as a 15s timeout naming nothing, where this prints the label it
// actually rendered.
expect(page.getByRole('menuitem').element().textContent).toBe('Mute announcements from Kolors');
expect(lastToggleArgs()).toEqual([CREATOR, 'Kolors']);
});
test('clicking the bell still toggles, and toggles TOWARDS muted', async () => {
await renderBell('Kolors');
await userEvent.click(page.getByRole('button', { name: 'Mute announcements' }));
// The count matters as much as the argument: a doubled handler still satisfies
// `toHaveBeenCalledWith`, and double-toggling lands back where it started.
expect(mocks.toggle).toHaveBeenCalledTimes(1);
expect(mocks.toggle).toHaveBeenCalledWith(true);
});
test('the unmute label names the creator too', async () => {
await renderMenuItem('Kolors', true);
expect(page.getByRole('menuitem').element().textContent).toBe(
'Unmute announcements from Kolors'
);
});
// Paired with the positives above: without it, a component that hard-coded some name would
// pass every assertion here, and "the name arrived" would mean nothing.
test('with no name at the call site the hook is handed none — it does not invent one', async () => {
await renderBell(undefined);
expect(lastToggleArgs()).toEqual([CREATOR, undefined]);
});
});
@@ -9,10 +9,10 @@ import {
import { LegacyActionIcon } from '~/components/LegacyActionIcon/LegacyActionIcon';
import { useCurrentUser } from '~/hooks/useCurrentUser';
function useMuteControl(creatorId: number, muted: boolean) {
function useMuteControl(creatorId: number, muted: boolean, creatorName?: string | null) {
const currentUser = useCurrentUser();
const enabled = useCreatorAnnouncementsFeature();
const { toggle, isLoading } = useToggleAnnouncementMute(creatorId);
const { toggle, isLoading } = useToggleAnnouncementMute(creatorId, creatorName);
return {
isLoading,
@@ -21,9 +21,15 @@ function useMuteControl(creatorId: number, muted: boolean) {
};
}
export function AnnouncementMuteToggle({ creatorId }: { creatorId: number }) {
export function AnnouncementMuteToggle({
creatorId,
creatorName,
}: {
creatorId: number;
creatorName?: string | null;
}) {
const muted = useIsCreatorMuted(creatorId);
const { isLoading, handleToggle, visible } = useMuteControl(creatorId, muted);
const { isLoading, handleToggle, visible } = useMuteControl(creatorId, muted, creatorName);
if (!visible) return null;
const label = muted ? 'Unmute announcements' : 'Mute announcements';
@@ -54,7 +60,7 @@ export function AnnouncementMuteMenuItem({
creatorName?: string | null;
muted: boolean;
}) {
const { handleToggle, visible } = useMuteControl(creatorId, muted);
const { handleToggle, visible } = useMuteControl(creatorId, muted, creatorName);
if (!visible) return null;
const who = creatorName ? ` from ${creatorName}` : '';
@@ -24,6 +24,8 @@ const invalidate = vi.hoisted(() => ({
const captured = vi.hoisted(() => ({ options: {} as Record<string, any> }));
const notifications = vi.hoisted(() => ({ showSuccessNotification: vi.fn() }));
vi.mock('~/utils/trpc', async (importOriginal) => {
const actual = await importOriginal<typeof Trpc>();
const mutationHook = (name: string) => ({
@@ -52,7 +54,7 @@ vi.mock('~/utils/trpc', async (importOriginal) => {
});
vi.mock('~/utils/notifications', () => ({
showSuccessNotification: vi.fn(),
showSuccessNotification: notifications.showSuccessNotification,
showErrorNotification: vi.fn(),
}));
@@ -60,11 +62,15 @@ vi.mock('~/providers/FeatureFlagsProvider', () => ({
useFeatureFlags: () => ({ creatorAnnouncements: true }),
}));
import { showSuccessNotification } from '~/utils/notifications';
import {
useDeleteCreatorAnnouncement,
useToggleAnnouncementMute,
} from '~/components/Announcements/creator-announcements.utils';
const successMessage = () =>
vi.mocked(showSuccessNotification).mock.calls.at(-1)?.[0].message as string;
describe('creator announcement mutations invalidate both feeds', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -89,3 +95,50 @@ describe('creator announcement mutations invalidate both feeds', () => {
expect(invalidate.getMutedCreators).toHaveBeenCalled();
});
});
/**
* The mute confirmation names WHO was muted.
*
* Deliberate, and the reason is not obvious from the string: a toast already existed here and
* testers still reported no confirmation. Mute and dismiss sit adjacent on the same card, so a
* message naming neither the action nor the creator does not tell a reader which one ran.
*
* Asserted as whole-string equality, NOT `toContain`: the message this replaced was
* "Announcements from this creator are muted", so a substring check on the creator name, on
* "muted", or on "Announcements from" passes with the fix reverted. If you are here to relax
* these to `toContain`, the assertion stops protecting anything.
*/
describe('the mute confirmation names the creator', () => {
beforeEach(() => {
vi.clearAllMocks();
captured.options = {};
});
it('a mute names the creator in the from-slot', async () => {
useToggleAnnouncementMute(99, 'Kolors');
await captured.options.mute.onSuccess({ muted: true });
expect(successMessage()).toBe('Muted announcements from Kolors');
});
it('an unmute names the creator too, and says which way it went', async () => {
useToggleAnnouncementMute(99, 'Kolors');
await captured.options.mute.onSuccess({ muted: false });
expect(successMessage()).toBe('Unmuted announcements from Kolors');
});
it('a surface with no name in hand still says which action ran', async () => {
useToggleAnnouncementMute(99);
await captured.options.mute.onSuccess({ muted: true });
expect(successMessage()).toBe('Muted announcements from this creator');
});
it('an empty username falls back rather than reading "from "', async () => {
useToggleAnnouncementMute(99, '');
await captured.options.mute.onSuccess({ muted: true });
expect(successMessage()).toBe('Muted announcements from this creator');
});
});
@@ -50,14 +50,17 @@ export function useIsCreatorMuted(creatorId?: number) {
return data ?? false;
}
export function useToggleAnnouncementMute(creatorId: number) {
export function useToggleAnnouncementMute(creatorId: number, creatorName?: string | null) {
const queryUtils = trpc.useUtils();
const mutation = trpc.announcement.toggleAnnouncementMute.useMutation({
onSuccess: async (result) => {
// Mute and dismiss sit adjacent on the same card, so a confirmation naming neither the
// action nor the creator cannot tell a reader which of the two they just used.
const who = creatorName || 'this creator';
showSuccessNotification({
message: result.muted
? 'Announcements from this creator are muted'
: 'Announcements from this creator are unmuted',
? `Muted announcements from ${who}`
: `Unmuted announcements from ${who}`,
});
await Promise.all([
queryUtils.announcement.getMutedCreators.invalidate(),
+1 -1
View File
@@ -224,7 +224,7 @@ export function ProfileSidebar({ username, className }: { username: string; clas
style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.5, flex: 1 }}
variant={isMobile ? 'filled' : undefined}
/>
<AnnouncementMuteToggle creatorId={user.id} />
<AnnouncementMuteToggle creatorId={user.id} creatorName={user.username} />
</Group>
);