From e0512fbda196735c7f0d68c335ed900c9c9abb39 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 20:08:29 -0400 Subject: [PATCH 1/7] fix(polls): add /poll slash command and move poll button to attach menu --- src/app/features/room/RoomInput.tsx | 73 +++++++++++++++++++++++++---- src/app/hooks/useCommands.ts | 7 +++ 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 69f67248b..7d5d61abd 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -383,6 +383,8 @@ export const RoomInput = forwardRef( ); const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState(); const [showSchedulePicker, setShowSchedulePicker] = useState(false); + const [pollCreatorOpen, setPollCreatorOpen] = useState(false); + const [attachMenuAnchor, setAttachMenuAnchor] = useState(); const [silentReply, setSilentReply] = useState(!mentionInReplies); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const setServerMaxDelayMs = useSetAtom(serverMaxDelayMsAtom); @@ -813,6 +815,12 @@ export const RoomInput = forwardRef( } else if (commandName === Command.UnFlip) { plainText = `${UNFLIP} ${plainText}`; customHtml = `${UNFLIP} ${customHtml}`; + } else if (commandName === Command.CreatePoll) { + setPollCreatorOpen(true); + resetEditor(editor); + resetEditorHistory(editor); + sendTypingStatus(false); + return; } else if (commandName) { const commandContent = commands[commandName as Command]; if (commandContent) { @@ -1517,16 +1525,63 @@ export const RoomInput = forwardRef( } before={ - pickFile('*')} - variant="SurfaceVariant" - size="300" - radii="300" - title="Upload File" - aria-label="Upload and attach a File" + setAttachMenuAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ } + onClick={() => { + setAttachMenuAnchor(undefined); + pickFile('*'); + }} + > + Upload File + + } + onClick={() => { + setAttachMenuAnchor(undefined); + setPollCreatorOpen(true); + }} + > + Create Poll + +
+
+ + } > - -
+ + setAttachMenuAnchor(evt.currentTarget.getBoundingClientRect()) + } + variant="SurfaceVariant" + size="300" + radii="300" + title="Attach" + aria-label="Attach or create poll" + > + + + } after={ <> diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 1a9818ed0..ddc0c8161 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -285,6 +285,8 @@ export enum Command { // Spec missing from cinny Location = 'location', ShareMyLocation = 'sharemylocation', + // Polls + CreatePoll = 'poll', } export type CommandContent = { @@ -1614,6 +1616,11 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { navigator.geolocation.getCurrentPosition(success, error, options); }, }, + [Command.CreatePoll]: { + name: Command.CreatePoll, + description: 'Create a poll', + exe: async () => undefined, + }, }), [ mx, From 01015d2497816bd4cc299b6cff2bb4645301d35a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 4 May 2026 10:22:35 -0400 Subject: [PATCH 2/7] feat(polls): rewrite PollContent to match cinny PR #2763 UI - Attachment card wrapper with header row (poll type label, ended state, vote count) - RadioButton per answer, ProgressBar for results (disclosed/ended undisclosed) - Reactive ended state via Poll.isEnded + PollModelEvent.End listener - End poll confirmation modal for undisclosed poll creators - messageLayout prop wired through to Attachment outlined for bubble layout --- src/app/features/room/PollContent.tsx | 306 ++++++++++++++++++ src/app/features/room/PollCreator.tsx | 298 +++++++++++++++++ src/app/features/room/RoomInput.tsx | 5 +- src/app/features/settings/general/General.tsx | 2 + .../timeline/useTimelineEventRenderer.tsx | 11 + src/app/hooks/usePollTally.test.ts | 105 ++++++ src/app/hooks/usePollTally.ts | 77 +++++ vite.config.ts | 9 + 8 files changed, 810 insertions(+), 3 deletions(-) create mode 100644 src/app/features/room/PollContent.tsx create mode 100644 src/app/features/room/PollCreator.tsx create mode 100644 src/app/hooks/usePollTally.test.ts create mode 100644 src/app/hooks/usePollTally.ts diff --git a/src/app/features/room/PollContent.tsx b/src/app/features/room/PollContent.tsx new file mode 100644 index 000000000..6dd62c142 --- /dev/null +++ b/src/app/features/room/PollContent.tsx @@ -0,0 +1,306 @@ +import type { MatrixEvent, Relations, Room } from '$types/matrix-sdk'; +import { useCallback, useEffect, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + config, + Header, + Line, + Modal, + Overlay, + OverlayBackdrop, + OverlayCenter, + ProgressBar, + RadioButton, + Text, +} from 'folds'; +import { + M_POLL_END, + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, + M_POLL_RESPONSE, + M_POLL_START, +} from 'matrix-js-sdk/lib/@types/polls'; +import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; +import { M_TEXT } from 'matrix-js-sdk/lib/@types/extensible_events'; +import { PollEvent as PollModelEvent } from 'matrix-js-sdk/lib/models/poll'; +import type { Poll } from 'matrix-js-sdk/lib/models/poll'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { tallyCounts } from '$hooks/usePollTally'; +import { MessageLayout } from '$state/settings'; +import { Attachment, AttachmentBox, AttachmentContent } from '$components/message/attachment'; +import { stopPropagation } from '$utils/keyboard'; + +type PollContentProps = { + mEvent: MatrixEvent; + room: Room; + messageLayout?: MessageLayout; +}; + +function pluralize(amount: number, noun: string): string { + return amount === 1 ? noun : `${noun}s`; +} + +function getAnswerText(answer: PollAnswer): string { + const raw = answer as unknown as Record; + return ( + (raw[M_TEXT.name] as string | undefined) ?? (raw[M_TEXT.altName] as string | undefined) ?? '' + ); +} + +export function PollContent({ mEvent, room, messageLayout }: PollContentProps) { + const mx = useMatrixClient(); + const content = mEvent.getContent(); + + const pollRaw = (content[M_POLL_START.name] ?? content[M_POLL_START.altName]) as + | Record + | undefined; + + const question: string = (() => { + if (!pollRaw) return '(Poll)'; + const q = pollRaw.question as Record | undefined; + if (!q) return '(Poll)'; + return ( + (q[M_TEXT.name] as string | undefined) ?? + (q[M_TEXT.altName] as string | undefined) ?? + '(Poll)' + ); + })(); + + const answers = (pollRaw?.answers as PollAnswer[] | undefined) ?? []; + const maxSelections = (pollRaw?.max_selections as number | undefined) ?? 1; + const kind = (pollRaw?.kind as string | undefined) ?? M_POLL_KIND_DISCLOSED.name; + const isDisclosed = kind === M_POLL_KIND_DISCLOSED.name || kind === M_POLL_KIND_DISCLOSED.altName; + const isUndisclosed = + kind === M_POLL_KIND_UNDISCLOSED.name || kind === M_POLL_KIND_UNDISCLOSED.altName; + + const eventId = mEvent.getId() ?? ''; + const roomId = room.roomId; + const myUserId = mx.getUserId() ?? ''; + const senderId = mEvent.getSender() ?? ''; + + const [relations, setRelations] = useState(undefined); + const [isEnded, setIsEnded] = useState(false); + const [openEndModal, setOpenEndModal] = useState(false); + + useEffect(() => { + const roomWithPolls = room as unknown as { polls: Map }; + const poll = roomWithPolls.polls.get(eventId); + + if (poll) { + setIsEnded(poll.isEnded); + + poll + .getResponses() + .then((rels) => setRelations(rels)) + .catch(console.warn); + + const onResponses = (rels: Relations) => setRelations(rels); + const onEnd = () => setIsEnded(true); + poll.on(PollModelEvent.Responses, onResponses); + poll.on(PollModelEvent.End, onEnd); + return () => { + poll.off(PollModelEvent.Responses, onResponses); + poll.off(PollModelEvent.End, onEnd); + }; + } + + return undefined; + }, [room, eventId]); + + const tally = tallyCounts(answers, relations, myUserId, maxSelections); + const canShowResults = isDisclosed || (isUndisclosed && isEnded); + const outlined = messageLayout === MessageLayout.Bubble; + + const handleVote = useCallback( + async (answerId: string) => { + if (isEnded) return; + const isSelected = tally.myAnswers.includes(answerId); + let newAnswers: string[]; + if (maxSelections === 1) { + newAnswers = isSelected ? [] : [answerId]; + } else { + newAnswers = isSelected + ? tally.myAnswers.filter((id) => id !== answerId) + : [...tally.myAnswers, answerId].slice(0, maxSelections); + } + + const voteContent: Record = { + [M_POLL_RESPONSE.name]: { answers: newAnswers }, + [M_POLL_RESPONSE.altName]: { answers: newAnswers }, + 'm.relates_to': { + rel_type: 'm.reference', + event_id: eventId, + }, + }; + + type SendEventContent = Parameters[3]; + await ( + mx as unknown as { + sendEvent( + roomId: string, + threadId: null, + eventType: string, + content: SendEventContent + ): Promise; + } + ).sendEvent(roomId, null, M_POLL_RESPONSE.name, voteContent as unknown as SendEventContent); + }, + [mx, roomId, eventId, tally.myAnswers, maxSelections, isEnded] + ); + + const handleEndPoll = useCallback(async () => { + type SendEventContent = Parameters[3]; + await ( + mx as unknown as { + sendEvent( + roomId: string, + threadId: null, + eventType: string, + content: SendEventContent + ): Promise; + } + ).sendEvent(roomId, null, M_POLL_END.name, { + [M_POLL_END.name]: {}, + [M_POLL_END.altName]: {}, + 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, + 'm.text': 'The poll has ended.', + } as unknown as SendEventContent); + setOpenEndModal(false); + }, [mx, roomId, eventId]); + + const pollLabel = isDisclosed ? 'Poll' : 'Undisclosed poll'; + + return ( + <> + }> + + setOpenEndModal(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ + End poll + +
+ + + Are you sure you want to end this poll? This will reveal the results, and no one + will be able to vote anymore. + + + + + + +
+
+
+
+ + + + + {pollLabel} + {isEnded ? ' (ended)' : ''} + + + + {tally.totalVoters} {pluralize(tally.totalVoters, 'vote')} + + + + + + {question} + + {answers.map((answer, idx) => { + const text = getAnswerText(answer); + const isSelected = tally.myAnswers.includes(answer.id); + const voteCount = tally.counts.get(answer.id) ?? 0; + return ( + + + handleVote(answer.id)} + checked={isSelected} + /> + + + + + {text || `Option ${idx + 1}`} + + {canShowResults && ( + + {voteCount} {pluralize(voteCount, 'vote')} + + )} + + {canShowResults && ( + + )} + + + ); + })} + {isUndisclosed && !isEnded && senderId === myUserId && ( + <> + + + + )} + + + + + + ); +} diff --git a/src/app/features/room/PollCreator.tsx b/src/app/features/room/PollCreator.tsx new file mode 100644 index 000000000..dc6a07eeb --- /dev/null +++ b/src/app/features/room/PollCreator.tsx @@ -0,0 +1,298 @@ +import { useCallback, useRef, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Dialog, + Header, + Icon, + IconButton, + Icons, + Input, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Switch, + Text, + config, +} from 'folds'; +import { PollStartEvent } from 'matrix-js-sdk/lib/extensible_events_v1/PollStartEvent'; +import { M_POLL_KIND_DISCLOSED, M_POLL_KIND_UNDISCLOSED } from 'matrix-js-sdk/lib/@types/polls'; +import type { Room } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; + +const MIN_ANSWERS = 2; +const MAX_ANSWERS = 20; + +let answerIdSeed = 0; +function newId(): string { + answerIdSeed += 1; + return `a${answerIdSeed}`; +} + +type AnswerDraft = { id: string; text: string }; + +type PollCreatorProps = { + room: Room; + onClose: () => void; +}; + +export function PollCreator({ room, onClose }: PollCreatorProps) { + const mx = useMatrixClient(); + + const [question, setQuestion] = useState(''); + const [answers, setAnswers] = useState([ + { id: newId(), text: '' }, + { id: newId(), text: '' }, + ]); + const [multiSelect, setMultiSelect] = useState(false); + const [maxSelections, setMaxSelections] = useState(2); + const [disclosed, setDisclosed] = useState(true); + const [sending, setSending] = useState(false); + const [error, setError] = useState(); + + const lastInputRef = useRef(null); + + const handleAddAnswer = useCallback(() => { + if (answers.length >= MAX_ANSWERS) return; + setAnswers((prev) => [...prev, { id: newId(), text: '' }]); + requestAnimationFrame(() => lastInputRef.current?.focus()); + }, [answers.length]); + + const handleRemoveAnswer = useCallback( + (id: string) => { + if (answers.length <= MIN_ANSWERS) return; + setAnswers((prev) => prev.filter((a) => a.id !== id)); + }, + [answers.length] + ); + + const handleAnswerChange = useCallback((id: string, text: string) => { + setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, text } : a))); + }, []); + + const handleMultiSelectToggle = useCallback((v: boolean) => { + setMultiSelect(v); + if (v) setMaxSelections(2); + }, []); + + const handleSend = useCallback(async () => { + const q = question.trim(); + if (!q) { + setError('Please enter a question.'); + return; + } + const validAnswers = answers.map((a) => a.text.trim()).filter(Boolean); + if (validAnswers.length < MIN_ANSWERS) { + setError(`Please fill in at least ${MIN_ANSWERS} answer options.`); + return; + } + + const kind = disclosed ? M_POLL_KIND_DISCLOSED : M_POLL_KIND_UNDISCLOSED; + const maxSel = multiSelect ? Math.max(2, Math.min(maxSelections, validAnswers.length)) : 1; + const pollEvent = PollStartEvent.from(q, validAnswers, kind, maxSel); + const serialized = pollEvent.serialize(); + + setSending(true); + setError(undefined); + try { + type SendEventContent = Parameters[3]; + await ( + mx as unknown as { + sendEvent( + roomId: string, + threadId: null, + eventType: string, + content: SendEventContent + ): Promise; + } + ).sendEvent( + room.roomId, + null, + serialized.type, + serialized.content as unknown as SendEventContent + ); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send poll.'); + setSending(false); + } + }, [question, answers, multiSelect, maxSelections, disclosed, mx, room.roomId, onClose]); + + return ( + }> + + + +
+ + + Create Poll + + + + +
+ + + + + {/* Question */} + + Question + setQuestion((e.target as HTMLInputElement).value)} + maxLength={340} + /> + + + {/* Answers */} + + Options + {answers.map((ans, idx) => ( + + + + handleAnswerChange(ans.id, (e.target as HTMLInputElement).value) + } + maxLength={340} + /> + + handleRemoveAnswer(ans.id)} + variant="Surface" + size="300" + radii="300" + disabled={answers.length <= MIN_ANSWERS} + aria-label={`Remove option ${idx + 1}`} + > + + + + ))} + {answers.length < MAX_ANSWERS && ( + + )} + + + {/* Multi-select */} + + + + Allow multiple selections + + {multiSelect && ( + + Up to + { + const v = parseInt((e.target as HTMLInputElement).value, 10); + if (!Number.isNaN(v)) { + setMaxSelections(Math.max(2, Math.min(v, answers.length))); + } + }} + style={{ width: '4rem' }} + /> + + )} + + + {/* Disclosed toggle */} + + + + {disclosed ? 'Disclosed poll' : 'Undisclosed poll'} + + {disclosed + ? 'Results visible while voting' + : 'Results hidden until poll ends'} + + + + + {error && ( + + {error} + + )} + + + + {/* Footer */} + + + + + +
+
+
+
+ ); +} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 7d5d61abd..512aa5e58 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1570,9 +1570,7 @@ export const RoomInput = forwardRef( } > - setAttachMenuAnchor(evt.currentTarget.getBoundingClientRect()) - } + onClick={(evt) => setAttachMenuAnchor(evt.currentTarget.getBoundingClientRect())} variant="SurfaceVariant" size="300" radii="300" @@ -1824,6 +1822,7 @@ export const RoomInput = forwardRef( }} /> )} + {pollCreatorOpen && setPollCreatorOpen(false)} />} ); } diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 29361bdd6..8049885d5 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -419,6 +419,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [mentionInReplies, setMentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); return ( @@ -476,6 +477,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { after={} /> + ); + if ( + type === (M_POLL_START.name as string) || + type === (M_POLL_START.altName as string) + ) + return ; if (type === (EventType.RoomMessage as string)) { const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); let editedNewContent: unknown; @@ -686,6 +691,12 @@ export function useTimelineEventRenderer({ ); }, + [M_POLL_START.name]: (_mEventId, mEvent) => ( + + ), + [M_POLL_START.altName]: (_mEventId, mEvent) => ( + + ), [EventType.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => { const { replyEventId: rawReplyEventId, threadRootId } = mEvent; const isThreadRel = isThreadRelationEvent(mEvent, threadRootId); diff --git a/src/app/hooks/usePollTally.test.ts b/src/app/hooks/usePollTally.test.ts new file mode 100644 index 000000000..e32529b49 --- /dev/null +++ b/src/app/hooks/usePollTally.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import type { Relations } from '$types/matrix-sdk'; +import type { MatrixEvent } from '$types/matrix-sdk'; +import { M_POLL_RESPONSE } from 'matrix-js-sdk/lib/@types/polls'; +import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; +import { tallyCounts } from '$hooks/usePollTally'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeRelations(events: Partial[]): Relations { + return { + getRelations: () => events as MatrixEvent[], + } as unknown as Relations; +} + +function makeVote(sender: string, answerIds: string[], ts = 1000): Partial { + return { + getSender: () => sender, + getTs: () => ts, + getContent: (() => ({ + [M_POLL_RESPONSE.name]: { answers: answerIds }, + })) as MatrixEvent['getContent'], + }; +} + +const ANSWERS: PollAnswer[] = [ + { id: 'a', body: 'Option A', mimetype: 'text/plain' } as unknown as PollAnswer, + { id: 'b', body: 'Option B', mimetype: 'text/plain' } as unknown as PollAnswer, + { id: 'c', body: 'Option C', mimetype: 'text/plain' } as unknown as PollAnswer, +]; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('tallyCounts', () => { + it('returns zero counts and no voters for empty relations', () => { + const result = tallyCounts(ANSWERS, makeRelations([]), '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + expect(result.counts.get('a')).toBe(0); + expect(result.myAnswers).toEqual([]); + }); + + it('counts a single vote correctly', () => { + const rel = makeRelations([makeVote('@alice:example.com', ['a'])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.counts.get('a')).toBe(1); + expect(result.counts.get('b')).toBe(0); + expect(result.totalVoters).toBe(1); + }); + + it('only counts the last vote per user', () => { + const rel = makeRelations([ + makeVote('@alice:example.com', ['a'], 1000), + makeVote('@alice:example.com', ['b'], 2000), // later — should win + ]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.counts.get('a')).toBe(0); + expect(result.counts.get('b')).toBe(1); + expect(result.totalVoters).toBe(1); + }); + + it('ignores invalid answer IDs (not in poll answers)', () => { + const rel = makeRelations([makeVote('@alice:example.com', ['z'])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + expect([...result.counts.values()].every((v) => v === 0)).toBe(true); + }); + + it('tracks the current user vote in myAnswers', () => { + const rel = makeRelations([makeVote('@me:example.com', ['c'])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.myAnswers).toEqual(['c']); + }); + + it('supports multi-select up to max_selections', () => { + const rel = makeRelations([makeVote('@alice:example.com', ['a', 'b', 'c'])]); + // max_selections = 2 → only first 2 are kept + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 2); + expect(result.counts.get('a')).toBe(1); + expect(result.counts.get('b')).toBe(1); + expect(result.counts.get('c')).toBe(0); + }); + + it('handles null/undefined relations gracefully', () => { + const result = tallyCounts(ANSWERS, null, '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + }); + + it('counts multiple distinct voters independently', () => { + const rel = makeRelations([ + makeVote('@alice:example.com', ['a']), + makeVote('@bob:example.com', ['a']), + makeVote('@carol:example.com', ['b']), + ]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.counts.get('a')).toBe(2); + expect(result.counts.get('b')).toBe(1); + expect(result.totalVoters).toBe(3); + }); + + it('treats empty answers array as a spoil (abstain) — not counted in totalVoters', () => { + const rel = makeRelations([makeVote('@alice:example.com', [])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + }); +}); diff --git a/src/app/hooks/usePollTally.ts b/src/app/hooks/usePollTally.ts new file mode 100644 index 000000000..f643b3311 --- /dev/null +++ b/src/app/hooks/usePollTally.ts @@ -0,0 +1,77 @@ +import type { Relations } from '$types/matrix-sdk'; +import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; +import { M_POLL_RESPONSE } from 'matrix-js-sdk/lib/@types/polls'; + +export type PollTally = { + /** Map from answerId → vote count (deduplicated to last vote per user) */ + counts: Map; + /** Total number of users who cast at least one valid answer */ + totalVoters: number; + /** The current user's selected answer IDs (empty = not voted) */ + myAnswers: string[]; +}; + +/** + * Pure function — tallies poll votes from a Relations object. + * + * Rules per MSC3381: + * - Only the last response per user is counted. + * - Answers that don't exist in the poll's answer list are ignored (spoiled). + * - A response with an empty answers array is a deliberate spoil (abstain). + */ +export function tallyCounts( + answers: PollAnswer[], + relations: Relations | null | undefined, + myUserId: string, + maxSelections: number +): PollTally { + const validIds = new Set(answers.map((a) => a.id)); + const answerIds = answers.map((a) => a.id); + + // Map userId → their last response's answer IDs (already validated) + const lastVoteByUser = new Map(); + + const events = relations?.getRelations() ?? []; + + // Sort ascending so iterating gives chronological order; last write wins + const sorted = [...events].toSorted((a, b) => a.getTs() - b.getTs()); + + for (const event of sorted) { + const sender = event.getSender(); + if (!sender) continue; + + const content = event.getContent(); + // Support both stable (m.poll.response) and unstable (org.matrix.msc3381.poll.response) keys + const responsePart = + (content[M_POLL_RESPONSE.name] as { answers?: unknown } | undefined) ?? + (content[M_POLL_RESPONSE.altName] as { answers?: unknown } | undefined); + + if (!responsePart || !Array.isArray(responsePart.answers)) { + continue; + } + + const rawAnswers = responsePart.answers as unknown[]; + // Filter to only valid answer IDs; enforce max_selections limit + const validAnswers = ( + rawAnswers.filter((id) => typeof id === 'string' && validIds.has(id)) as string[] + ).slice(0, Math.max(1, maxSelections)); + + lastVoteByUser.set(sender, validAnswers); + } + + const counts = new Map(answerIds.map((id) => [id, 0])); + let myAnswers: string[] = []; + + for (const [userId, selectedIds] of lastVoteByUser) { + for (const id of selectedIds) { + counts.set(id, (counts.get(id) ?? 0) + 1); + } + if (userId === myUserId) { + myAnswers = selectedIds; + } + } + + const totalVoters = Array.from(lastVoteByUser.values()).filter((ids) => ids.length > 0).length; + + return { counts, totalVoters, myAnswers }; +} diff --git a/vite.config.ts b/vite.config.ts index bfa79f67c..79730791f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -50,6 +50,15 @@ const resolveBuildHash = (): string | undefined => { const appVersion = packageJson.version; const buildHash = resolveBuildHash(); +const injectedExperimentFlags = Object.fromEntries( + Object.entries(process.env) + .filter(([k]) => k.startsWith('VITE_FEATURE_')) + .map(([k, v]) => [ + k.slice('VITE_FEATURE_'.length).toLowerCase().replace(/_/g, '-'), + v === 'true', + ]) +); + const isReleaseTag = (() => { const envVal = process.env.VITE_IS_RELEASE_TAG; if (envVal !== undefined && envVal !== '') return envVal === 'true'; From 50e3fa6949c42f07b19b31c385b29d6a4f6d9177 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 11 May 2026 23:04:28 -0400 Subject: [PATCH 3/7] fix(room-input): remove polls toolbar button; wire /poll slash command The polls toolbar button is removed from RoomInput. The /poll slash command now opens PollCreator directly, keeping the feature accessible via keyboard-driven workflow without cluttering the toolbar. --- src/app/features/room/RoomInput.tsx | 65 +++++------------------------ 1 file changed, 10 insertions(+), 55 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 512aa5e58..69c1f94a1 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -160,6 +160,7 @@ import type { import { AudioMessageRecorder } from './AudioMessageRecorder'; import * as prefix from '$unstable/prefixes'; + // Returns the event ID of the most recent non-reaction/non-edit event in a thread, // falling back to the thread root if no replies exist yet. const getLatestThreadEventId = (room: Room, threadRootId: string): string => { @@ -384,7 +385,6 @@ export const RoomInput = forwardRef( const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState(); const [showSchedulePicker, setShowSchedulePicker] = useState(false); const [pollCreatorOpen, setPollCreatorOpen] = useState(false); - const [attachMenuAnchor, setAttachMenuAnchor] = useState(); const [silentReply, setSilentReply] = useState(!mentionInReplies); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const setServerMaxDelayMs = useSetAtom(serverMaxDelayMsAtom); @@ -1525,61 +1525,16 @@ export const RoomInput = forwardRef( } before={ - setAttachMenuAnchor(undefined), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
- } - onClick={() => { - setAttachMenuAnchor(undefined); - pickFile('*'); - }} - > - Upload File - - } - onClick={() => { - setAttachMenuAnchor(undefined); - setPollCreatorOpen(true); - }} - > - Create Poll - -
-
- - } + pickFile('*')} + variant="SurfaceVariant" + size="300" + radii="300" + title="Upload File" + aria-label="Upload and attach a File" > - setAttachMenuAnchor(evt.currentTarget.getBoundingClientRect())} - variant="SurfaceVariant" - size="300" - radii="300" - title="Attach" - aria-label="Attach or create poll" - > - - -
+ +
} after={ <> From e5a51469047ed134260ed284a0cfcf512cb7687a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 11 May 2026 12:40:10 -0400 Subject: [PATCH 4/7] fix(polls): complete sendEvent payloads for poll response and end The poll response and end-poll sendEvent calls were missing their event content bodies and .catch() handlers, so voting and ending polls were silently broken. Add the m.relates_to, response answers, and msc3381 compat fields. --- src/app/features/room/poll/PollEvent.tsx | 499 ++++++++++++++++++ .../timeline/useTimelineEventRenderer.tsx | 206 +++++++- 2 files changed, 699 insertions(+), 6 deletions(-) create mode 100644 src/app/features/room/poll/PollEvent.tsx diff --git a/src/app/features/room/poll/PollEvent.tsx b/src/app/features/room/poll/PollEvent.tsx new file mode 100644 index 000000000..9ee8ffa45 --- /dev/null +++ b/src/app/features/room/poll/PollEvent.tsx @@ -0,0 +1,499 @@ +import { type ReactNode, useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Checkbox, + config, + Icon, + Icons, + Line, + Menu, + PopOut, + ProgressBar, + RadioButton, + Scroll, + Text, + toRem, +} from 'folds'; +import { + M_POLL_END, + M_POLL_KIND_DISCLOSED, + M_POLL_RESPONSE, + M_POLL_START, + MatrixEventEvent, + RoomEvent, +} from '$types/matrix-sdk'; +import type { MatrixEvent, Room } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { stopPropagation } from '$utils/keyboard'; +import { + Attachment, + AttachmentBox, + AttachmentContent, + AttachmentHeader, +} from '$components/message/attachment/Attachment'; +import { MessageEvent } from '$types/matrix/room'; +import * as css from './PollEvent.css'; + +type PollAnswer = { id: string; text: string }; + +export function extractPollData(mEvent: MatrixEvent): { + question: string; + answers: PollAnswer[]; + maxSelections: number; + isDisclosed: boolean; + showVoterNames: boolean; + closesAt: number | undefined; +} | null { + const content = mEvent.getContent(); + const pollStartKey = M_POLL_START.altName ?? 'org.matrix.msc3381.poll.start'; + const pollData = content[M_POLL_START.name] ?? content[pollStartKey]; + if (!pollData) return null; + + const questionText = + (pollData.question?.['m.text'] as { body: string }[] | undefined)?.[0]?.body ?? + (pollData.question?.['org.matrix.msc1767.text'] as string | undefined) ?? + ''; + const rawAnswers: { + id?: string; + 'm.id'?: string; + 'org.matrix.msc1767.text'?: string; + 'm.text'?: { body: string }[]; + }[] = pollData.answers ?? []; + const answers: PollAnswer[] = rawAnswers.slice(0, 20).map((a) => ({ + id: String(a['m.id'] ?? a.id ?? ''), + text: + (a['m.text'] as { body: string }[] | undefined)?.[0]?.body ?? + a['org.matrix.msc1767.text'] ?? + '', + })); + const maxSelections = + typeof pollData.max_selections === 'number' && pollData.max_selections >= 1 + ? pollData.max_selections + : 1; + const kind = pollData.kind ?? ''; + const isDisclosed = + kind === M_POLL_KIND_DISCLOSED.name || + kind === (M_POLL_KIND_DISCLOSED.altName ?? 'org.matrix.msc3381.poll.disclosed'); + const showVoterNames = pollData.show_voter_names !== false; + const rawClosesAt = pollData.closes_at; + const closesAt = typeof rawClosesAt === 'number' && rawClosesAt > 0 ? rawClosesAt : undefined; + return { question: questionText, answers, maxSelections, isDisclosed, showVoterNames, closesAt }; +} + +export function extractVoteSelections(responseEvent: MatrixEvent): string[] { + const content = responseEvent.getContent(); + const unstablePayload = content['org.matrix.msc3381.poll.response']; + const selections: unknown = + content['m.selections'] ?? + (typeof unstablePayload === 'object' && unstablePayload !== null + ? (unstablePayload as { answers?: unknown }).answers + : undefined); + if (!Array.isArray(selections)) return []; + return selections.filter((s): s is string => typeof s === 'string'); +} + +type TallyResult = { + tally: Map>; + myVote: string[]; + isEnded: boolean; +}; + +export function computeTally( + room: Room, + pollEventId: string, + pollStartEvent: MatrixEvent, + answers: PollAnswer[], + maxSelections: number, + myUserId: string +): TallyResult { + const childEvents = room + .getUnfilteredTimelineSet() + .relations.getAllChildEventsForEvent(pollEventId); + + const userVotes = new Map(); + const validAnswerIds = new Set(answers.map((a) => a.id)); + const pollCreator = pollStartEvent.getSender(); + let isEnded = false; + let endTs: number | undefined; + + childEvents.forEach((event) => { + if (M_POLL_END.matches(event.getType())) { + const sender = event.getSender(); + if (!sender) return; + const ts = event.getTs(); + if ( + sender !== pollCreator && + !room.currentState.maySendRedactionForEvent(pollStartEvent, sender) + ) + return; + if (endTs !== undefined && endTs <= ts) return; + endTs = ts; + isEnded = true; + } + if (M_POLL_RESPONSE.matches(event.getType())) { + if (event.isDecryptionFailure()) return; + const sender = event.getSender(); + if (!sender) return; + const ts = event.getTs(); + const existing = userVotes.get(sender); + if (existing && existing.ts >= ts) return; + userVotes.set(sender, { ts, selections: extractVoteSelections(event) }); + } + }); + + const cutoff = endTs ?? Number.MAX_SAFE_INTEGER; + const tally = new Map>(answers.map((a) => [a.id, new Set()])); + userVotes.forEach(({ ts, selections }, userId) => { + if (ts > cutoff) return; + // Per MSC3381, strip invalid answer IDs but keep the remaining valid ones. + const valid = selections.slice(0, maxSelections).filter((s) => validAnswerIds.has(s)); + if (valid.length === 0) return; + valid.forEach((sel) => tally.get(sel)?.add(userId)); + }); + + const myEntry = userVotes.get(myUserId); + let myVote: string[] = []; + if (myEntry && myEntry.ts <= cutoff) { + myVote = myEntry.selections.slice(0, maxSelections).filter((s) => validAnswerIds.has(s)); + } + + return { tally, myVote, isEnded }; +} + +export function formatExpiry(ts: number): string { + const diff = ts - Date.now(); + if (diff <= 0) return 'now'; + const hours = diff / 3_600_000; + if (hours < 1) return `in ${Math.round(diff / 60_000)} min`; + if (hours < 24) return `in ${Math.round(hours)} hr`; + const days = hours / 24; + if (days < 7) return `in ${Math.round(days)} day${Math.round(days) === 1 ? '' : 's'}`; + return new Date(ts).toLocaleDateString(); +} + +type PollEventProps = { + room: Room; + mEvent: MatrixEvent; + canEnd: boolean; + outlined?: boolean; +}; + +export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) { + const mx = useMatrixClient(); + const myUserId = mx.getUserId() ?? ''; + const pollEventId = mEvent.getId() ?? ''; + const [tick, incrementTick] = useReducer((n: number) => n + 1, 0); + const [, forceExpiry] = useReducer((n: number) => n + 1, 0); + + const pollData = useMemo(() => extractPollData(mEvent), [mEvent]); + + // Re-compute tally whenever a new response/end event lands + useEffect(() => { + const onTimeline = (event: MatrixEvent) => { + const relTo = event.getContent()?.['m.relates_to']?.event_id; + if (relTo === pollEventId) incrementTick(); + }; + room.on(RoomEvent.Timeline, onTimeline); + return () => { + room.off(RoomEvent.Timeline, onTimeline); + }; + }, [room, pollEventId]); + + // Also re-compute when an encrypted poll response/end is decrypted + useEffect(() => { + const onDecrypted = (event: MatrixEvent) => { + if (M_POLL_RESPONSE.matches(event.getType()) || M_POLL_END.matches(event.getType())) { + const relTo = event.getContent()?.['m.relates_to']?.event_id; + if (relTo === pollEventId) incrementTick(); + } + }; + mx.on(MatrixEventEvent.Decrypted, onDecrypted); + return () => { + mx.off(MatrixEventEvent.Decrypted, onDecrypted); + }; + }, [mx, pollEventId]); + + // Re-render when the expiry countdown reaches zero + useEffect(() => { + if (!pollData?.closesAt) return undefined; + const remaining = pollData.closesAt - Date.now(); + if (remaining <= 0) return undefined; + const timer = setTimeout(forceExpiry, remaining); + return () => clearTimeout(timer); + }, [pollData?.closesAt]); + + const { tally, myVote, isEnded } = useMemo( + () => + pollData + ? computeTally( + room, + pollEventId, + mEvent, + pollData.answers, + pollData.maxSelections, + myUserId + ) + : { tally: new Map>(), myVote: [] as string[], isEnded: false }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [room, pollEventId, mEvent, pollData, myUserId, tick] + ); + + const isExpiredByTime = pollData?.closesAt !== undefined && Date.now() >= pollData.closesAt; + const effectivelyEnded = isEnded || isExpiredByTime; + const showResults = effectivelyEnded || (pollData?.isDisclosed ?? false); + + const totalVoters = useMemo( + () => new Set([...tally.values()].flatMap((s) => [...s])).size, + [tally] + ); + + const handleAnswerClick = useCallback( + (answerId: string) => { + if (effectivelyEnded || !pollData) return; + const { maxSelections } = pollData; + let next: string[]; + if (maxSelections === 1) { + next = myVote[0] === answerId ? [] : [answerId]; + } else if (myVote.includes(answerId)) { + next = myVote.filter((id) => id !== answerId); + } else { + next = [...myVote, answerId].slice(0, maxSelections); + } + const selections: Record = { 'm.selections': next }; + (mx as { sendEvent(roomId: string, eventType: string, content: Record): Promise }).sendEvent(room.roomId, MessageEvent.PollResponse, { + 'm.relates_to': { rel_type: 'm.reference', event_id: pollEventId }, + ...selections, + 'org.matrix.msc3381.poll.response': { answers: next }, + }).catch(() => undefined); + }, + [effectivelyEnded, pollData, myVote, mx, room.roomId, pollEventId] + ); + + const endPoll = useCallback(() => { + (mx as { sendEvent(roomId: string, eventType: string, content: Record): Promise }).sendEvent(room.roomId, MessageEvent.PollEnd, { + 'm.relates_to': { rel_type: 'm.reference', event_id: pollEventId }, + 'org.matrix.msc3381.poll.end': {}, + body: 'The poll has ended', + }).catch(() => undefined); + }, [mx, room.roomId, pollEventId]); + + const [expandedVoters, setExpandedVoters] = useState<{ id: string; anchor: DOMRect } | null>( + null + ); + const toggleVoters = useCallback( + (id: string, anchor: DOMRect) => + setExpandedVoters((prev) => (prev?.id === id ? null : { id, anchor })), + [] + ); + const canShowVoters = (pollData?.showVoterNames ?? false) && showResults; + + if (!pollData) return null; + + const { question, answers, isDisclosed, closesAt, maxSelections } = pollData; + const isMultiSelect = maxSelections > 1; + const voterLabel = `${totalVoters} ${totalVoters === 1 ? 'voter' : 'voters'}`; + + let statusText: string; + if (isEnded) statusText = `Poll ended · ${voterLabel}`; + else if (isExpiredByTime) statusText = `Poll expired · ${voterLabel}`; + else if (closesAt !== undefined && !isDisclosed) + statusText = `${voterLabel} · Results hidden until closed · Closes ${formatExpiry(closesAt)}`; + else if (closesAt !== undefined) statusText = `${voterLabel} · Closes ${formatExpiry(closesAt)}`; + else if (!isDisclosed) statusText = `${voterLabel} · Results hidden until closed`; + else statusText = voterLabel; + + return ( + + + + + {isDisclosed ? 'Poll' : 'Undisclosed Poll'} + + + + {voterLabel} + + + + + + {question || '(no question)'} + + + {answers.map((answer) => { + const voteCount = tally.get(answer.id)?.size ?? 0; + const percent = totalVoters > 0 ? Math.round((voteCount / totalVoters) * 100) : 0; + const isSelected = myVote.includes(answer.id); + + let textZone: ReactNode; + if (canShowVoters && voteCount > 0) { + textZone = ( + + ); + } else if (!effectivelyEnded) { + textZone = ( + + ); + } else { + textZone = ( + + + {answer.text} + + {showResults && ( + + {percent}% + + )} + + ); + } + + return ( + + + + {textZone} + + {showResults && ( + + )} + + ); + })} + + + + + {statusText} + + {!effectivelyEnded && canEnd && ( + + )} + + + + + {expandedVoters && canShowVoters && ( + setExpandedVoters(null), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + + Voters + + + + {[...(tally.get(expandedVoters.id) ?? [])].map((userId) => ( + + + {room.getMember(userId)?.name ?? userId} + + + ))} + + + + + + } + /> + )} + + ); +} diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index f15879a3d..704844de9 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -691,12 +691,206 @@ export function useTimelineEventRenderer({ ); }, - [M_POLL_START.name]: (_mEventId, mEvent) => ( - - ), - [M_POLL_START.altName]: (_mEventId, mEvent) => ( - - ), + [M_POLL_START.name]: (mEventId, mEvent, item, timelineSet, collapse) => { + const { replyEventId: rawReplyEventId, threadRootId } = mEvent; + const isThreadRel = isThreadRelationEvent(mEvent, threadRootId); + const actualThreadRootId = isThreadRel ? threadRootId : undefined; + const explicitInReplyTo = mEvent.getWireContent()?.['m.relates_to']?.['m.in_reply_to'] + ?.event_id as unknown; + const threadReplyTargetId = + isThreadRel && typeof explicitInReplyTo === 'string' ? explicitInReplyTo : undefined; + const replyEventId = + hideThreadChip && mEvent.getWireContent()?.['m.relates_to']?.is_falling_back + ? undefined + : (threadReplyTargetId ?? rawReplyEventId); + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations?.getSortedAnnotationsByKey(); + const hasReactions = reactions && reactions.length > 0; + const highlighted = focusItem?.index === item && focusItem.highlight; + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + const content = mEvent.getContent() ?? {}; + return ( + + ) + } + reactions={(() => { + const threadChip = + !hideThreadChip && (room.getThread(mEventId) || threadRootId) ? ( + setOpenThread(openThreadId === mEventId ? undefined : mEventId)} + /> + ) : null; + if (!reactionRelations && !threadChip) return undefined; + return ( + <> + {reactionRelations && ( + + )} + {threadChip} + + ); + })()} + hideReadReceipts={hideReads} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(senderId)} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {mEvent.isRedacted() ? ( + + ) : ( + + )} + + ); + }, + [M_POLL_START.altName]: (mEventId, mEvent, item, timelineSet, collapse) => { + const { replyEventId: rawReplyEventId, threadRootId } = mEvent; + const isThreadRel = isThreadRelationEvent(mEvent, threadRootId); + const actualThreadRootId = isThreadRel ? threadRootId : undefined; + const explicitInReplyTo = mEvent.getWireContent()?.['m.relates_to']?.['m.in_reply_to'] + ?.event_id as unknown; + const threadReplyTargetId = + isThreadRel && typeof explicitInReplyTo === 'string' ? explicitInReplyTo : undefined; + const replyEventId = + hideThreadChip && mEvent.getWireContent()?.['m.relates_to']?.is_falling_back + ? undefined + : (threadReplyTargetId ?? rawReplyEventId); + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations?.getSortedAnnotationsByKey(); + const hasReactions = reactions && reactions.length > 0; + const highlighted = focusItem?.index === item && focusItem.highlight; + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + const content = mEvent.getContent() ?? {}; + return ( + + ) + } + reactions={(() => { + const threadChip = + !hideThreadChip && (room.getThread(mEventId) || threadRootId) ? ( + setOpenThread(openThreadId === mEventId ? undefined : mEventId)} + /> + ) : null; + if (!reactionRelations && !threadChip) return undefined; + return ( + <> + {reactionRelations && ( + + )} + {threadChip} + + ); + })()} + hideReadReceipts={hideReads} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(senderId)} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {mEvent.isRedacted() ? ( + + ) : ( + + )} + + ); + }, [EventType.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => { const { replyEventId: rawReplyEventId, threadRootId } = mEvent; const isThreadRel = isThreadRelationEvent(mEvent, threadRootId); From 98c91249847a15280a08b3e31561e2acfee8611f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 15:16:39 -0400 Subject: [PATCH 5/7] fix: parse stable poll response payloads --- src/app/features/room/poll/PollEvent.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/poll/PollEvent.tsx b/src/app/features/room/poll/PollEvent.tsx index 9ee8ffa45..50551a3dd 100644 --- a/src/app/features/room/poll/PollEvent.tsx +++ b/src/app/features/room/poll/PollEvent.tsx @@ -62,7 +62,7 @@ export function extractPollData(mEvent: MatrixEvent): { 'm.text'?: { body: string }[]; }[] = pollData.answers ?? []; const answers: PollAnswer[] = rawAnswers.slice(0, 20).map((a) => ({ - id: String(a['m.id'] ?? a.id ?? ''), + id: a['m.id'] ?? a.id ?? '', text: (a['m.text'] as { body: string }[] | undefined)?.[0]?.body ?? a['org.matrix.msc1767.text'] ?? @@ -84,11 +84,12 @@ export function extractPollData(mEvent: MatrixEvent): { export function extractVoteSelections(responseEvent: MatrixEvent): string[] { const content = responseEvent.getContent(); - const unstablePayload = content['org.matrix.msc3381.poll.response']; + const responsePayload = + content[M_POLL_RESPONSE.name] ?? content[M_POLL_RESPONSE.altName ?? 'm.poll.response']; const selections: unknown = content['m.selections'] ?? - (typeof unstablePayload === 'object' && unstablePayload !== null - ? (unstablePayload as { answers?: unknown }).answers + (typeof responsePayload === 'object' && responsePayload !== null + ? (responsePayload as { answers?: unknown }).answers : undefined); if (!Array.isArray(selections)) return []; return selections.filter((s): s is string => typeof s === 'string'); From ffef49231ac043c655b12056cfb9089cbdf31d20 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 17:15:28 -0400 Subject: [PATCH 6/7] fix(polls): add missing PollCreator import in RoomInput --- src/app/features/room/RoomInput.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 69c1f94a1..1d4f2677d 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -158,6 +158,7 @@ import type { AudioRecordingCompletePayload, } from './AudioMessageRecorder'; import { AudioMessageRecorder } from './AudioMessageRecorder'; +import { PollCreator } from './PollCreator'; import * as prefix from '$unstable/prefixes'; From a033fdf2e31dda6a713dc2ce842f738542b32c88 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 17:45:18 -0400 Subject: [PATCH 7/7] fix(polls): consolidate to PollEvent, fix input widths - Replace PollContent with PollEvent (more complete implementation): closesAt/expiry, voter names, Checkbox for multi-select, MSC3381 end-time cutoff, permission check for unauthorized end events - Add missing PollEvent.css.ts and fix broken MessageEvent import - Update sendEvent calls in PollEvent to standard project pattern - Delete PollContent.tsx, usePollTally.ts, usePollTally.test.ts - Fix poll creator option/question inputs to fill dialog width --- src/app/features/room/PollContent.tsx | 306 ------------------ src/app/features/room/PollCreator.tsx | 2 + src/app/features/room/poll/PollEvent.css.ts | 39 +++ src/app/features/room/poll/PollEvent.tsx | 31 +- .../timeline/useTimelineEventRenderer.tsx | 25 +- src/app/hooks/usePollTally.test.ts | 105 ------ src/app/hooks/usePollTally.ts | 77 ----- 7 files changed, 82 insertions(+), 503 deletions(-) delete mode 100644 src/app/features/room/PollContent.tsx create mode 100644 src/app/features/room/poll/PollEvent.css.ts delete mode 100644 src/app/hooks/usePollTally.test.ts delete mode 100644 src/app/hooks/usePollTally.ts diff --git a/src/app/features/room/PollContent.tsx b/src/app/features/room/PollContent.tsx deleted file mode 100644 index 6dd62c142..000000000 --- a/src/app/features/room/PollContent.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import type { MatrixEvent, Relations, Room } from '$types/matrix-sdk'; -import { useCallback, useEffect, useState } from 'react'; -import FocusTrap from 'focus-trap-react'; -import { - Box, - Button, - config, - Header, - Line, - Modal, - Overlay, - OverlayBackdrop, - OverlayCenter, - ProgressBar, - RadioButton, - Text, -} from 'folds'; -import { - M_POLL_END, - M_POLL_KIND_DISCLOSED, - M_POLL_KIND_UNDISCLOSED, - M_POLL_RESPONSE, - M_POLL_START, -} from 'matrix-js-sdk/lib/@types/polls'; -import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; -import { M_TEXT } from 'matrix-js-sdk/lib/@types/extensible_events'; -import { PollEvent as PollModelEvent } from 'matrix-js-sdk/lib/models/poll'; -import type { Poll } from 'matrix-js-sdk/lib/models/poll'; -import { useMatrixClient } from '$hooks/useMatrixClient'; -import { tallyCounts } from '$hooks/usePollTally'; -import { MessageLayout } from '$state/settings'; -import { Attachment, AttachmentBox, AttachmentContent } from '$components/message/attachment'; -import { stopPropagation } from '$utils/keyboard'; - -type PollContentProps = { - mEvent: MatrixEvent; - room: Room; - messageLayout?: MessageLayout; -}; - -function pluralize(amount: number, noun: string): string { - return amount === 1 ? noun : `${noun}s`; -} - -function getAnswerText(answer: PollAnswer): string { - const raw = answer as unknown as Record; - return ( - (raw[M_TEXT.name] as string | undefined) ?? (raw[M_TEXT.altName] as string | undefined) ?? '' - ); -} - -export function PollContent({ mEvent, room, messageLayout }: PollContentProps) { - const mx = useMatrixClient(); - const content = mEvent.getContent(); - - const pollRaw = (content[M_POLL_START.name] ?? content[M_POLL_START.altName]) as - | Record - | undefined; - - const question: string = (() => { - if (!pollRaw) return '(Poll)'; - const q = pollRaw.question as Record | undefined; - if (!q) return '(Poll)'; - return ( - (q[M_TEXT.name] as string | undefined) ?? - (q[M_TEXT.altName] as string | undefined) ?? - '(Poll)' - ); - })(); - - const answers = (pollRaw?.answers as PollAnswer[] | undefined) ?? []; - const maxSelections = (pollRaw?.max_selections as number | undefined) ?? 1; - const kind = (pollRaw?.kind as string | undefined) ?? M_POLL_KIND_DISCLOSED.name; - const isDisclosed = kind === M_POLL_KIND_DISCLOSED.name || kind === M_POLL_KIND_DISCLOSED.altName; - const isUndisclosed = - kind === M_POLL_KIND_UNDISCLOSED.name || kind === M_POLL_KIND_UNDISCLOSED.altName; - - const eventId = mEvent.getId() ?? ''; - const roomId = room.roomId; - const myUserId = mx.getUserId() ?? ''; - const senderId = mEvent.getSender() ?? ''; - - const [relations, setRelations] = useState(undefined); - const [isEnded, setIsEnded] = useState(false); - const [openEndModal, setOpenEndModal] = useState(false); - - useEffect(() => { - const roomWithPolls = room as unknown as { polls: Map }; - const poll = roomWithPolls.polls.get(eventId); - - if (poll) { - setIsEnded(poll.isEnded); - - poll - .getResponses() - .then((rels) => setRelations(rels)) - .catch(console.warn); - - const onResponses = (rels: Relations) => setRelations(rels); - const onEnd = () => setIsEnded(true); - poll.on(PollModelEvent.Responses, onResponses); - poll.on(PollModelEvent.End, onEnd); - return () => { - poll.off(PollModelEvent.Responses, onResponses); - poll.off(PollModelEvent.End, onEnd); - }; - } - - return undefined; - }, [room, eventId]); - - const tally = tallyCounts(answers, relations, myUserId, maxSelections); - const canShowResults = isDisclosed || (isUndisclosed && isEnded); - const outlined = messageLayout === MessageLayout.Bubble; - - const handleVote = useCallback( - async (answerId: string) => { - if (isEnded) return; - const isSelected = tally.myAnswers.includes(answerId); - let newAnswers: string[]; - if (maxSelections === 1) { - newAnswers = isSelected ? [] : [answerId]; - } else { - newAnswers = isSelected - ? tally.myAnswers.filter((id) => id !== answerId) - : [...tally.myAnswers, answerId].slice(0, maxSelections); - } - - const voteContent: Record = { - [M_POLL_RESPONSE.name]: { answers: newAnswers }, - [M_POLL_RESPONSE.altName]: { answers: newAnswers }, - 'm.relates_to': { - rel_type: 'm.reference', - event_id: eventId, - }, - }; - - type SendEventContent = Parameters[3]; - await ( - mx as unknown as { - sendEvent( - roomId: string, - threadId: null, - eventType: string, - content: SendEventContent - ): Promise; - } - ).sendEvent(roomId, null, M_POLL_RESPONSE.name, voteContent as unknown as SendEventContent); - }, - [mx, roomId, eventId, tally.myAnswers, maxSelections, isEnded] - ); - - const handleEndPoll = useCallback(async () => { - type SendEventContent = Parameters[3]; - await ( - mx as unknown as { - sendEvent( - roomId: string, - threadId: null, - eventType: string, - content: SendEventContent - ): Promise; - } - ).sendEvent(roomId, null, M_POLL_END.name, { - [M_POLL_END.name]: {}, - [M_POLL_END.altName]: {}, - 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, - 'm.text': 'The poll has ended.', - } as unknown as SendEventContent); - setOpenEndModal(false); - }, [mx, roomId, eventId]); - - const pollLabel = isDisclosed ? 'Poll' : 'Undisclosed poll'; - - return ( - <> - }> - - setOpenEndModal(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
- - End poll - -
- - - Are you sure you want to end this poll? This will reveal the results, and no one - will be able to vote anymore. - - - - - - -
-
-
-
- - - - - {pollLabel} - {isEnded ? ' (ended)' : ''} - - - - {tally.totalVoters} {pluralize(tally.totalVoters, 'vote')} - - - - - - {question} - - {answers.map((answer, idx) => { - const text = getAnswerText(answer); - const isSelected = tally.myAnswers.includes(answer.id); - const voteCount = tally.counts.get(answer.id) ?? 0; - return ( - - - handleVote(answer.id)} - checked={isSelected} - /> - - - - - {text || `Option ${idx + 1}`} - - {canShowResults && ( - - {voteCount} {pluralize(voteCount, 'vote')} - - )} - - {canShowResults && ( - - )} - - - ); - })} - {isUndisclosed && !isEnded && senderId === myUserId && ( - <> - - - - )} - - - - - - ); -} diff --git a/src/app/features/room/PollCreator.tsx b/src/app/features/room/PollCreator.tsx index dc6a07eeb..94857c750 100644 --- a/src/app/features/room/PollCreator.tsx +++ b/src/app/features/room/PollCreator.tsx @@ -156,6 +156,7 @@ export function PollCreator({ room, onClose }: PollCreatorProps) { Question = { 'm.selections': next }; - (mx as { sendEvent(roomId: string, eventType: string, content: Record): Promise }).sendEvent(room.roomId, MessageEvent.PollResponse, { - 'm.relates_to': { rel_type: 'm.reference', event_id: pollEventId }, - ...selections, - 'org.matrix.msc3381.poll.response': { answers: next }, - }).catch(() => undefined); + mx.sendEvent( + room.roomId, + M_POLL_RESPONSE.name as keyof TimelineEvents, + { + 'm.relates_to': { rel_type: 'm.reference', event_id: pollEventId }, + ...selections, + 'org.matrix.msc3381.poll.response': { answers: next }, + } as TimelineEvents[keyof TimelineEvents] + ).catch(() => undefined); }, [effectivelyEnded, pollData, myVote, mx, room.roomId, pollEventId] ); const endPoll = useCallback(() => { - (mx as { sendEvent(roomId: string, eventType: string, content: Record): Promise }).sendEvent(room.roomId, MessageEvent.PollEnd, { - 'm.relates_to': { rel_type: 'm.reference', event_id: pollEventId }, - 'org.matrix.msc3381.poll.end': {}, - body: 'The poll has ended', - }).catch(() => undefined); + mx.sendEvent( + room.roomId, + M_POLL_END.name as keyof TimelineEvents, + { + 'm.relates_to': { rel_type: 'm.reference', event_id: pollEventId }, + 'org.matrix.msc3381.poll.end': {}, + body: 'The poll has ended', + } as TimelineEvents[keyof TimelineEvents] + ).catch(() => undefined); }, [mx, room.roomId, pollEventId]); const [expandedVoters, setExpandedVoters] = useState<{ id: string; anchor: DOMRect } | null>( diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 704844de9..fdae15577 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -648,7 +648,16 @@ export function useTimelineEventRenderer({ type === (M_POLL_START.name as string) || type === (M_POLL_START.altName as string) ) - return ; + return ( + + ); if (type === (EventType.RoomMessage as string)) { const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); let editedNewContent: unknown; @@ -786,7 +795,12 @@ export function useTimelineEventRenderer({ {mEvent.isRedacted() ? ( ) : ( - + )} ); @@ -886,7 +900,12 @@ export function useTimelineEventRenderer({ {mEvent.isRedacted() ? ( ) : ( - + )} ); diff --git a/src/app/hooks/usePollTally.test.ts b/src/app/hooks/usePollTally.test.ts deleted file mode 100644 index e32529b49..000000000 --- a/src/app/hooks/usePollTally.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { Relations } from '$types/matrix-sdk'; -import type { MatrixEvent } from '$types/matrix-sdk'; -import { M_POLL_RESPONSE } from 'matrix-js-sdk/lib/@types/polls'; -import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; -import { tallyCounts } from '$hooks/usePollTally'; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function makeRelations(events: Partial[]): Relations { - return { - getRelations: () => events as MatrixEvent[], - } as unknown as Relations; -} - -function makeVote(sender: string, answerIds: string[], ts = 1000): Partial { - return { - getSender: () => sender, - getTs: () => ts, - getContent: (() => ({ - [M_POLL_RESPONSE.name]: { answers: answerIds }, - })) as MatrixEvent['getContent'], - }; -} - -const ANSWERS: PollAnswer[] = [ - { id: 'a', body: 'Option A', mimetype: 'text/plain' } as unknown as PollAnswer, - { id: 'b', body: 'Option B', mimetype: 'text/plain' } as unknown as PollAnswer, - { id: 'c', body: 'Option C', mimetype: 'text/plain' } as unknown as PollAnswer, -]; - -// ── Tests ──────────────────────────────────────────────────────────────────── - -describe('tallyCounts', () => { - it('returns zero counts and no voters for empty relations', () => { - const result = tallyCounts(ANSWERS, makeRelations([]), '@me:example.com', 1); - expect(result.totalVoters).toBe(0); - expect(result.counts.get('a')).toBe(0); - expect(result.myAnswers).toEqual([]); - }); - - it('counts a single vote correctly', () => { - const rel = makeRelations([makeVote('@alice:example.com', ['a'])]); - const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); - expect(result.counts.get('a')).toBe(1); - expect(result.counts.get('b')).toBe(0); - expect(result.totalVoters).toBe(1); - }); - - it('only counts the last vote per user', () => { - const rel = makeRelations([ - makeVote('@alice:example.com', ['a'], 1000), - makeVote('@alice:example.com', ['b'], 2000), // later — should win - ]); - const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); - expect(result.counts.get('a')).toBe(0); - expect(result.counts.get('b')).toBe(1); - expect(result.totalVoters).toBe(1); - }); - - it('ignores invalid answer IDs (not in poll answers)', () => { - const rel = makeRelations([makeVote('@alice:example.com', ['z'])]); - const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); - expect(result.totalVoters).toBe(0); - expect([...result.counts.values()].every((v) => v === 0)).toBe(true); - }); - - it('tracks the current user vote in myAnswers', () => { - const rel = makeRelations([makeVote('@me:example.com', ['c'])]); - const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); - expect(result.myAnswers).toEqual(['c']); - }); - - it('supports multi-select up to max_selections', () => { - const rel = makeRelations([makeVote('@alice:example.com', ['a', 'b', 'c'])]); - // max_selections = 2 → only first 2 are kept - const result = tallyCounts(ANSWERS, rel, '@me:example.com', 2); - expect(result.counts.get('a')).toBe(1); - expect(result.counts.get('b')).toBe(1); - expect(result.counts.get('c')).toBe(0); - }); - - it('handles null/undefined relations gracefully', () => { - const result = tallyCounts(ANSWERS, null, '@me:example.com', 1); - expect(result.totalVoters).toBe(0); - }); - - it('counts multiple distinct voters independently', () => { - const rel = makeRelations([ - makeVote('@alice:example.com', ['a']), - makeVote('@bob:example.com', ['a']), - makeVote('@carol:example.com', ['b']), - ]); - const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); - expect(result.counts.get('a')).toBe(2); - expect(result.counts.get('b')).toBe(1); - expect(result.totalVoters).toBe(3); - }); - - it('treats empty answers array as a spoil (abstain) — not counted in totalVoters', () => { - const rel = makeRelations([makeVote('@alice:example.com', [])]); - const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); - expect(result.totalVoters).toBe(0); - }); -}); diff --git a/src/app/hooks/usePollTally.ts b/src/app/hooks/usePollTally.ts deleted file mode 100644 index f643b3311..000000000 --- a/src/app/hooks/usePollTally.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { Relations } from '$types/matrix-sdk'; -import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; -import { M_POLL_RESPONSE } from 'matrix-js-sdk/lib/@types/polls'; - -export type PollTally = { - /** Map from answerId → vote count (deduplicated to last vote per user) */ - counts: Map; - /** Total number of users who cast at least one valid answer */ - totalVoters: number; - /** The current user's selected answer IDs (empty = not voted) */ - myAnswers: string[]; -}; - -/** - * Pure function — tallies poll votes from a Relations object. - * - * Rules per MSC3381: - * - Only the last response per user is counted. - * - Answers that don't exist in the poll's answer list are ignored (spoiled). - * - A response with an empty answers array is a deliberate spoil (abstain). - */ -export function tallyCounts( - answers: PollAnswer[], - relations: Relations | null | undefined, - myUserId: string, - maxSelections: number -): PollTally { - const validIds = new Set(answers.map((a) => a.id)); - const answerIds = answers.map((a) => a.id); - - // Map userId → their last response's answer IDs (already validated) - const lastVoteByUser = new Map(); - - const events = relations?.getRelations() ?? []; - - // Sort ascending so iterating gives chronological order; last write wins - const sorted = [...events].toSorted((a, b) => a.getTs() - b.getTs()); - - for (const event of sorted) { - const sender = event.getSender(); - if (!sender) continue; - - const content = event.getContent(); - // Support both stable (m.poll.response) and unstable (org.matrix.msc3381.poll.response) keys - const responsePart = - (content[M_POLL_RESPONSE.name] as { answers?: unknown } | undefined) ?? - (content[M_POLL_RESPONSE.altName] as { answers?: unknown } | undefined); - - if (!responsePart || !Array.isArray(responsePart.answers)) { - continue; - } - - const rawAnswers = responsePart.answers as unknown[]; - // Filter to only valid answer IDs; enforce max_selections limit - const validAnswers = ( - rawAnswers.filter((id) => typeof id === 'string' && validIds.has(id)) as string[] - ).slice(0, Math.max(1, maxSelections)); - - lastVoteByUser.set(sender, validAnswers); - } - - const counts = new Map(answerIds.map((id) => [id, 0])); - let myAnswers: string[] = []; - - for (const [userId, selectedIds] of lastVoteByUser) { - for (const id of selectedIds) { - counts.set(id, (counts.get(id) ?? 0) + 1); - } - if (userId === myUserId) { - myAnswers = selectedIds; - } - } - - const totalVoters = Array.from(lastVoteByUser.values()).filter((ids) => ids.length > 0).length; - - return { counts, totalVoters, myAnswers }; -}