Skip to content

Commit f17ab5a

Browse files
committed
feat(polls): implement MSC3381 polls with creator dialog and timeline renderer
1 parent fcff9ef commit f17ab5a

13 files changed

Lines changed: 1252 additions & 19 deletions

File tree

.changeset/feat-polls.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
Add MSC3381 polls: create, vote on, and end polls directly in rooms (opt-in via `features.polls` in config.json).

config.json

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
{
22
"defaultHomeserver": 0,
3-
"homeserverList": ["matrix.org", "mozilla.org", "unredacted.org", "sable.moe", "kendama.moe"],
3+
"homeserverList": [
4+
"matrix.org",
5+
"mozilla.org",
6+
"unredacted.org",
7+
"sable.moe",
8+
"kendama.moe"
9+
],
410
"allowCustomHomeservers": true,
511
"elementCallUrl": null,
6-
712
"disableAccountSwitcher": false,
813
"hideUsernamePasswordFields": false,
9-
1014
"pushNotificationDetails": {
1115
"pushNotifyUrl": "https://sygnal.sable.moe/_matrix/push/v1/notify",
1216
"vapidPublicKey": "BCnS4SbHjeOaqVFW4wjt5xDt_pYIL62qMzKePfYF9fl9PQU14RieIaObh7nLR_9dQf4sykZa-CTrcjkgMIE1mcg",
1317
"webPushAppID": "moe.sable.app.sygnal"
1418
},
15-
1619
"slidingSync": {
1720
"enabled": true
1821
},
19-
2022
"featuredCommunities": {
2123
"openAsDefault": false,
2224
"spaces": [
@@ -35,11 +37,17 @@
3537
"#PrivSec.dev:arcticfoxes.net",
3638
"#disroot:aria-net.org"
3739
],
38-
"servers": ["matrixrooms.info", "mozilla.org", "unredacted.org"]
40+
"servers": [
41+
"matrixrooms.info",
42+
"mozilla.org",
43+
"unredacted.org"
44+
]
3945
},
40-
4146
"hashRouter": {
4247
"enabled": false,
4348
"basename": "/"
49+
},
50+
"features": {
51+
"polls": false
4452
}
45-
}
53+
}

