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.