diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json index f4d2d920e4..4c54061fb9 100644 --- a/examples/tutorial/package.json +++ b/examples/tutorial/package.json @@ -16,7 +16,7 @@ "emoji-mart": "^5.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "^9.52.0", + "stream-chat": "^9.54.0", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/package.json b/examples/vite/package.json index ff9fe2c6ed..9670c077b2 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -16,7 +16,7 @@ "modern-normalize": "^3.0.1", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "^9.52.0", + "stream-chat": "^9.54.0", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/package.json b/package.json index 9301b8fdd0..f11738f07f 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "modern-normalize": "^3.0.1", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0", - "stream-chat": "^9.52.0" + "stream-chat": "^9.54.0" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -179,7 +179,7 @@ "react-dom": "^19.2.6", "sass": "^1.100.0", "semantic-release": "^25.0.3", - "stream-chat": "^9.52.0", + "stream-chat": "^9.54.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", "vite": "^8.1.3", diff --git a/src/components/Poll/PollOptionSelector.tsx b/src/components/Poll/PollOptionSelector.tsx index d7ee9a95ab..0900d19e4a 100644 --- a/src/components/Poll/PollOptionSelector.tsx +++ b/src/components/Poll/PollOptionSelector.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx'; -import debounce from 'lodash.debounce'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import type { PollOption, PollState, PollVote, VotingVisibility } from 'stream-chat'; import { isVoteAnswer } from 'stream-chat'; import { AvatarStack as DefaultAvatarStack } from '../Avatar'; @@ -85,17 +84,13 @@ export const PollOptionSelector = ({ ? vote_counts_by_option[maxVotedOptionIds[0]] : 0; - const toggleVote = useMemo( - () => - debounce(() => { - if (!canCastVote) return; - const haveVotedForTheOption = !!ownVotesByOptionId[option.id]; - return haveVotedForTheOption - ? poll.removeVote(ownVotesByOptionId[option.id].id, message.id) - : poll.castVote(option.id, message.id); - }, 100), - [canCastVote, message.id, option.id, ownVotesByOptionId, poll], - ); + const toggleVote = useCallback(() => { + if (!canCastVote) return; + const haveVotedForTheOption = !!ownVotesByOptionId[option.id]; + return haveVotedForTheOption + ? poll.removeVote(ownVotesByOptionId[option.id].id, message.id) + : poll.castVote(option.id, message.id); + }, [canCastVote, message.id, option.id, ownVotesByOptionId, poll]); const avatarDisplayInfo = useMemo( () => @@ -120,6 +115,8 @@ export const PollOptionSelector = ({ ? (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); + // a held key would otherwise toggle the vote on every auto-repeat + if (event.repeat) return; toggleVote(); } : undefined diff --git a/src/components/Poll/__tests__/PollOptionList.test.tsx b/src/components/Poll/__tests__/PollOptionList.test.tsx index 496ce69ee4..9986c8c90e 100644 --- a/src/components/Poll/__tests__/PollOptionList.test.tsx +++ b/src/components/Poll/__tests__/PollOptionList.test.tsx @@ -307,6 +307,92 @@ describe('PollOptionList', () => { }); }); + it('ignores auto-repeated key presses on a held key', async () => { + const poll = new Poll({ + client: fromPartial({}), + poll: pollWithNoVotes, + }); + const castVoteSpy = vi.spyOn(poll, 'castVote').mockResolvedValue(fromPartial({})); + const removeVoteSpy = vi.spyOn(poll, 'removeVote').mockResolvedValue(fromPartial({})); + + const { container } = renderComponent({ poll }); + const firstOption = container.querySelector(VOTABLE_OPTION_SELECTOR) as HTMLElement; + + act(() => { + fireEvent.keyDown(firstOption, { key: 'Enter' }); + fireEvent.keyDown(firstOption, { key: 'Enter', repeat: true }); + fireEvent.keyDown(firstOption, { key: ' ', repeat: true }); + }); + + await waitFor(() => { + expect(castVoteSpy).toHaveBeenCalledTimes(1); + }); + expect(removeVoteSpy).not.toHaveBeenCalled(); + }); + + // Renders a poll whose votes go through stream-chat's optimistic vote updates, with only the + // vote requests mocked. Each cast gets a new server vote id: server-vote-1, server-vote-2... + const renderWithOptimisticVotes = async () => { + const client = await getTestClientWithUser(generateUser()); + const poll = new Poll({ client, poll: pollWithNoVotes }); + const optionId = pollWithNoVotes.options[0].id; + const requests: string[] = []; + let castCount = 0; + vi.spyOn(client, 'castPollVote').mockImplementation(() => { + const id = `server-vote-${++castCount}`; + requests.push(`cast ${id}`); + return Promise.resolve( + fromPartial({ + vote: { + created_at: new Date().toISOString(), + id, + option_id: optionId, + poll_id: pollWithNoVotes.id, + updated_at: new Date().toISOString(), + user: client.user, + user_id: client.userID, + }, + }), + ); + }); + vi.spyOn(client, 'removePollVote').mockImplementation((_, __, voteId) => { + requests.push(`remove ${voteId}`); + return Promise.resolve(fromPartial({})); + }); + + const { container } = renderComponent({ poll }); + const option = container.querySelector(VOTABLE_OPTION_SELECTOR) as HTMLElement; + return { client, option, optionId, requests }; + }; + + it('toggles the vote on rapid clicks without waiting for the server', async () => { + const { client, option, optionId, requests } = await renderWithOptimisticVotes(); + const messageId = defaultMessageContext.message.id; + + // the second click lands before the server responded to the first one + act(() => { + fireEvent.click(option); + }); + expect(option).toHaveAttribute('aria-pressed', 'true'); + act(() => { + fireEvent.click(option); + }); + expect(option).toHaveAttribute('aria-pressed', 'false'); + + // the removal waits for the cast and removes the vote the server created + await waitFor(() => { + expect(requests).toEqual(['cast server-vote-1', 'remove server-vote-1']); + }); + expect(client.castPollVote).toHaveBeenCalledWith(messageId, pollWithNoVotes.id, { + option_id: optionId, + }); + expect(client.removePollVote).toHaveBeenCalledWith( + messageId, + pollWithNoVotes.id, + 'server-vote-1', + ); + }); + it('passes axe on the default poll option list', async () => { const poll = new Poll({ client: fromPartial({}), poll: generatePoll() }); const { container } = renderComponent({ poll }); diff --git a/yarn.lock b/yarn.lock index 0306691a65..de0969ac63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2069,7 +2069,7 @@ __metadata: emoji-mart: "npm:^5.6.0" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" - stream-chat: "npm:^9.52.0" + stream-chat: "npm:^9.54.0" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -2095,7 +2095,7 @@ __metadata: react: "npm:^19.2.6" react-dom: "npm:^19.2.6" sass: "npm:^1.100.0" - stream-chat: "npm:^9.52.0" + stream-chat: "npm:^9.54.0" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -10104,7 +10104,7 @@ __metadata: remark-parse: "npm:^11.0.0" sass: "npm:^1.100.0" semantic-release: "npm:^25.0.3" - stream-chat: "npm:^9.52.0" + stream-chat: "npm:^9.54.0" typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" unified: "npm:^11.0.5" @@ -10121,7 +10121,7 @@ __metadata: modern-normalize: ^3.0.1 react: ^19.0.0 || ^18.0.0 || ^17.0.0 react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0 - stream-chat: ^9.52.0 + stream-chat: ^9.54.0 dependenciesMeta: "@parcel/watcher": built: true @@ -10145,9 +10145,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.52.0": - version: 9.52.0 - resolution: "stream-chat@npm:9.52.0" +"stream-chat@npm:^9.54.0": + version: 9.54.0 + resolution: "stream-chat@npm:9.54.0" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -10163,7 +10163,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/1ff1a595a17f54e244e1d0803e2821887092b609f8ab56377a006789cdaaed1307ed09cb9a64aecf8f863aefee6050a3b23bd9eec2b14bbbe661a26410d2c8c0 + checksum: 10c0/f8c02372ffa672e759970223811667b8263bbded048a85adbdd6c3ed60a16f72c882d9700538b94a3148a8e4bfa259eafae5effdc0d8e7ffd2525e61f27a190e languageName: node linkType: hard