feat(polls): implement MSC3381 polls#589
feat(polls): implement MSC3381 polls#589Just-Insane wants to merge 11 commits intoSableClient:devfrom
Conversation
55449fb to
2ea98d6
Compare
|
is it using the ui you showed in matrix chat or the ui of cinnyapp/cinny#2763 ? |
The UI from Cinny |
Allow poll creators to choose how many options voters may select (1 to the number of options). Defaults to 1 (single-choice). The value is clamped to [1, validAnswers.length] on submit per MSC3381. The voting UI already handles multi-selection correctly in PollEvent."
…Tally/formatExpiry/extractVoteSelections
There was a problem hiding this comment.
Pull request overview
Implements Matrix MSC3381 poll support in the room timeline, including poll creation UI, vote/end handling, and a feature-flag gate via client config.
Changes:
- Add poll event types/SDK exports and a
features.pollsclient-config flag (defaultfalse). - Add
PollCreatorDialog(poll creation) andPollEvent(timeline renderer + tallying/end action), plus supporting styles and tests. - Integrate poll rendering/filtering into timeline processing and add a
/pollcommand pathway inRoomInput.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types/matrix/room.ts | Adds MSC3381 (unstable) poll event types to the room message type enum. |
| src/types/matrix-sdk.ts | Re-exports poll constants/types from matrix-js-sdk for app usage. |
| src/app/hooks/useCommands.ts | Adds /poll command entry for command discovery/autocomplete. |
| src/app/hooks/useClientConfig.ts | Extends client config typing with features.polls. |
| src/app/hooks/timeline/useTimelineEventRenderer.tsx | Adds timeline rendering for poll start events and suppresses rendering for response/end events. |
| src/app/hooks/timeline/useProcessedTimeline.ts | Filters poll response/end events so they don’t appear as timeline items. |
| src/app/features/room/RoomInput.tsx | Adds feature-gated poll creator dialog launch (via /poll) and sends poll start events. |
| src/app/features/room/poll/PollEvent.tsx | Implements poll content extraction, tally computation, voting, ending, and results UI. |
| src/app/features/room/poll/pollEvent.test.ts | Adds unit tests for poll parsing, tallying, and expiry formatting. |
| src/app/features/room/poll/PollEvent.css.ts | Adds styles for poll option controls and layout. |
| src/app/features/room/poll/PollCreatorDialog.tsx | Implements poll creation dialog (question/options/visibility/expiry/max selections). |
| src/app/features/room/poll/PollCreatorDialog.css.ts | Adds styles for the poll creation dialog. |
| src/app/features/room/poll/index.ts | Exports poll feature components/types. |
| config.json | Adds features.polls: false feature flag to config. |
| .changeset/feat-polls.md | Adds changeset entry for the new polls feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| [MessageEvent.PollStart]: (mEventId, mEvent, item, timelineSet, collapse) => { | ||
| const { getSender, getAssociatedStatus, isRedacted, getUnsigned } = mEvent; | ||
| const reactionRelations = getEventReactions(timelineSet, mEventId); | ||
| const reactions = reactionRelations?.getSortedAnnotationsByKey(); | ||
| const hasReactions = reactions && reactions.length > 0; |
There was a problem hiding this comment.
m.poll.start (stable) events from other clients will not hit this renderer mapping (only MessageEvent.PollStart / org.matrix.msc3381.poll.start is handled), so polls may show as unsupported/unknown events in rooms that use the stable type. Consider handling both stable and unstable poll start types here (e.g. by checking M_POLL_START.matches(mEvent.getType()) or adding a stable enum entry and mapping it to the same renderer).
| // Poll response and end events are always filtered — they update the poll tally | ||
| // via RoomEvent.Timeline listeners in PollEvent and must never render as timeline items. | ||
| if ( | ||
| type === 'org.matrix.msc3381.poll.response' || | ||
| type === 'org.matrix.msc3381.poll.end' || | ||
| type === 'm.poll.response' || | ||
| type === 'm.poll.end' | ||
| ) | ||
| return acc; |
There was a problem hiding this comment.
Filtering poll response/end events by mEvent.getType() will miss responses in E2EE rooms while they are still m.room.encrypted, causing poll votes/end events to render as encrypted timeline items until decryption (and sometimes leaving blank gaps after). Consider also filtering when the decrypted/effective event type is poll response/end (e.g. via mEvent.getEffectiveEvent()?.type) and ensuring the processed timeline recomputes on MatrixEventEvent.Decrypted.
| // 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]); |
There was a problem hiding this comment.
In encrypted rooms, poll response/end events will often arrive as m.room.encrypted and decrypt later; this component only recomputes on RoomEvent.Timeline, so tallies may never update when a response decrypts (since computeTally ignores decryption failures/non-matching types). Consider subscribing to MatrixEventEvent.Decrypted (for events that relate to this poll) and triggering a recompute when a relevant event decrypts.
| const minDatetime = useMemo( | ||
| () => new Date(Date.now() + 60_000).toISOString().slice(0, 16), | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| [expiryPreset] | ||
| ); |
There was a problem hiding this comment.
The datetime-local min value is derived from toISOString() (UTC). datetime-local expects a local-time string (YYYY-MM-DDTHH:mm), so this can shift the minimum by the user’s timezone and incorrectly reject/allow times. Compute minDatetime in local time (e.g. by adjusting for getTimezoneOffset() before formatting) to match the input’s semantics.
| <button | ||
| type="button" | ||
| className={css.RadioZone} | ||
| onClick={() => handleAnswerClick(answer.id)} | ||
| disabled={effectivelyEnded} | ||
| aria-pressed={isSelected} | ||
| aria-label={`Vote for ${answer.text}`} | ||
| > | ||
| <RadioButton size="50" checked={isSelected} readOnly tabIndex={-1} /> | ||
| </button> |
There was a problem hiding this comment.
The UI always uses a radio control for each answer, but polls can be multi-select (maxSelections > 1). This is misleading (and not great for a11y) because radios imply single choice. Consider rendering a checkbox-style control (and appropriate ARIA semantics) when maxSelections > 1.
| // Vote button wrapping just the radio circle - minimal touch target | ||
| export const RadioZone = style({ | ||
| all: 'unset', | ||
| cursor: 'pointer', | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| justifyContent: 'center', | ||
| flexShrink: 0, | ||
| padding: `${config.space.S100} 0`, | ||
| selectors: { | ||
| '&:disabled': { | ||
| cursor: 'default', | ||
| }, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
all: 'unset' on interactive buttons removes default focus outlines, but no :focus-visible styling is added here. This makes keyboard navigation hard/impossible to see. Add a visible focus style (or avoid unsetting focus styles) for RadioZone and AnswerTextButton.
| } | ||
| aria-pressed={silentReply} | ||
| aria-label={ | ||
| silentReply ? 'Unmute reply notifications' : 'Mute reply notifications' | ||
| } | ||
| onClick={() => setSilentReply(!silentReply)} | ||
| > | ||
| {!silentReply && <Icon src={Icons.BellPing} />} | ||
| {silentReply && <Icon src={Icons.BellMute} />} | ||
| </IconButton> | ||
| </Box> | ||
| </Box> | ||
| </div> | ||
| )} | ||
| </> | ||
| } | ||
| before={ | ||
| <IconButton | ||
| onClick={() => pickFile('*')} | ||
| variant="SurfaceVariant" | ||
| size="300" | ||
| radii="300" | ||
| title="Upload File" | ||
| aria-label="Upload and attach a File" | ||
| > | ||
| <Icon src={Icons.PlusCircle} /> | ||
| </IconButton> | ||
| <Box alignItems="Center" gap="100"> | ||
| <IconButton | ||
| onClick={() => pickFile('*')} | ||
| variant="SurfaceVariant" | ||
| size="300" | ||
| radii="300" | ||
| title="Upload File" | ||
| aria-label="Upload and attach a File" | ||
| > | ||
| <Icon src={Icons.PlusCircle} /> | ||
| </IconButton> | ||
| </Box> |
There was a problem hiding this comment.
The PR description mentions a poll button in RoomInput, but in this change the poll UI is only reachable via the /poll command (no toolbar/button added in the composer UI). Either add the button as described (gated by features.polls) or update the PR description to match the implementation.
| const { tally, myVote, isEnded } = useMemo( | ||
| () => | ||
| pollData | ||
| ? computeTally( | ||
| room, | ||
| pollEventId, | ||
| mEvent, | ||
| pollData.answers, | ||
| pollData.maxSelections, | ||
| myUserId | ||
| ) | ||
| : { tally: new Map<string, Set<string>>(), myVote: [] as string[], isEnded: false }, | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| [room, pollEventId, mEvent, pollData, myUserId, tick] | ||
| ); |
There was a problem hiding this comment.
The PR description calls out a usePoll hook that paginates the room timeline, but there is no usePoll implementation in this PR/repo (and PollEvent is doing its own tallying from relations). Either add the hook as described or update the description to reflect the current approach.
| // MSC3381 Polls — unstable prefix (stable types not yet in a shipped room version) | ||
| PollStart = 'org.matrix.msc3381.poll.start', | ||
| PollResponse = 'org.matrix.msc3381.poll.response', | ||
| PollEnd = 'org.matrix.msc3381.poll.end', |
There was a problem hiding this comment.
MessageEvent only defines the unstable MSC3381 poll event types. Since the code/tests already aim to handle stable m.poll.* payloads, consider also representing the stable event types (or otherwise ensuring stable poll events route through the same rendering/filtering paths) to avoid inconsistent handling across the app.
| // MSC3381 Polls — unstable prefix (stable types not yet in a shipped room version) | |
| PollStart = 'org.matrix.msc3381.poll.start', | |
| PollResponse = 'org.matrix.msc3381.poll.response', | |
| PollEnd = 'org.matrix.msc3381.poll.end', | |
| // MSC3381 Polls — support both unstable and stable event types | |
| PollStart = 'org.matrix.msc3381.poll.start', | |
| PollResponse = 'org.matrix.msc3381.poll.response', | |
| PollEnd = 'org.matrix.msc3381.poll.end', | |
| PollStartStable = 'm.poll.start', | |
| PollResponseStable = 'm.poll.response', | |
| PollEndStable = 'm.poll.end', |
- Add stable m.poll.* type aliases alongside unstable MSC3381 types - Register stable poll types in useTimelineEventRenderer - Fix datetime-local timezone bug in PollCreatorDialog (UTC→local) - Add FocusOutline from folds for keyboard a11y on poll options - Add MatrixEventEvent.Decrypted listener for encrypted poll responses - Support multi-select polls with Checkbox component
- Add stable m.poll.* type aliases alongside unstable MSC3381 types - Register stable poll types in useTimelineEventRenderer - Fix datetime-local timezone bug in PollCreatorDialog (UTC→local) - Add FocusOutline from folds for keyboard a11y on poll options - Add MatrixEventEvent.Decrypted listener for encrypted poll responses - Support multi-select polls with Checkbox component
- Use getEffectiveEvent() to check decrypted type for encrypted poll response/end events - Add poll start types to isStandardRendered list in useProcessedTimeline
…e vote Per MSC3381, invalid individual answer selections should be stripped but remaining valid selections should still be counted. Changed from valid.every() (discard all) to valid.filter() (strip invalid, keep valid).
Description
>⚠️ Merge together with SableClient/docs#12
Implements Matrix polls (MSC3381) with a creator dialog and timeline renderer.
PollCreatorDialog— question, 2–20 answers, disclosed/undisclosed type, voter visibility toggle (show/hide voter names), poll duration (presets: 1h / 12h / 24h / 48h / 1 week / custom date-time)PollEventtimeline renderer — vote buttons, live/final results bars, expandable voter lists per answer, expiry countdown in footer, auto-expire enforcement, end-poll actionPollEventfromorg.matrix.msc3381.poll.start,.poll.response, and.poll.endevents via paginated room timeline; respectsshow_voter_namesandcloses_atfields. Also handles stablem.poll.*types./pollslash command inRoomInput, gated byfeatures.pollsinconfig.json(defaultsfalse)m.poll.*) and unstable (org.matrix.msc3381.poll.*) event typesMatrixEventEvent.DecryptedlistenerFocusOutlinefocus-visible styles on poll optionsSpec: MSC3381
Related upstream issue: cinnyapp/cinny#563
Documentation: SableClient/docs#12
Fixes #
Type of change
Checklist:
AI disclosure:
Poll state is assembled inline in
PollEventby paginating the room timeline to collectpoll.responseevents keyed by sender, deduplicating so only the last vote per user counts, and tallying totals and percentages.PollEventrenders the resulting state as vote buttons with fill-bars; it re-fetches onRoomEvent.TimelineandMatrixEventEvent.Decryptedso it stays live even for encrypted responses.PollCreatorDialogbuilds them.poll.startcontent withcrypto.randomUUID()answer IDs and sends viamx.sendEvent. The/pollslash command is conditionally available based onclientConfig.features?.polls. Theshow_voter_namesflag is a client-side display hint stored in the poll start event; thecloses_atfield is a Unix ms timestamp enforced client-side (voting blocked after expiry, UI shows countdown).