Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4dbb77c
feat: crude implementation of poll event gathering
elisaado Mar 10, 2026
c689d34
feat: crude implementation for displaying polls
elisaado Mar 10, 2026
0589f7e
chore(polls): add TODOs regarding event types
elisaado Mar 10, 2026
83a836c
chore(polls): fix some linting issues
elisaado Mar 10, 2026
cdb8276
Merge branch 'cinnyapp:dev' into feat-polls
elisaado Mar 11, 2026
410e21a
chore(polls): clean up design a bit
elisaado Mar 11, 2026
c1894cd
chore(polls): clean up and improve types
elisaado Mar 14, 2026
eb4973e
Merge branch 'cinnyapp:dev' into feat-polls
elisaado Mar 14, 2026
9edd322
feat(polls): add undisclosed poll functionality
elisaado Mar 14, 2026
ed0badd
feat(polls): Add poll end button and modal
elisaado Mar 14, 2026
b88b498
feat(polls): allow voting! (dirty bad impl)
elisaado Mar 15, 2026
86ed2d9
feat(polls): actually hide results for undisclosed polls
elisaado Mar 15, 2026
7abb40c
Merge branch 'cinnyapp:dev' into feat-polls
elisaado Mar 15, 2026
a1e32a5
chore(polls): move poll component into own file, show edit, disallow …
elisaado Mar 15, 2026
914cdf6
feat(polls): Implement poll ending
elisaado Mar 16, 2026
a9ef82c
chore(polls): fix some linting errors
elisaado Mar 16, 2026
786770e
chore(polls): clean up code a little
elisaado Mar 16, 2026
a8a30c5
Merge branch 'cinnyapp:dev' into feat-polls
elisaado Mar 24, 2026
e2032b5
chore(polls): refactor pollAnswer into component
elisaado Mar 26, 2026
3c4e6e2
Merge branch 'feat-polls' of github.com:elisaado/cinny into feat-polls
elisaado Mar 26, 2026
a4f4a17
Merge branch 'cinnyapp:dev' into feat-polls
elisaado Mar 26, 2026
c616819
Merge branch 'cinnyapp:dev' into feat-polls
elisaado Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/app/components/message/poll/EndPoll.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React from 'react';
import FocusTrap from 'focus-trap-react';
import {
as,
Box,
Button,
config,
Header,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
import { M_POLL_END, Room } from 'matrix-js-sdk';
import { stopPropagation } from '../../../utils/keyboard';

export const EndPollModal = as<
'div',
{
room: Room;
eventId: string;
open: boolean;
answers: {
id: string;
body: string;
}[];
votesByAnswer: Record<
string,
{
eventId: string | undefined;
userId: string | undefined;
}[]
>;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
>(({ room, votesByAnswer, answers, eventId, open, setOpen }, ref) => {
// TODO: handle multiple winners
const [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]
);

const winningAnswer = answers.find((x) => x.id === winnerID);

// TODO: implement sending actual poll end event
return (
<Overlay ref={ref} open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="300" style={{ height: 'fit-content' }}>
<Header
size="600"
style={{ padding: `0 ${config.space.S500}`, marginTop: config.space.S100 }}
>
<Text size="H4" truncate>
End poll
</Text>
</Header>
<Box
direction="Column"
gap="500"
style={{ padding: `0 ${config.space.S500} ${config.space.S500}` }}
>
<Text size="T300">
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.
</Text>

<Box direction="Row" gap="500" style={{ width: '100%' }}>
<Button
variant="Secondary"
fill="Soft"
onClick={() => setOpen(false)}
style={{ width: '100%' }}
>
<Text size="B400">Cancel</Text>
</Button>
<Button
variant="Primary"
fill="Soft"
onClick={async () => {
await room.client.sendEvent(room.roomId, M_POLL_END.name, {
'm.poll.end': {},
'org.matrix.msc3381.poll.end': {},
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.text': winningAnswer
? `The poll has ended. The winner was ${winningAnswer} with ${winnerVotes} votes`
: 'The poll has ended. There was no winner.',
});
setOpen(false);
}}
style={{ width: '100%' }}
>
<Text size="B400">End poll</Text>
</Button>
</Box>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
});
194 changes: 194 additions & 0 deletions src/app/components/message/poll/Poll.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { Box, Button, config, Line, ProgressBar, RadioButton, Text } from 'folds';
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';
import { EndPollModal } from './EndPoll';

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<HTMLInputElement> | undefined;
ownVotes: string[];
canShowResults: boolean;
votesByAnswer: { [k: string]: { eventId: string | undefined; userId: string | undefined }[] };
totalVoteCount: number;
messageLayout: MessageLayout;
}) {
return (
<Box key={answer.id} direction="Row" gap="300" justifyItems="Center">
<Box direction="Row" alignItems="Center">
<RadioButton
size="50"
disabled={!!endedEvent}
onClick={onClick}
checked={(ownVotes || []).includes(answer.id)}
/>
</Box>
<Box direction="Column" grow="Yes" gap="200">
<Box direction="Row" gap="200" alignItems="Center" style={{ width: '100%' }}>
<Box
grow="Yes"
display="InlineFlex"
direction="Row"
gap="200"
alignItems="Center"
justifyItems="Stretch"
justifyContent="Stretch"
>
<Text align="Left">{answer.body}</Text>
</Box>
{canShowResults ? (
<Text align="Right">
{votesByAnswer[answer.id].length} {pluralize(votesByAnswer[answer.id].length, 'vote')}
</Text>
) : null}
</Box>

{canShowResults ? (
<ProgressBar
style={{ width: '100%' }}
as="div"
variant={(ownVotes || []).includes(answer.id) ? 'Primary' : 'Secondary'}
max={totalVoteCount}
value={votesByAnswer[answer.id].length}
fill="Soft"
min={0}
outlined={messageLayout === MessageLayout.Bubble}
/>
) : null}
</Box>
</Box>
);
}

