Skip to content

feat(polls): implement MSC3381 polls#589

Draft
Just-Insane wants to merge 11 commits intoSableClient:devfrom
Just-Insane:feat/polls
Draft

feat(polls): implement MSC3381 polls#589
Just-Insane wants to merge 11 commits intoSableClient:devfrom
Just-Insane:feat/polls

Conversation

@Just-Insane
Copy link
Copy Markdown
Contributor

@Just-Insane Just-Insane commented Mar 29, 2026

Description

> ⚠️ Merge together with SableClient/docs#12

Implements Matrix polls (MSC3381) with a creator dialog and timeline renderer.

  • New 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)
  • New PollEvent timeline renderer — vote buttons, live/final results bars, expandable voter lists per answer, expiry countdown in footer, auto-expire enforcement, end-poll action
  • Poll state assembled inline in PollEvent from org.matrix.msc3381.poll.start, .poll.response, and .poll.end events via paginated room timeline; respects show_voter_names and closes_at fields. Also handles stable m.poll.* types.
  • /poll slash command in RoomInput, gated by features.polls in config.json (defaults false)
  • Supports both stable (m.poll.*) and unstable (org.matrix.msc3381.poll.*) event types
  • Handles encrypted poll responses via MatrixEventEvent.Decrypted listener
  • Multi-select polls render with checkboxes; single-select with radio buttons
  • Keyboard accessibility via FocusOutline focus-visible styles on poll options

Spec: MSC3381
Related upstream issue: cinnyapp/cinny#563
Documentation: SableClient/docs#12

Fixes #

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

AI disclosure:

  • Partially AI assisted (clarify which code was AI assisted and briefly explain what it does).
  • Fully AI generated (explain what all the generated code does in moderate detail).

Poll state is assembled inline in PollEvent by paginating the room timeline to collect poll.response events keyed by sender, deduplicating so only the last vote per user counts, and tallying totals and percentages. PollEvent renders the resulting state as vote buttons with fill-bars; it re-fetches on RoomEvent.Timeline and MatrixEventEvent.Decrypted so it stays live even for encrypted responses. PollCreatorDialog builds the m.poll.start content with crypto.randomUUID() answer IDs and sends via mx.sendEvent. The /poll slash command is conditionally available based on clientConfig.features?.polls. The show_voter_names flag is a client-side display hint stored in the poll start event; the closes_at field is a Unix ms timestamp enforced client-side (voting blocked after expiry, UI shows countdown).

@Just-Insane Just-Insane requested review from 7w1 and hazre as code owners March 29, 2026 21:41
@Just-Insane Just-Insane marked this pull request as draft March 29, 2026 21:46
@Just-Insane Just-Insane force-pushed the feat/polls branch 3 times, most recently from 55449fb to 2ea98d6 Compare March 30, 2026 12:54
@Just-Insane Just-Insane marked this pull request as ready for review March 30, 2026 13:46
@dozro
Copy link
Copy Markdown
Contributor

dozro commented Mar 30, 2026

is it using the ui you showed in matrix chat or the ui of cinnyapp/cinny#2763 ?

@Just-Insane
Copy link
Copy Markdown
Contributor Author

is it using the ui you showed in matrix chat or the ui of cinnyapp/cinny#2763 ?

The UI from Cinny

@Just-Insane Just-Insane marked this pull request as draft March 31, 2026 20:13
@Just-Insane Just-Insane marked this pull request as ready for review April 8, 2026 21:54
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."
Copilot AI review requested due to automatic review settings April 12, 2026 21:29
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.polls client-config flag (default false).
  • Add PollCreatorDialog (poll creation) and PollEvent (timeline renderer + tallying/end action), plus supporting styles and tests.
  • Integrate poll rendering/filtering into timeline processing and add a /poll command pathway in RoomInput.

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.

Comment on lines +1083 to +1087
[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;
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +108
// 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;
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +191 to +201
// 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]);
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +76
const minDatetime = useMemo(
() => new Date(Date.now() + 60_000).toISOString().slice(0, 16),
// eslint-disable-next-line react-hooks/exhaustive-deps
[expiryPreset]
);
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +378 to +387
<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>
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +18
// 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',
},
},
});
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 1366 to +1394
}
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>
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +212 to +226
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]
);
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/types/matrix/room.ts Outdated
Comment on lines +61 to +64
// 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',
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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',

Copilot uses AI. Check for mistakes.
- 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
Just-Insane added a commit to Just-Insane/Sable that referenced this pull request Apr 13, 2026
- 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).
@Just-Insane Just-Insane marked this pull request as draft April 17, 2026 11:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants