From 4dbb77c1f48d92661dd0ca0f3071d88c7239abac Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Tue, 10 Mar 2026 19:42:23 +0100 Subject: [PATCH 01/15] feat: crude implementation of poll event gathering --- src/app/features/room/RoomTimeline.tsx | 164 +++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 39d7e50a60..c72256300d 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -17,6 +17,8 @@ import { EventTimelineSet, EventTimelineSetHandlerMap, IContent, + IEvent, + M_POLL_START, MatrixClient, MatrixEvent, Room, @@ -1022,6 +1024,168 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli [string, MatrixEvent, number, EventTimelineSet, boolean] >( { + [M_POLL_START.name]: (mEventId, mEvent, item, timelineSet, collapse) => { + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); + const hasReactions = reactions && reactions.length > 0; + const { replyEventId, threadRootId } = mEvent; + const highlighted = focusItem?.index === item && focusItem.highlight; + + // TODO: handle edits + // TODO: make sure polls can't be edited after there have been votes on it (e.g. ignore that event) + // ^ maybe this should be done server side? + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + const getContent = (() => + editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; + + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + + // collect all votes + // select per user only the most recent one (by event.origin_server_ts) + // aggregate the votes into an object of {answer_id: [{user, vote_event_id}]} + // + // we do this like this because it implicitly handles: + // - vote redactions + // - polls where you can vote on multiple items + const latestVoteEventByUser = timelineSet.relations + .getAllChildEventsForEvent(mEventId) + .map((evt) => evt.event) + .reduce((users: Record | undefined>, evt) => { + if (!evt.sender) { + return users; + } + + const newGrouped = users; + const currentNewest = newGrouped[evt.sender]; + if ( + !currentNewest || + (currentNewest.origin_server_ts || 0) <= (evt.origin_server_ts || 0) + ) { + newGrouped[evt.sender] = evt; + } + return newGrouped; + }, {}); + + const votesDeduped = Object.values(latestVoteEventByUser).map((voteEvent) => { + // TODO: remove non null/undefined assertions + const answers = voteEvent!.content!['org.matrix.msc3381.poll.response'] + .answers as string[]; + const userId = voteEvent!.sender as string; + const voteEventId = voteEvent!.event_id as string; + + return { + answers, + userId, + eventId: voteEventId, + }; + }); + + const content = getContent(); + const pollContent = content['org.matrix.msc3381.poll.start']; + + const title = pollContent.question['org.matrix.msc1767.text']; + + const answers = pollContent.answers.map( + // TODO: proper typing + (answer: { id: string; 'org.matrix.msc1767.text': any }) => ({ + id: answer.id, + body: answer['org.matrix.msc1767.text'], + }) + ); + + const answerIds: string[] = pollContent.answers.map( + // TODO: proper typing + (answer: { id: string }) => answer.id + ); + + const votesByAnswer = Object.fromEntries( + answerIds.map((answerId) => [ + answerId, + votesDeduped + .filter((vote) => vote.answers.includes(answerId)) + .map((vote) => ({ + eventId: vote.eventId, + userId: vote.userId, + })), + ]) + ); + + // TODO: do not show the answer yet if pollType is m.undisclosed + const pollType = pollContent.kind; + const totalVoteCount = votesDeduped.reduce((count, evt) => count + evt.answers.length, 0); + const ownUserId = room.client.getUserId(); + const ownVoteEvent = latestVoteEventByUser[ownUserId || '']; + let ownVotes = ownVoteEvent?.content?.['org.matrix.msc3381.poll.response']?.answers; + console.log({ ownVotes, ownVoteEvent }); + + console.log({ votesByAnswer }); + + return ( + + ) + } + reactions={ + reactionRelations && ( + + ) + } + hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(senderId)} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {mEvent.isRedacted() ? ( + + ) : ( +

Poll

+ )} +
+ ); + }, [MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => { const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); From c689d340115ff1c94b19b6639888195c9a4eed0e Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Tue, 10 Mar 2026 19:43:41 +0100 Subject: [PATCH 02/15] feat: crude implementation for displaying polls --- src/app/features/room/RoomTimeline.tsx | 76 +++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index c72256300d..d5cf461fbb 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -40,6 +40,8 @@ import { Icon, Icons, Line, + ProgressBar, + RadioButton, Scroll, Text, as, @@ -67,6 +69,10 @@ import { MSticker, ImageContent, EventContent, + Attachment, + AttachmentHeader, + AttachmentBox, + AttachmentContent, } from '../../components/message'; import { factoryRenderLinkifyWithMention, @@ -1181,7 +1187,75 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli {mEvent.isRedacted() ? ( ) : ( -

Poll

+ // TODO: stop abusing the Attachment elements + + + + Poll + + + {/* TODO: make this a hyperlink that opens a dialog that shows who voted for what */} + + {totalVoteCount} {totalVoteCount === 1 ? 'vote' : 'votes'} + + + + + + {title} + + + {answers.map((answer: any) => ( + + + + + + + {answer.body} + + + {votesByAnswer[answer.id].length}{' '} + {votesByAnswer[answer.id].length === 1 ? 'vote' : 'votes'} + + + + {/* */} + + {/* */} + + ))} + + {/* {renderAudioContent({ + info: audioInfo, + mimeType: safeMimeType, + url: mxcUrl, + encInfo: content.file, + })} */} + + + + )} ); From 0589f7e9717d8a72202219e5315c83fafe023875 Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Tue, 10 Mar 2026 19:45:06 +0100 Subject: [PATCH 03/15] chore(polls): add TODOs regarding event types --- src/app/features/room/RoomTimeline.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d5cf461fbb..6ca015cbb0 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1048,6 +1048,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const senderDisplayName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + // TODO: check event /type/ to make sure it is a vote event and not another type of poll event + // TODO: check if poll.end event is in here and if so, disallow voting on the poll (may need authentication check?) // collect all votes // select per user only the most recent one (by event.origin_server_ts) // aggregate the votes into an object of {answer_id: [{user, vote_event_id}]} From 83a836c50ea6be14f00d8acae909b73907bf52ce Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Tue, 10 Mar 2026 20:01:15 +0100 Subject: [PATCH 04/15] chore(polls): fix some linting issues --- src/app/features/room/RoomTimeline.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 6ca015cbb0..e77fa34941 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1045,8 +1045,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; const senderId = mEvent.getSender() ?? ''; - const senderDisplayName = - getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; // TODO: check event /type/ to make sure it is a vote event and not another type of poll event // TODO: check if poll.end event is in here and if so, disallow voting on the poll (may need authentication check?) @@ -1120,15 +1118,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ]) ); - // TODO: do not show the answer yet if pollType is m.undisclosed + // TODO: do not show the answer yet if pollType is m.undisclosed, and remove eslint ignore below + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars const pollType = pollContent.kind; const totalVoteCount = votesDeduped.reduce((count, evt) => count + evt.answers.length, 0); const ownUserId = room.client.getUserId(); const ownVoteEvent = latestVoteEventByUser[ownUserId || '']; - let ownVotes = ownVoteEvent?.content?.['org.matrix.msc3381.poll.response']?.answers; - console.log({ ownVotes, ownVoteEvent }); - - console.log({ votesByAnswer }); + const ownVotes = ownVoteEvent?.content?.['org.matrix.msc3381.poll.response']?.answers; return ( Date: Thu, 12 Mar 2026 00:10:12 +0100 Subject: [PATCH 05/15] chore(polls): clean up design a bit --- src/app/features/room/RoomTimeline.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index e77fa34941..89f164d8aa 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1187,7 +1187,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ) : ( // TODO: stop abusing the Attachment elements - + Poll @@ -1196,16 +1201,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli {totalVoteCount} {totalVoteCount === 1 ? 'vote' : 'votes'} - + - + {title} - {answers.map((answer: any) => ( - - + + Date: Sat, 14 Mar 2026 21:53:31 +0100 Subject: [PATCH 06/15] chore(polls): clean up and improve types --- src/app/features/room/RoomTimeline.tsx | 203 ++++++++++++++----------- 1 file changed, 115 insertions(+), 88 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 89f164d8aa..a7655428ee 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -16,11 +16,16 @@ import { EventTimeline, EventTimelineSet, EventTimelineSetHandlerMap, + ExtensibleAnyMessageEventContent, IContent, - IEvent, + M_MESSAGE, + M_POLL_RESPONSE, M_POLL_START, + M_TEXT, MatrixClient, MatrixEvent, + PollResponseEvent, + PollStartEventContent, Room, RoomEvent, RoomEventHandlerMap, @@ -70,7 +75,6 @@ import { ImageContent, EventContent, Attachment, - AttachmentHeader, AttachmentBox, AttachmentContent, } from '../../components/message'; @@ -1057,60 +1061,84 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // - polls where you can vote on multiple items const latestVoteEventByUser = timelineSet.relations .getAllChildEventsForEvent(mEventId) - .map((evt) => evt.event) - .reduce((users: Record | undefined>, evt) => { - if (!evt.sender) { - return users; + .reduce((userMap: Record, evt) => { + const sender = evt.getSender(); + if (!sender) { + return userMap; } - const newGrouped = users; - const currentNewest = newGrouped[evt.sender]; - if ( - !currentNewest || - (currentNewest.origin_server_ts || 0) <= (evt.origin_server_ts || 0) - ) { - newGrouped[evt.sender] = evt; + const newUserMap = userMap; + const currentNewestVoteTimestamp = newUserMap[sender]?.getTs() || 0; + const newVoteTimestamp = evt.getTs() || 0; + if (currentNewestVoteTimestamp <= newVoteTimestamp) { + newUserMap[sender] = evt; } - return newGrouped; + return newUserMap; }, {}); - const votesDeduped = Object.values(latestVoteEventByUser).map((voteEvent) => { - // TODO: remove non null/undefined assertions - const answers = voteEvent!.content!['org.matrix.msc3381.poll.response'] - .answers as string[]; - const userId = voteEvent!.sender as string; - const voteEventId = voteEvent!.event_id as string; - - return { - answers, - userId, - eventId: voteEventId, - }; - }); - - const content = getContent(); - const pollContent = content['org.matrix.msc3381.poll.start']; + const votesDeduped = Object.values(latestVoteEventByUser) + .map((voteEvent) => { + const content = voteEvent.getContent(); + let responseContent; - const title = pollContent.question['org.matrix.msc1767.text']; + if (M_POLL_RESPONSE.name in content) { + responseContent = content[M_POLL_RESPONSE.name]; + } else { + responseContent = content[M_POLL_RESPONSE.altName]; + } - const answers = pollContent.answers.map( - // TODO: proper typing - (answer: { id: string; 'org.matrix.msc1767.text': any }) => ({ - id: answer.id, - body: answer['org.matrix.msc1767.text'], + return { + answers: responseContent.answers, + userId: voteEvent.getSender(), + eventId: voteEvent.getId(), + }; }) - ); + .filter((x) => x !== undefined); + + const content = mEvent.getContent(); + const pollContent = + M_POLL_START.name in content ? content[M_POLL_START.name] : content[M_POLL_START.altName]; + console.log({ pollContent }); + + const getBodyFromExtensibleAnyMessageEventContent = ( + e: ExtensibleAnyMessageEventContent + ) => { + if ('body' in e && typeof e.body === 'string') { + return e.body; + } - const answerIds: string[] = pollContent.answers.map( - // TODO: proper typing - (answer: { id: string }) => answer.id - ); + if (M_TEXT.name in e) { + return e[M_TEXT.name]; + } + + if (M_TEXT.altName in e) { + return e[M_TEXT.altName]; + } + + if (M_MESSAGE.name in e) { + return e[M_MESSAGE.name][0].body; + } + + if (M_MESSAGE.altName in e) { + return e[M_MESSAGE.altName][0].body; + } + + // TODO: handle extensible text, e.g. html + return ''; + }; + + const title = getBodyFromExtensibleAnyMessageEventContent(pollContent.question); + + const answers = pollContent.answers.map((answer) => ({ + id: answer.id, + body: getBodyFromExtensibleAnyMessageEventContent(answer), + })); const votesByAnswer = Object.fromEntries( - answerIds.map((answerId) => [ - answerId, + answers.map(({ id }) => [ + id, votesDeduped - .filter((vote) => vote.answers.includes(answerId)) + .filter((vote) => vote.answers.includes(id)) .map((vote) => ({ eventId: vote.eventId, userId: vote.userId, @@ -1121,10 +1149,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // TODO: do not show the answer yet if pollType is m.undisclosed, and remove eslint ignore below // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars const pollType = pollContent.kind; + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + const allowedVotes = pollContent.max_selections || 1; + const totalVoteCount = votesDeduped.reduce((count, evt) => count + evt.answers.length, 0); const ownUserId = room.client.getUserId(); - const ownVoteEvent = latestVoteEventByUser[ownUserId || '']; - const ownVotes = ownVoteEvent?.content?.['org.matrix.msc3381.poll.response']?.answers; + const ownVoteEvent = latestVoteEventByUser[ownUserId || ''].getContent(); + const ownVotes = ( + M_POLL_RESPONSE.name in ownVoteEvent + ? ownVoteEvent[M_POLL_RESPONSE.name] + : ownVoteEvent[M_POLL_RESPONSE.altName] + ).answers; return ( {title} {answers.map((answer: any) => ( - - - - - + + + + + - {answer.body} + + {answer.body} + + + {votesByAnswer[answer.id].length}{' '} + {votesByAnswer[answer.id].length === 1 ? 'vote' : 'votes'} + - - {votesByAnswer[answer.id].length}{' '} - {votesByAnswer[answer.id].length === 1 ? 'vote' : 'votes'} - - - {/* */} - - {/* */} + + ))} - - {/* {renderAudioContent({ - info: audioInfo, - mimeType: safeMimeType, - url: mxcUrl, - encInfo: content.file, - })} */} From 9edd3226f50d6f9d619bb1278a4a8f6680e5052a Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Sat, 14 Mar 2026 23:06:06 +0100 Subject: [PATCH 07/15] feat(polls): add undisclosed poll functionality --- src/app/features/room/RoomTimeline.tsx | 91 ++++++++++++++++++-------- 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index a7655428ee..4f9aee3bf4 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -19,6 +19,9 @@ import { ExtensibleAnyMessageEventContent, IContent, M_MESSAGE, + M_POLL_END, + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, M_POLL_RESPONSE, M_POLL_START, M_TEXT, @@ -40,6 +43,7 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { Badge, Box, + Button, Chip, ContainerColor, Icon, @@ -1050,8 +1054,23 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const senderId = mEvent.getSender() ?? ''; - // TODO: check event /type/ to make sure it is a vote event and not another type of poll event // TODO: check if poll.end event is in here and if so, disallow voting on the poll (may need authentication check?) + let endedEvent; + const childEvents = timelineSet.relations + .getAllChildEventsForEvent(mEventId) + .filter((event) => { + if ( + event.getType() !== M_POLL_RESPONSE.name && + event.getType() !== M_POLL_RESPONSE.altName + ) { + if (event.getType() === M_POLL_END.name || event.getType() === M_POLL_END.altName) { + endedEvent = event; + } + return false; + } + + return true; + }); // collect all votes // select per user only the most recent one (by event.origin_server_ts) // aggregate the votes into an object of {answer_id: [{user, vote_event_id}]} @@ -1059,9 +1078,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // we do this like this because it implicitly handles: // - vote redactions // - polls where you can vote on multiple items - const latestVoteEventByUser = timelineSet.relations - .getAllChildEventsForEvent(mEventId) - .reduce((userMap: Record, evt) => { + const latestVoteEventByUser = childEvents.reduce( + (userMap: Record, evt) => { const sender = evt.getSender(); if (!sender) { return userMap; @@ -1074,7 +1092,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli newUserMap[sender] = evt; } return newUserMap; - }, {}); + }, + {} + ); const votesDeduped = Object.values(latestVoteEventByUser) .map((voteEvent) => { @@ -1098,7 +1118,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const content = mEvent.getContent(); const pollContent = M_POLL_START.name in content ? content[M_POLL_START.name] : content[M_POLL_START.altName]; - console.log({ pollContent }); const getBodyFromExtensibleAnyMessageEventContent = ( e: ExtensibleAnyMessageEventContent @@ -1147,19 +1166,32 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ); // TODO: do not show the answer yet if pollType is m.undisclosed, and remove eslint ignore below - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - const pollType = pollContent.kind; + const getPollKind = (kind: string) => { + if (kind === M_POLL_KIND_UNDISCLOSED.name || kind === M_POLL_KIND_UNDISCLOSED.altName) { + return 'm.poll.undisclosed' as const; + } + + return 'm.poll.disclosed' as const; + }; + + const pollKind = getPollKind(pollContent.kind); // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars const allowedVotes = pollContent.max_selections || 1; const totalVoteCount = votesDeduped.reduce((count, evt) => count + evt.answers.length, 0); const ownUserId = room.client.getUserId(); - const ownVoteEvent = latestVoteEventByUser[ownUserId || ''].getContent(); - const ownVotes = ( - M_POLL_RESPONSE.name in ownVoteEvent - ? ownVoteEvent[M_POLL_RESPONSE.name] - : ownVoteEvent[M_POLL_RESPONSE.altName] - ).answers; + const ownVoteEvent = + latestVoteEventByUser[ownUserId || '']?.getContent(); + const ownVotes = ownVoteEvent + ? (M_POLL_RESPONSE.name in ownVoteEvent + ? ownVoteEvent[M_POLL_RESPONSE.name] + : ownVoteEvent[M_POLL_RESPONSE.altName] + ).answers + : []; + + const canShowResults = + pollKind === 'm.poll.disclosed' || + (pollKind === 'm.poll.undisclosed' && endedEvent != null); return ( - Poll + + {pollKind === 'm.poll.disclosed' ? 'Poll' : 'Undisclosed poll'} + {endedEvent ? ' (ended)' : ''} + {/* TODO: make this a hyperlink that opens a dialog that shows who voted for what */} @@ -1271,18 +1306,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli - + {canShowResults ? ( + + ) : null} ))} From ed0badd7920ce92631d3a8ea07b0c984c726737f Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Sun, 15 Mar 2026 00:44:59 +0100 Subject: [PATCH 08/15] feat(polls): Add poll end button and modal --- src/app/components/message/poll/EndPoll.tsx | 74 +++++++++++++++++++++ src/app/features/room/RoomTimeline.tsx | 20 +++++- 2 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 src/app/components/message/poll/EndPoll.tsx diff --git a/src/app/components/message/poll/EndPoll.tsx b/src/app/components/message/poll/EndPoll.tsx new file mode 100644 index 0000000000..113775fcb2 --- /dev/null +++ b/src/app/components/message/poll/EndPoll.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + as, + Box, + Button, + config, + Header, + Modal, + Overlay, + OverlayBackdrop, + OverlayCenter, + Text, +} from 'folds'; +import { stopPropagation } from '../../../utils/keyboard'; + +export const EndPollModal = as< + 'div', + { open: string; setOpen: (open: string) => void; eventID: string } +>(({ open, setOpen, eventID }, ref) => ( + // TODO: implement sending actual poll end event + }> + + setOpen(''), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ + End poll + +
+ + + Are you sure you want to end this poll? This will reveal the results of the poll, and + not allow anyone to vote on it anymore. + + + + + + + +
+
+
+
+)); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 4f9aee3bf4..7cd906c5ff 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -20,7 +20,6 @@ import { IContent, M_MESSAGE, M_POLL_END, - M_POLL_KIND_DISCLOSED, M_POLL_KIND_UNDISCLOSED, M_POLL_RESPONSE, M_POLL_START, @@ -143,6 +142,7 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; +import { EndPollModal } from '../../components/message/poll/EndPoll'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( @@ -1034,6 +1034,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ); const { t } = useTranslation(); + const [openEndPollModal, setOpenEndPollModal] = useState(''); const renderMatrixEvent = useMatrixEventRenderer< [string, MatrixEvent, number, EventTimelineSet, boolean] >( @@ -1165,7 +1166,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ]) ); - // TODO: do not show the answer yet if pollType is m.undisclosed, and remove eslint ignore below const getPollKind = (kind: string) => { if (kind === M_POLL_KIND_UNDISCLOSED.name || kind === M_POLL_KIND_UNDISCLOSED.altName) { return 'm.poll.undisclosed' as const; @@ -1193,6 +1193,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli pollKind === 'm.poll.disclosed' || (pollKind === 'm.poll.undisclosed' && endedEvent != null); + // TODO: actually send events when things are clicked return ( {title} - {answers.map((answer: any) => ( + {answers.map((answer) => ( @@ -1323,6 +1324,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ))} + {senderId === ownUserId && pollKind === 'm.poll.undisclosed' ? ( + <> + + + + + ) : null} From b88b4986472fb4da071570939846c0cb2d19d88d Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Sun, 15 Mar 2026 01:23:51 +0100 Subject: [PATCH 09/15] feat(polls): allow voting! (dirty bad impl) --- src/app/features/room/RoomTimeline.tsx | 38 ++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 7cd906c5ff..e0cc5aad43 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1056,7 +1056,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const senderId = mEvent.getSender() ?? ''; // TODO: check if poll.end event is in here and if so, disallow voting on the poll (may need authentication check?) - let endedEvent; + let endedEvent: MatrixEvent | undefined; const childEvents = timelineSet.relations .getAllChildEventsForEvent(mEventId) .filter((event) => { @@ -1072,6 +1072,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli return true; }); + + console.log({ mEventId, childEvents }); // collect all votes // select per user only the most recent one (by event.origin_server_ts) // aggregate the votes into an object of {answer_id: [{user, vote_event_id}]} @@ -1089,7 +1091,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const newUserMap = userMap; const currentNewestVoteTimestamp = newUserMap[sender]?.getTs() || 0; const newVoteTimestamp = evt.getTs() || 0; - if (currentNewestVoteTimestamp <= newVoteTimestamp) { + if ( + // pick newest + currentNewestVoteTimestamp <= newVoteTimestamp && + // ignore events after poll ended event + (!endedEvent || (endedEvent && newVoteTimestamp <= endedEvent?.getTs())) + ) { newUserMap[sender] = evt; } return newUserMap; @@ -1175,6 +1182,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli }; const pollKind = getPollKind(pollContent.kind); + // TODO: make buttons checkboxes when >1 votes are allowed // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars const allowedVotes = pollContent.max_selections || 1; @@ -1281,7 +1289,31 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli {answers.map((answer) => ( - + { + let x = await mx.sendEvent( + room.roomId, + M_POLL_RESPONSE.name as string, + { + 'm.relates_to': { + event_id: mEventId, + rel_type: 'm.reference', + }, + + 'm.selections': [answer.id], + 'm.poll.response': { + answers: [answer.id], + }, + 'org.matrix.msc3381.poll.response': { + answers: [answer.id], + }, + } as IContent + ); + console.log({ x }); + }} + checked={(ownVotes || []).includes(answer.id)} + /> Date: Sun, 15 Mar 2026 13:42:40 +0100 Subject: [PATCH 10/15] feat(polls): actually hide results for undisclosed polls --- src/app/features/room/RoomTimeline.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index e0cc5aad43..53c2c8823b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1333,10 +1333,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli > {answer.body} - - {votesByAnswer[answer.id].length}{' '} - {votesByAnswer[answer.id].length === 1 ? 'vote' : 'votes'} - + {canShowResults ? ( + + {votesByAnswer[answer.id].length}{' '} + {votesByAnswer[answer.id].length === 1 ? 'vote' : 'votes'} + + ) : null} {canShowResults ? ( From a1e32a5265273537ca4fdd369191a2cebc9dba06 Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Sun, 15 Mar 2026 21:31:27 +0100 Subject: [PATCH 11/15] chore(polls): move poll component into own file, show edit, disallow votes after ended --- src/app/components/message/poll/EndPoll.tsx | 12 +- src/app/components/message/poll/Poll.tsx | 154 ++++++++++++++++++++ src/app/features/room/RoomTimeline.tsx | 149 +++---------------- 3 files changed, 178 insertions(+), 137 deletions(-) create mode 100644 src/app/components/message/poll/Poll.tsx diff --git a/src/app/components/message/poll/EndPoll.tsx b/src/app/components/message/poll/EndPoll.tsx index 113775fcb2..2af07af5ef 100644 --- a/src/app/components/message/poll/EndPoll.tsx +++ b/src/app/components/message/poll/EndPoll.tsx @@ -16,15 +16,15 @@ import { stopPropagation } from '../../../utils/keyboard'; export const EndPollModal = as< 'div', - { open: string; setOpen: (open: string) => void; eventID: string } ->(({ open, setOpen, eventID }, ref) => ( + { open: boolean; setOpen: React.Dispatch> } +>(({ open, setOpen }, ref) => ( // TODO: implement sending actual poll end event - }> + }> setOpen(''), + onDeactivate: () => setOpen(false), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} @@ -52,7 +52,7 @@ export const EndPollModal = as< + + + ) : null} + + + +
+ ); +} diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 53c2c8823b..2031898cc1 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -42,14 +42,11 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { Badge, Box, - Button, Chip, ContainerColor, Icon, Icons, Line, - ProgressBar, - RadioButton, Scroll, Text, as, @@ -77,9 +74,6 @@ import { MSticker, ImageContent, EventContent, - Attachment, - AttachmentBox, - AttachmentContent, } from '../../components/message'; import { factoryRenderLinkifyWithMention, @@ -142,7 +136,7 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; -import { EndPollModal } from '../../components/message/poll/EndPoll'; +import { Poll } from '../../components/message/poll/Poll'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( @@ -1034,7 +1028,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ); const { t } = useTranslation(); - const [openEndPollModal, setOpenEndPollModal] = useState(''); const renderMatrixEvent = useMatrixEventRenderer< [string, MatrixEvent, number, EventTimelineSet, boolean] >( @@ -1046,13 +1039,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const { replyEventId, threadRootId } = mEvent; const highlighted = focusItem?.index === item && focusItem.highlight; - // TODO: handle edits - // TODO: make sure polls can't be edited after there have been votes on it (e.g. ignore that event) - // ^ maybe this should be done server side? const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); - const getContent = (() => - editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; - const senderId = mEvent.getSender() ?? ''; // TODO: check if poll.end event is in here and if so, disallow voting on the poll (may need authentication check?) @@ -1073,7 +1060,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli return true; }); - console.log({ mEventId, childEvents }); // collect all votes // select per user only the most recent one (by event.origin_server_ts) // aggregate the votes into an object of {answer_id: [{user, vote_event_id}]} @@ -1182,8 +1168,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli }; const pollKind = getPollKind(pollContent.kind); - // TODO: make buttons checkboxes when >1 votes are allowed - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars const allowedVotes = pollContent.max_selections || 1; const totalVoteCount = votesDeduped.reduce((count, evt) => count + evt.answers.length, 0); @@ -1261,120 +1245,23 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli {mEvent.isRedacted() ? ( ) : ( - // TODO: stop abusing the Attachment elements - - - - - {pollKind === 'm.poll.disclosed' ? 'Poll' : 'Undisclosed poll'} - {endedEvent ? ' (ended)' : ''} - - - - {/* TODO: make this a hyperlink that opens a dialog that shows who voted for what */} - - {totalVoteCount} {totalVoteCount === 1 ? 'vote' : 'votes'} - - - - - - {title} - - {answers.map((answer) => ( - - - { - let x = await mx.sendEvent( - room.roomId, - M_POLL_RESPONSE.name as string, - { - 'm.relates_to': { - event_id: mEventId, - rel_type: 'm.reference', - }, - - 'm.selections': [answer.id], - 'm.poll.response': { - answers: [answer.id], - }, - 'org.matrix.msc3381.poll.response': { - answers: [answer.id], - }, - } as IContent - ); - console.log({ x }); - }} - checked={(ownVotes || []).includes(answer.id)} - /> - - - - - {answer.body} - - {canShowResults ? ( - - {votesByAnswer[answer.id].length}{' '} - {votesByAnswer[answer.id].length === 1 ? 'vote' : 'votes'} - - ) : null} - - - {canShowResults ? ( - - ) : null} - - - ))} - {senderId === ownUserId && pollKind === 'm.poll.undisclosed' ? ( - <> - - - - - ) : null} - - - - + )}
); From 914cdf6103b657b2051052bbf16e5f74d1a55238 Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Mon, 16 Mar 2026 01:00:41 +0100 Subject: [PATCH 12/15] feat(polls): Implement poll ending --- src/app/components/message/poll/EndPoll.tsx | 149 +++++++++++++------- src/app/components/message/poll/Poll.tsx | 12 +- 2 files changed, 106 insertions(+), 55 deletions(-) diff --git a/src/app/components/message/poll/EndPoll.tsx b/src/app/components/message/poll/EndPoll.tsx index 2af07af5ef..99329268ea 100644 --- a/src/app/components/message/poll/EndPoll.tsx +++ b/src/app/components/message/poll/EndPoll.tsx @@ -12,63 +12,106 @@ import { OverlayCenter, Text, } from 'folds'; +import { IContent, M_POLL_END, Room } from 'matrix-js-sdk'; import { stopPropagation } from '../../../utils/keyboard'; export const EndPollModal = as< 'div', - { open: boolean; setOpen: React.Dispatch> } ->(({ open, setOpen }, ref) => ( + { + room: Room; + eventId: string; + open: boolean; + answers: { + id: string; + body: string; + }[]; + votesByAnswer: Record< + string, + { + eventId: string | undefined; + userId: string | undefined; + }[] + >; + setOpen: React.Dispatch>; + } +>(({ room, votesByAnswer, answers, eventId, open, setOpen }, ref) => { + // TODO: handle multiple winners + let [winnerID, winnerVotes] = Object.entries(votesByAnswer).reduce( + (currentWinner: [string, number], answer) => { + let newWinner = currentWinner; + if (currentWinner[1] < answer[1].length) { + newWinner = [answer[0], answer[1].length]; + } + return newWinner; + }, + ['', -1] + ); + + let winningAnswer = answers.find((x) => x.id === winnerID); + // TODO: implement sending actual poll end event - }> - - setOpen(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
- - End poll - -
- - - Are you sure you want to end this poll? This will reveal the results of the poll, and - not allow anyone to vote on it anymore. - + return ( + }> + + setOpen(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ + End poll + +
+ + + Are you sure you want to end this poll? This will reveal the results of the poll, + and not allow anyone to vote on it anymore. + - - - + + + + - -
-
-
-
-)); +
+
+
+
+ ); +}); diff --git a/src/app/components/message/poll/Poll.tsx b/src/app/components/message/poll/Poll.tsx index b9437eb9de..6d555d3d37 100644 --- a/src/app/components/message/poll/Poll.tsx +++ b/src/app/components/message/poll/Poll.tsx @@ -137,13 +137,21 @@ export function Poll({ ))} - {senderId === ownUserId && pollKind === 'm.poll.undisclosed' ? ( + {/* TODO: allow people with redaction power level to also close polls */} + {senderId === ownUserId && pollKind === 'm.poll.undisclosed' && !endedEvent ? ( <> - + ) : null} From a9ef82c9a5dec07756d65491aa699ab5d6b7e903 Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Mon, 16 Mar 2026 01:36:45 +0100 Subject: [PATCH 13/15] chore(polls): fix some linting errors --- src/app/components/message/poll/EndPoll.tsx | 6 ++-- src/app/components/message/poll/Poll.tsx | 35 +++++++++------------ 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/app/components/message/poll/EndPoll.tsx b/src/app/components/message/poll/EndPoll.tsx index 99329268ea..b20919b43a 100644 --- a/src/app/components/message/poll/EndPoll.tsx +++ b/src/app/components/message/poll/EndPoll.tsx @@ -12,7 +12,7 @@ import { OverlayCenter, Text, } from 'folds'; -import { IContent, M_POLL_END, Room } from 'matrix-js-sdk'; +import { M_POLL_END, Room } from 'matrix-js-sdk'; import { stopPropagation } from '../../../utils/keyboard'; export const EndPollModal = as< @@ -36,7 +36,7 @@ export const EndPollModal = as< } >(({ room, votesByAnswer, answers, eventId, open, setOpen }, ref) => { // TODO: handle multiple winners - let [winnerID, winnerVotes] = Object.entries(votesByAnswer).reduce( + const [winnerID, winnerVotes] = Object.entries(votesByAnswer).reduce( (currentWinner: [string, number], answer) => { let newWinner = currentWinner; if (currentWinner[1] < answer[1].length) { @@ -47,7 +47,7 @@ export const EndPollModal = as< ['', -1] ); - let winningAnswer = answers.find((x) => x.id === winnerID); + const winningAnswer = answers.find((x) => x.id === winnerID); // TODO: implement sending actual poll end event return ( diff --git a/src/app/components/message/poll/Poll.tsx b/src/app/components/message/poll/Poll.tsx index 6d555d3d37..f00f83067f 100644 --- a/src/app/components/message/poll/Poll.tsx +++ b/src/app/components/message/poll/Poll.tsx @@ -1,5 +1,5 @@ import { Box, Button, config, Line, ProgressBar, RadioButton, Text } from 'folds'; -import { IContent, M_POLL_RESPONSE, MatrixEvent, Room } from 'matrix-js-sdk'; +import { M_POLL_RESPONSE, MatrixEvent, Room } from 'matrix-js-sdk'; import React, { useState } from 'react'; import { Attachment, AttachmentBox, AttachmentContent } from '../../message'; import { MessageLayout } from '../../../state/settings'; @@ -78,25 +78,20 @@ export function Poll({ size="50" disabled={!!endedEvent} onClick={async () => { - let x = await room.client.sendEvent( - room.roomId, - M_POLL_RESPONSE.name as string, - { - 'm.relates_to': { - event_id: mEventId, - rel_type: 'm.reference', - }, - - 'm.selections': [answer.id], - 'm.poll.response': { - answers: [answer.id], - }, - 'org.matrix.msc3381.poll.response': { - answers: [answer.id], - }, - } as IContent - ); - console.log({ x }); + // @ts-expect-error this is allowed according to one of the function overloads, but that overload is /unreachable/ type-wise + await room.client.sendEvent(room.roomId, M_POLL_RESPONSE.name as string, { + 'm.relates_to': { + event_id: mEventId, + rel_type: 'm.reference', + }, + 'm.selections': [answer.id], + 'm.poll.response': { + answers: [answer.id], + }, + 'org.matrix.msc3381.poll.response': { + answers: [answer.id], + }, + }); }} checked={(ownVotes || []).includes(answer.id)} /> From 786770ea28fc38af700795b06f85b76090cde0d8 Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Mon, 16 Mar 2026 01:39:29 +0100 Subject: [PATCH 14/15] chore(polls): clean up code a little --- src/app/components/message/poll/Poll.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/components/message/poll/Poll.tsx b/src/app/components/message/poll/Poll.tsx index f00f83067f..7f31bec005 100644 --- a/src/app/components/message/poll/Poll.tsx +++ b/src/app/components/message/poll/Poll.tsx @@ -5,6 +5,10 @@ import { Attachment, AttachmentBox, AttachmentContent } from '../../message'; import { MessageLayout } from '../../../state/settings'; import { EndPollModal } from './EndPoll'; +function pluralize(amount: number, noun: string) { + return amount === 1 ? noun : `${noun}s`; +} + export function Poll({ messageLayout, pollKind, @@ -62,7 +66,7 @@ export function Poll({ {edited ? (Edited) : null} - {totalVoteCount} {totalVoteCount === 1 ? 'vote' : 'votes'} + {totalVoteCount} {pluralize(totalVoteCount, 'vote')} @@ -112,7 +116,7 @@ export function Poll({ {canShowResults ? ( {votesByAnswer[answer.id].length}{' '} - {votesByAnswer[answer.id].length === 1 ? 'vote' : 'votes'} + {pluralize(votesByAnswer[answer.id].length, 'vote')} ) : null} From e2032b5b2cd0b83f3a2b80d70de82a9ce09d4d3b Mon Sep 17 00:00:00 2001 From: Eli Saado Date: Thu, 26 Mar 2026 15:22:15 +0100 Subject: [PATCH 15/15] chore(polls): refactor pollAnswer into component --- src/app/components/message/poll/Poll.tsx | 151 ++++++++++++++--------- 1 file changed, 92 insertions(+), 59 deletions(-) diff --git a/src/app/components/message/poll/Poll.tsx b/src/app/components/message/poll/Poll.tsx index 7f31bec005..10f9f2b227 100644 --- a/src/app/components/message/poll/Poll.tsx +++ b/src/app/components/message/poll/Poll.tsx @@ -9,6 +9,72 @@ function pluralize(amount: number, noun: string) { return amount === 1 ? noun : `${noun}s`; } +function PollAnswer({ + answer, + endedEvent, + onClick, + ownVotes, + canShowResults, + votesByAnswer, + totalVoteCount, + messageLayout, +}: { + answer: { id: string; body: string }; + endedEvent: MatrixEvent | undefined; + onClick: React.MouseEventHandler | undefined; + ownVotes: string[]; + canShowResults: boolean; + votesByAnswer: { [k: string]: { eventId: string | undefined; userId: string | undefined }[] }; + totalVoteCount: number; + messageLayout: MessageLayout; +}) { + return ( + + + + + + + + {answer.body} + + {canShowResults ? ( + + {votesByAnswer[answer.id].length} {pluralize(votesByAnswer[answer.id].length, 'vote')} + + ) : null} + + + {canShowResults ? ( + + ) : null} + + + ); +} + export function Poll({ messageLayout, pollKind, @@ -76,65 +142,32 @@ export function Poll({ {title} {answers.map((answer) => ( - - - { - // @ts-expect-error this is allowed according to one of the function overloads, but that overload is /unreachable/ type-wise - await room.client.sendEvent(room.roomId, M_POLL_RESPONSE.name as string, { - 'm.relates_to': { - event_id: mEventId, - rel_type: 'm.reference', - }, - 'm.selections': [answer.id], - 'm.poll.response': { - answers: [answer.id], - }, - 'org.matrix.msc3381.poll.response': { - answers: [answer.id], - }, - }); - }} - checked={(ownVotes || []).includes(answer.id)} - /> - - - - - {answer.body} - - {canShowResults ? ( - - {votesByAnswer[answer.id].length}{' '} - {pluralize(votesByAnswer[answer.id].length, 'vote')} - - ) : null} - - - {canShowResults ? ( - - ) : null} - - + { + // @ts-expect-error this is allowed according to one of the function overloads, but that overload is /unreachable/ type-wise + await room.client.sendEvent(room.roomId, M_POLL_RESPONSE.name as string, { + 'm.relates_to': { + event_id: mEventId, + rel_type: 'm.reference', + }, + 'm.selections': [answer.id], + 'm.poll.response': { + answers: [answer.id], + }, + 'org.matrix.msc3381.poll.response': { + answers: [answer.id], + }, + }); + }} + ownVotes={ownVotes} + canShowResults={canShowResults} + votesByAnswer={votesByAnswer} + totalVoteCount={totalVoteCount} + messageLayout={messageLayout} + /> ))} {/* TODO: allow people with redaction power level to also close polls */} {senderId === ownUserId && pollKind === 'm.poll.undisclosed' && !endedEvent ? (