export function Poll({
messageLayout,
pollKind,
endedEvent,
totalVoteCount,
// TODO: make buttons checkboxes when >1 votes are allowed
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
allowedVotes,
title,
answers,
room,
mEventId,
ownVotes,
canShowResults,
votesByAnswer,
senderId,
ownUserId,
edited,
}: {
messageLayout: MessageLayout;
pollKind: string;
endedEvent: MatrixEvent | undefined;
totalVoteCount: number;
allowedVotes: number;
title: string;
answers: { id: string; body: string }[];
room: Room;
mEventId: string;
ownVotes: string[];
canShowResults: boolean;
votesByAnswer: { [k: string]: { eventId: string | undefined; userId: string | undefined }[] };
senderId: string;
ownUserId: string | null;
edited: boolean;
}) {
const [openEndPollModal, setOpenEndPollModal] = useState(false);

return (
// TODO: stop abusing the Attachment elements
<Attachment outlined={messageLayout === MessageLayout.Bubble}>
<Box
alignItems="Center"
style={{
padding: config.space.S300,
}}
>
<Box grow="Yes">
<Text size="T300">
{pollKind === 'm.poll.disclosed' ? 'Poll' : 'Undisclosed poll'}
{endedEvent ? ' (ended)' : ''}
</Text>
</Box>

{/* TODO: make this a hyperlink that opens a dialog that shows who voted for what */}
<Box gap="200">
{edited ? <Text size="C400">(Edited)</Text> : null}
<Text size="C400">
{totalVoteCount} {pluralize(totalVoteCount, 'vote')}
</Text>
</Box>
</Box>
<AttachmentBox>
<AttachmentContent>
<Box gap="300" direction="Column">
<Text size="H5">{title}</Text>
<Line />
{answers.map((answer) => (
<PollAnswer
key={answer.id}
answer={answer}
endedEvent={endedEvent}
onClick={async () => {
// @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 ? (
<>
<Line />
<Button onClick={() => setOpenEndPollModal(true)}>
<Text size="B400">End poll</Text>
</Button>
<EndPollModal
room={room}
eventId={mEventId}
open={openEndPollModal}
answers={answers}
votesByAnswer={votesByAnswer}
setOpen={setOpenEndPollModal}
/>
</>
) : null}
</Box>
</AttachmentContent>
</AttachmentBox>
</Attachment>
);
}
Loading
Loading