Skip to content

Commit 01860c7

Browse files
committed
fix: address PR SableClient#589 review comments
- 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
1 parent 6c90b73 commit 01860c7

5 files changed

Lines changed: 149 additions & 109 deletions

File tree

src/app/features/room/poll/PollCreatorDialog.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,13 @@ export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps
6969
const [error, setError] = useState<string>();
7070
const lastInputRef = useRef<HTMLInputElement>(null);
7171

72-
const minDatetime = useMemo(
73-
() => new Date(Date.now() + 60_000).toISOString().slice(0, 16),
72+
const minDatetime = useMemo(() => {
73+
const d = new Date(Date.now() + 60_000);
74+
// datetime-local expects local time, not UTC — build YYYY-MM-DDTHH:MM manually
75+
const pad = (n: number) => String(n).padStart(2, '0');
76+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
7477
// eslint-disable-next-line react-hooks/exhaustive-deps
75-
[expiryPreset]
76-
);
78+
}, [expiryPreset]);
7779

7880
const computeClosesAt = (): number | undefined => {
7981
const now = Date.now();

src/app/features/room/poll/PollEvent.css.ts

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,41 @@
11
import { style } from '@vanilla-extract/css';
2-
import { config } from 'folds';
2+
import { config, FocusOutline } from 'folds';
33

44
// Vote button wrapping just the radio circle - minimal touch target
5-
export const RadioZone = style({
6-
all: 'unset',
7-
cursor: 'pointer',
8-
display: 'flex',
9-
alignItems: 'center',
10-
justifyContent: 'center',
11-
flexShrink: 0,
12-
padding: `${config.space.S100} 0`,
13-
selectors: {
14-
'&:disabled': {
15-
cursor: 'default',
5+
export const RadioZone = style([
6+
FocusOutline,
7+
{
8+
all: 'unset',
9+
cursor: 'pointer',
10+
display: 'flex',
11+
alignItems: 'center',
12+
justifyContent: 'center',
13+
flexShrink: 0,
14+
padding: `${config.space.S100} 0`,
15+
borderRadius: config.radii.R300,
16+
selectors: {
17+
'&:disabled': {
18+
cursor: 'default',
19+
},
1620
},
1721
},
18-
});
22+
]);
1923

2024
// Text + percent area - clickable to reveal voters
21-
export const AnswerTextButton = style({
22-
all: 'unset',
23-
cursor: 'pointer',
24-
display: 'flex',
25-
flex: 1,
26-
alignItems: 'center',
27-
gap: config.space.S200,
28-
minWidth: 0,
29-
padding: `${config.space.S100} 0`,
30-
});
25+
export const AnswerTextButton = style([
26+
FocusOutline,
27+
{
28+
all: 'unset',
29+
cursor: 'pointer',
30+
display: 'flex',
31+
flex: 1,
32+
alignItems: 'center',
33+
gap: config.space.S200,
34+
minWidth: 0,
35+
padding: `${config.space.S100} 0`,
36+
borderRadius: config.radii.R300,
37+
},
38+
]);
3139

3240
// Non-interactive version of the text area
3341
export const AnswerTextRow = style({

src/app/features/room/poll/PollEvent.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import FocusTrap from 'focus-trap-react';
33
import {
44
Box,
55
Button,
6+
Checkbox,
67
config,
78
Icon,
89
Icons,
@@ -21,6 +22,7 @@ import {
2122
M_POLL_RESPONSE,
2223
M_POLL_START,
2324
MatrixEvent,
25+
MatrixEventEvent,
2426
Room,
2527
RoomEvent,
2628
} from '$types/matrix-sdk';
@@ -200,6 +202,20 @@ export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) {
200202
};
201203
}, [room, pollEventId]);
202204

205+
// Also re-compute when an encrypted poll response/end is decrypted
206+
useEffect(() => {
207+
const onDecrypted = (event: MatrixEvent) => {
208+
if (M_POLL_RESPONSE.matches(event.getType()) || M_POLL_END.matches(event.getType())) {
209+
const relTo = event.getContent()?.['m.relates_to']?.event_id;
210+
if (relTo === pollEventId) incrementTick();
211+
}
212+
};
213+
mx.on(MatrixEventEvent.Decrypted, onDecrypted);
214+
return () => {
215+
mx.off(MatrixEventEvent.Decrypted, onDecrypted);
216+
};
217+
}, [mx, pollEventId]);
218+
203219
// Re-render when the expiry countdown reaches zero
204220
useEffect(() => {
205221
if (!pollData?.closesAt) return undefined;
@@ -276,7 +292,8 @@ export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) {
276292

277293
if (!pollData) return null;
278294

279-
const { question, answers, isDisclosed, closesAt } = pollData;
295+
const { question, answers, isDisclosed, closesAt, maxSelections } = pollData;
296+
const isMultiSelect = maxSelections > 1;
280297
const voterLabel = `${totalVoters} ${totalVoters === 1 ? 'voter' : 'voters'}`;
281298

282299
let statusText: string;
@@ -383,7 +400,11 @@ export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) {
383400
aria-pressed={isSelected}
384401
aria-label={`Vote for ${answer.text}`}
385402
>
386-
<RadioButton size="50" checked={isSelected} readOnly tabIndex={-1} />
403+
{isMultiSelect ? (
404+
<Checkbox size="50" checked={isSelected} readOnly tabIndex={-1} />
405+
) : (
406+
<RadioButton size="50" checked={isSelected} readOnly tabIndex={-1} />
407+
)}
387408
</button>
388409
{textZone}
389410
</Box>

src/app/hooks/timeline/useTimelineEventRenderer.tsx

Lines changed: 84 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,85 @@ export function useTimelineEventRenderer({
354354
}: TimelineEventRendererOptions) {
355355
const { t } = useTranslation();
356356

357+
// Shared poll start renderer — used for both unstable and stable event types
358+
const renderPollStart = (
359+
mEventId: string,
360+
mEvent: MatrixEvent,
361+
item: number,
362+
timelineSet: EventTimelineSet,
363+
collapse: boolean
364+
) => {
365+
const { getSender, getAssociatedStatus, isRedacted, getUnsigned } = mEvent;
366+
const reactionRelations = getEventReactions(timelineSet, mEventId);
367+
const reactions = reactionRelations?.getSortedAnnotationsByKey();
368+
const hasReactions = reactions && reactions.length > 0;
369+
const highlighted = focusItem?.index === item && focusItem.highlight;
370+
const senderId = getSender.call(mEvent) ?? '';
371+
const senderDisplayName =
372+
getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId;
373+
const myUserId = mx.getUserId() ?? '';
374+
const canEnd = myUserId === senderId || canRedact;
375+
376+
return (
377+
<Message
378+
key={mEventId}
379+
data-message-item={item}
380+
data-message-id={mEventId}
381+
room={room}
382+
mEvent={mEvent}
383+
messageSpacing={messageSpacing}
384+
messageLayout={messageLayout}
385+
highlight={highlighted}
386+
canDelete={canRedact || (canDeleteOwn && senderId === myUserId)}
387+
canSendReaction={canSendReaction}
388+
canPinEvent={canPinEvent}
389+
imagePackRooms={imagePackRooms}
390+
relations={hasReactions ? reactionRelations : undefined}
391+
onUserClick={onUserClick}
392+
onUsernameClick={onUsernameClick}
393+
onReplyClick={onReplyClick}
394+
onReactionToggle={onReactionToggle}
395+
onEditId={onEditId}
396+
senderId={senderId}
397+
activeReplyId={activeReplyId}
398+
senderDisplayName={senderDisplayName}
399+
sendStatus={getAssociatedStatus.call(mEvent)}
400+
onResend={onResend}
401+
onDeleteFailedSend={onDeleteFailedSend}
402+
collapse={collapse}
403+
reactions={
404+
reactionRelations ? (
405+
<Reactions
406+
style={{ marginTop: config.space.S200 }}
407+
room={room}
408+
relations={reactionRelations}
409+
mEventId={mEventId}
410+
canSendReaction={canSendReaction}
411+
canDeleteOwn={canDeleteOwn}
412+
onReactionToggle={onReactionToggle}
413+
/>
414+
) : undefined
415+
}
416+
hideReadReceipts={hideReads}
417+
showDeveloperTools={showDeveloperTools}
418+
memberPowerTag={getMemberPowerTag(senderId)}
419+
hour24Clock={hour24Clock}
420+
dateFormatString={dateFormatString}
421+
>
422+
{isRedacted.call(mEvent) ? (
423+
<RedactedContent reason={getUnsigned.call(mEvent).redacted_because?.content.reason} />
424+
) : (
425+
<PollEvent
426+
room={room}
427+
mEvent={mEvent}
428+
canEnd={canEnd}
429+
outlined={messageLayout === MessageLayout.Bubble}
430+
/>
431+
)}
432+
</Message>
433+
);
434+
};
435+
357436
return useMatrixEventRenderer<[string, MatrixEvent, number, EventTimelineSet, boolean]>(
358437
{
359438
[EventType.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
@@ -1133,81 +1212,15 @@ export function useTimelineEventRenderer({
11331212
</Event>
11341213
);
11351214
},
1136-
[MessageEvent.PollStart]: (mEventId, mEvent, item, timelineSet, collapse) => {
1137-
const { getSender, getAssociatedStatus, isRedacted, getUnsigned } = mEvent;
1138-
const reactionRelations = getEventReactions(timelineSet, mEventId);
1139-
const reactions = reactionRelations?.getSortedAnnotationsByKey();
1140-
const hasReactions = reactions && reactions.length > 0;
1141-
const highlighted = focusItem?.index === item && focusItem.highlight;
1142-
const senderId = getSender.call(mEvent) ?? '';
1143-
const senderDisplayName =
1144-
getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId;
1145-
const myUserId = mx.getUserId() ?? '';
1146-
const canEnd = myUserId === senderId || canRedact;
1147-
1148-
return (
1149-
<Message
1150-
key={mEventId}
1151-
data-message-item={item}
1152-
data-message-id={mEventId}
1153-
room={room}
1154-
mEvent={mEvent}
1155-
messageSpacing={messageSpacing}
1156-
messageLayout={messageLayout}
1157-
highlight={highlighted}
1158-
canDelete={canRedact || (canDeleteOwn && senderId === myUserId)}
1159-
canSendReaction={canSendReaction}
1160-
canPinEvent={canPinEvent}
1161-
imagePackRooms={imagePackRooms}
1162-
relations={hasReactions ? reactionRelations : undefined}
1163-
onUserClick={onUserClick}
1164-
onUsernameClick={onUsernameClick}
1165-
onReplyClick={onReplyClick}
1166-
onReactionToggle={onReactionToggle}
1167-
onEditId={onEditId}
1168-
senderId={senderId}
1169-
activeReplyId={activeReplyId}
1170-
senderDisplayName={senderDisplayName}
1171-
sendStatus={getAssociatedStatus.call(mEvent)}
1172-
onResend={onResend}
1173-
onDeleteFailedSend={onDeleteFailedSend}
1174-
collapse={collapse}
1175-
reactions={
1176-
reactionRelations ? (
1177-
<Reactions
1178-
style={{ marginTop: config.space.S200 }}
1179-
room={room}
1180-
relations={reactionRelations}
1181-
mEventId={mEventId}
1182-
canSendReaction={canSendReaction}
1183-
canDeleteOwn={canDeleteOwn}
1184-
onReactionToggle={onReactionToggle}
1185-
/>
1186-
) : undefined
1187-
}
1188-
hideReadReceipts={hideReads}
1189-
showDeveloperTools={showDeveloperTools}
1190-
memberPowerTag={getMemberPowerTag(senderId)}
1191-
hour24Clock={hour24Clock}
1192-
dateFormatString={dateFormatString}
1193-
>
1194-
{isRedacted.call(mEvent) ? (
1195-
<RedactedContent reason={getUnsigned.call(mEvent).redacted_because?.content.reason} />
1196-
) : (
1197-
<PollEvent
1198-
room={room}
1199-
mEvent={mEvent}
1200-
canEnd={canEnd}
1201-
outlined={messageLayout === MessageLayout.Bubble}
1202-
/>
1203-
)}
1204-
</Message>
1205-
);
1206-
},
1215+
[MessageEvent.PollStart]: renderPollStart,
1216+
[MessageEvent.StablePollStart]: renderPollStart,
12071217
// Poll response and end events are not rendered individually —
12081218
// they update the poll via RoomEvent.Timeline listeners in PollEvent.
12091219
[MessageEvent.PollResponse]: () => null,
12101220
[MessageEvent.PollEnd]: () => null,
1221+
// Stable poll type aliases (m.poll.*)
1222+
[MessageEvent.StablePollResponse]: () => null,
1223+
[MessageEvent.StablePollEnd]: () => null,
12111224
},
12121225
(mEventId, mEvent, item, timelineSet, collapse) => {
12131226
if (!showHiddenEvents) return null;

src/types/matrix/room.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,18 +61,14 @@ export enum MessageEvent {
6161
Sticker = 'm.sticker',
6262
RoomRedaction = 'm.room.redaction',
6363
Reaction = 'm.reaction',
64-
}
65-
66-
export enum MessageEvent {
67-
RoomMessage = 'm.room.message',
68-
RoomMessageEncrypted = 'm.room.encrypted',
69-
Sticker = 'm.sticker',
70-
RoomRedaction = 'm.room.redaction',
71-
Reaction = 'm.reaction',
72-
// MSC3381 Polls — unstable prefix (stable types not yet in a shipped room version)
64+
// MSC3381 Polls — unstable prefix (still the most common in the wild)
7365
PollStart = 'org.matrix.msc3381.poll.start',
7466
PollResponse = 'org.matrix.msc3381.poll.response',
7567
PollEnd = 'org.matrix.msc3381.poll.end',
68+
// MSC3381 Polls — stable types (used by newer servers)
69+
StablePollStart = 'm.poll.start',
70+
StablePollResponse = 'm.poll.response',
71+
StablePollEnd = 'm.poll.end',
7672
}
7773

7874
export enum RoomType {

0 commit comments

Comments
 (0)