From f9172cc33911e66ff3901c38082a7ca6b5d90985 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 19:54:01 -0400 Subject: [PATCH] fix(polls): you can vote on a poll you just created (#284) PollContent read mEvent.getId() once at render. For your own new poll that is the local echo id (~!room:txn), and nothing re-rendered it when the SDK swapped in the real $id, so the vote related to the local id and the SDK threw synchronously (getPendingEvents under chronological ordering): no vote was ever sent. Track the id via LocalEventIdReplaced and Status, block voting while it is still local, and catch synchronous throws. Adds the Playwright regression the issue asks for. Fixes #284 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- e2e/local-homeserver.spec.ts | 46 +++++++++++++++ .../message/content/PollContent.tsx | 58 +++++++++++++------ 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/e2e/local-homeserver.spec.ts b/e2e/local-homeserver.spec.ts index 0df5c0d1a..7dcbd6f63 100644 --- a/e2e/local-homeserver.spec.ts +++ b/e2e/local-homeserver.spec.ts @@ -55,6 +55,52 @@ test.describe('local homeserver regression', () => { expect(console_.unexpected()).toEqual([]); }); + test('you can vote on a poll you just created, without reloading (#284)', async ({ page }) => { + const console_ = collectConsole(page); + const room = await createRoom(alice, 'Poll Room'); + await loginUI(page, alice); + await openRoom(page, room); + const question = uniq('Lunch? '); + await page.getByRole('button', { name: 'Create poll' }).first().click(); + await page.getByPlaceholder('Ask a question…').fill(question); + await page.getByLabel('Option 1', { exact: true }).fill('Pizza'); + await page.getByLabel('Option 2', { exact: true }).fill('Tacos'); + await page.getByRole('button', { name: 'Create Poll' }).last().click(); + const poll = page.locator('[data-message-id]', { hasText: question }).last(); + await expect(poll).toBeVisible(); + type Ev = { + type: string; + event_id: string; + content: { 'm.relates_to'?: { event_id?: string } }; + }; + const events = async () => + ( + await api<{ chunk: Ev[] }>( + 'GET', + `/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=10`, + alice.token, + ) + ).chunk; + // Vote once the poll has reached the server (its row may still carry the local id). + await expect + .poll(async () => (await events()).some((e) => /poll\.start/.test(e.type))) + .toBe(true); + await poll.getByText('Tacos', { exact: true }).first().click(); + await expect + .poll(async () => { + const chunk = await events(); + const start = chunk.find((e) => /poll\.start/.test(e.type)); + return chunk.some( + (e) => + /poll\.response/.test(e.type) && + e.content['m.relates_to']?.event_id === start?.event_id && + start?.event_id.startsWith('$'), + ); + }) + .toBe(true); + expect(console_.unexpected()).toEqual([]); + }); + test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => { const room = await createRoom(alice, 'Scroll Room'); await Array.from({ length: 40 }).reduce>( diff --git a/src/app/components/message/content/PollContent.tsx b/src/app/components/message/content/PollContent.tsx index 87a194970..819ecc19e 100644 --- a/src/app/components/message/content/PollContent.tsx +++ b/src/app/components/message/content/PollContent.tsx @@ -1,7 +1,7 @@ import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds'; import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; -import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk'; +import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMemberName, sendRoomEvent } from '../../../utils/room'; import { @@ -99,7 +99,21 @@ export function PollContent({ }) { const mx = useMatrixClient(); const roomId = room.roomId; - const eventId = mEvent.getId(); + // [Gitea #284] A poll you just created starts with its local-echo id + // (`~!room:txn`); the SDK swaps in the real `$event` id once it's sent but + // doesn't re-render us. Track the id so votes and the tally use the real one. + const [eventId, setEventId] = useState(() => mEvent.getId()); + useEffect(() => { + const sync = () => setEventId(mEvent.getId()); + sync(); + mEvent.on(MatrixEventEvent.LocalEventIdReplaced, sync); + mEvent.on(MatrixEventEvent.Status, sync); + return () => { + mEvent.off(MatrixEventEvent.LocalEventIdReplaced, sync); + mEvent.off(MatrixEventEvent.Status, sync); + }; + }, [mEvent]); + const isLocalEcho = !!eventId?.startsWith('~'); const senderId = mEvent.getSender(); const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]); @@ -192,8 +206,9 @@ export function PollContent({ const isMultiple = maxSelections > 1; const showResults = resultsVisible(isUndisclosed, isEnded); - const canVote = !!roomId && !!eventId && !isEnded; - const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact); + const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded; + const canEnd = + !!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact); const myVotes = pending ?? tally.myVotes; const { counts, total, voters } = tally; @@ -219,25 +234,34 @@ export function PollContent({ setPending(next); // Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader // accepts both namespaces). - sendRoomEvent(mx, roomId, null, 'm.poll.response', { - 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, - 'm.selections': Array.from(next), - }).catch(() => setPending(null)); + // The SDK can throw synchronously (e.g. a relation to a pending event). + try { + sendRoomEvent(mx, roomId, null, 'm.poll.response', { + 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, + 'm.selections': Array.from(next), + }).catch(() => setPending(null)); + } catch { + setPending(null); + } }; const handleEndPoll = () => { if (!roomId || !eventId || ending) return; setEnding(true); - sendRoomEvent(mx, roomId, null, 'm.poll.end', { - 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, - 'm.poll.end': {}, - 'm.text': 'The poll has ended.', - }) - .then(() => { - setConfirmEnd(false); - setEnding(false); + try { + sendRoomEvent(mx, roomId, null, 'm.poll.end', { + 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, + 'm.poll.end': {}, + 'm.text': 'The poll has ended.', }) - .catch(() => setEnding(false)); + .then(() => { + setConfirmEnd(false); + setEnding(false); + }) + .catch(() => setEnding(false)); + } catch { + setEnding(false); + } }; // Radiogroup keyboard model (single-choice only): arrows move focus + selection.