diff --git a/.changeset/hide-single-reaction-count.md b/.changeset/hide-single-reaction-count.md new file mode 100644 index 000000000..c79cfa2ff --- /dev/null +++ b/.changeset/hide-single-reaction-count.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Adds an opt-in "Hide Single Reaction Counts" setting that removes the count numeral from reaction chips with only one participant. Useful when bot reactions clutter the timeline. diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts index 482a0ae61..c966eb4a4 100644 --- a/src/app/components/message/Reaction.css.ts +++ b/src/app/components/message/Reaction.css.ts @@ -53,6 +53,12 @@ export const Reaction = style([ }, ]); +// Declared after Reaction so it wins the cascade tie and rebalances the +// right-side padding that was sized for the trailing count numeral. +export const ReactionNoCount = style({ + padding: `${toRem(2)} ${config.space.S100} ${toRem(2)} ${config.space.S100}`, +}); + export const ReactionText = style([ DefaultReset, { diff --git a/src/app/components/message/Reaction.test.tsx b/src/app/components/message/Reaction.test.tsx new file mode 100644 index 000000000..37548d9d4 --- /dev/null +++ b/src/app/components/message/Reaction.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { useSetting } from '$state/hooks/settings'; +import * as css from './Reaction.css'; +import { Reaction } from './Reaction'; + +vi.mock(import('$state/settings'), async (importOriginal) => ({ + ...(await importOriginal()), + settingsAtom: {} as never, +})); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: vi.fn(), +})); + +const defaultSettingValues: Record = { + hideSingleReactionCount: false, +}; + +describe('Reaction', () => { + beforeEach(() => { + vi.mocked(useSetting).mockImplementation(((_atom: unknown, key: string) => { + return [defaultSettingValues[key], vi.fn<() => void>()] as never; + }) as never); + }); + + it('shows the count on single-reaction chips by default', () => { + render(); + + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveClass(css.Reaction); + expect(screen.getByRole('button')).not.toHaveClass(css.ReactionNoCount); + }); + + it('shows the count on multi-reaction chips by default', () => { + render(); + + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByRole('button')).not.toHaveClass(css.ReactionNoCount); + }); + + it('hides the count on single-reaction chips when the setting is enabled', () => { + vi.mocked(useSetting).mockImplementation(((_atom: unknown, key: string) => { + if (key === 'hideSingleReactionCount') { + return [true, vi.fn<() => void>()] as never; + } + return [defaultSettingValues[key], vi.fn<() => void>()] as never; + }) as never); + + render(); + + expect(screen.getByText('😀')).toBeInTheDocument(); + expect(screen.queryByText('1')).not.toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveClass(css.ReactionNoCount); + }); + + it('keeps the count on multi-reaction chips when the setting is enabled', () => { + vi.mocked(useSetting).mockImplementation(((_atom: unknown, key: string) => { + if (key === 'hideSingleReactionCount') { + return [true, vi.fn<() => void>()] as never; + } + return [defaultSettingValues[key], vi.fn<() => void>()] as never; + }) as never); + + render(); + + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByRole('button')).not.toHaveClass(css.ReactionNoCount); + }); +}); diff --git a/src/app/components/message/Reaction.tsx b/src/app/components/message/Reaction.tsx index 12047913d..c7bf4a883 100644 --- a/src/app/components/message/Reaction.tsx +++ b/src/app/components/message/Reaction.tsx @@ -10,6 +10,8 @@ import { useAtomValue } from 'jotai'; import { Image as MediaImage } from '$components/media'; import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import { nicknamesAtom } from '$state/nicknames'; +import { settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; import * as css from './Reaction.css'; export const Reaction = as< @@ -22,6 +24,8 @@ export const Reaction = as< } >(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => { const [imgError, setImgError] = useState(false); + const [hideSingleReactionCount] = useSetting(settingsAtom, 'hideSingleReactionCount'); + const showCount = !hideSingleReactionCount || count > 1; const rawReactionUrl = reaction.startsWith('mxc://') ? (mxcUrlToHttp(mx, reaction, useAuthentication) ?? undefined) : undefined; @@ -30,7 +34,7 @@ export const Reaction = as< return ( )} - - {count} - + {showCount && ( + + {count} + + )} ); }); diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 7b559a028..bc90a0ef4 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -819,6 +819,10 @@ function Messages() { settingsAtom, 'hideMembershipInReadOnly' ); + const [hideSingleReactionCount, setHideSingleReactionCount] = useSetting( + settingsAtom, + 'hideSingleReactionCount' + ); const [showAllTimestamps, setShowAllTimestamps] = useSetting(settingsAtom, 'showAllTimestamps'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); @@ -896,6 +900,13 @@ function Messages() { value={hideMembershipInReadOnly} onChange={setHideMembershipInReadOnly} /> +