src/app/features/room/RoomInput.tsx

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -137,18 +137,21 @@ import { MessageEvent } from '$types/matrix/room';
137137
import { usePowerLevelsContext } from '$hooks/usePowerLevels';
138138
import { useRoomCreators } from '$hooks/useRoomCreators';
139139
import { useRoomPermissions } from '$hooks/useRoomPermissions';
140+
import { useClientConfig } from '$hooks/useClientConfig';
140141
import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice';
141142
import {
142143
convertPerMessageProfileToBeeperFormat,
143144
getCurrentlyUsedPerMessageProfileForRoom,
144145
} from '$hooks/usePerMessageProfile';
145-
import { Microphone, Stop } from '@phosphor-icons/react';
146+
import { Microphone, Stop, ChartBarHorizontal } from '@phosphor-icons/react';
146147
import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec';
147148
import { sanitizeCustomHtml } from '$utils/sanitize';
148149
import { PKitCommandMessageHandler } from '$plugins/pluralkit-handler/PKitCommandMessageHandler';
149150
import { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler';
150151
import { SchedulePickerDialog } from './schedule-send';
151152
import * as css from './schedule-send/SchedulePickerDialog.css';
153+
import { PollCreatorDialog } from './poll';
154+
import type { PollCreatorContent } from './poll';
152155
import {
153156
getAudioMsgContent,
154157
getFileMsgContent,
@@ -364,6 +367,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
364367
);
365368
const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState<RectCords>();
366369
const [showSchedulePicker, setShowSchedulePicker] = useState(false);
370+
const [showPollCreator, setShowPollCreator] = useState(false);
371+
const clientConfig = useClientConfig();
372+
const pollsEnabled = clientConfig.features?.polls ?? false;
367373
const [silentReply, setSilentReply] = useState(!mentionInReplies);
368374
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
369375
const isEncrypted = room.hasEncryptionStateEvent();
@@ -1348,16 +1354,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
13481354
</>
13491355
}
13501356
before={
1351-
<IconButton
1352-
onClick={() => pickFile('*')}
1353-
variant="SurfaceVariant"
1354-
size="300"
1355-
radii="300"
1356-
title="Upload File"
1357-
aria-label="Upload and attach a File"
1358-
>
1359-
<Icon src={Icons.PlusCircle} />
1360-
</IconButton>
1357+
<Box alignItems="Center" gap="100">
1358+
<IconButton
1359+
onClick={() => pickFile('*')}
1360+
variant="SurfaceVariant"
1361+
size="300"
1362+
radii="300"
1363+
title="Upload File"
1364+
aria-label="Upload and attach a File"
1365+
>
1366+
<Icon src={Icons.PlusCircle} />
1367+
</IconButton>
1368+
{pollsEnabled && (
1369+
<IconButton
1370+
onClick={() => setShowPollCreator(true)}
1371+
variant="SurfaceVariant"
1372+
size="300"
1373+
radii="300"
1374+
title="Create Poll"
1375+
aria-label="Create a poll"
1376+
>
1377+
<ChartBarHorizontal size={20} />
1378+
</IconButton>
1379+
)}
1380+
</Box>
13611381
}
13621382
after={
13631383
<>
@@ -1616,6 +1636,37 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
16161636
}}
16171637
/>
16181638
)}
1639+
{showPollCreator && (
1640+
<PollCreatorDialog
1641+
onCancel={() => setShowPollCreator(false)}
1642+
onSubmit={(content: PollCreatorContent) => {
1643+
setShowPollCreator(false);
1644+
const pollKindKey = content.kind;
1645+
const eventContent: Record<string, unknown> = {
1646+
'org.matrix.msc1767.text': content.question,
1647+
'org.matrix.msc3381.poll.start': {
1648+
question: {
1649+
'org.matrix.msc1767.text': content.question,
1650+
},
1651+
kind: pollKindKey,
1652+
max_selections: content.maxSelections,
1653+
answers: content.answers.map((a) => ({
1654+
id: a.id,
1655+
'org.matrix.msc1767.text': a.text,
1656+
})),
1657+
show_voter_names: content.showVoterNames,
1658+
...(content.closesAt !== undefined ? { closes_at: content.closesAt } : {}),
1659+
},
1660+
};
1661+
(mx as any).sendEvent(roomId, 'org.matrix.msc3381.poll.start', eventContent).catch(
1662+
// unstable MSC3381 type
1663+
(err: unknown) => {
1664+
console.error('Failed to send poll:', err);
1665+
}
1666+
);
1667+
}}
1668+
/>
1669+
)}
16191670
</div>
16201671
);
16211672
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { style } from '@vanilla-extract/css';
2+
import { color, config, toRem } from 'folds';
3+
4+
export const DialogContent = style({
5+
padding: config.space.S400,
6+
minWidth: toRem(340),
7+
maxWidth: toRem(500),
8+
display: 'flex',
9+
flexDirection: 'column',
10+
gap: config.space.S300,
11+
maxHeight: `min(80vh, ${toRem(600)})`,
12+
overflowY: 'auto',
13+
});
14+
15+
export const AnswerRow = style({
16+
display: 'flex',
17+
alignItems: 'center',
18+
gap: config.space.S200,
19+
});
20+
21+
export const AnswerInput = style({
22+
flex: 1,
23+
});
24+
25+
export const KindSelector = style({
26+
display: 'flex',
27+
gap: config.space.S200,
28+
});
29+
30+
export const ExpirySelector = style({
31+
display: 'flex',
32+
flexWrap: 'wrap',
33+
gap: config.space.S100,
34+
});
35+
36+
export const DatetimeInput = style({
37+
padding: `${config.space.S100} ${config.space.S200}`,
38+
borderRadius: config.radii.R300,
39+
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
40+
background: color.SurfaceVariant.Container,
41+
color: 'inherit',
42+
fontSize: config.fontSize.T300,
43+
outline: 'none',
44+
selectors: {
45+
'&:focus': {
46+
borderColor: color.Primary.Main,
47+
},
48+
},
49+
});

0 commit comments

Comments
 (0)