Skip to content

Commit 4254ae5

Browse files
authored
Add polls (mastodon#10111)
* Add polls Fix mastodon#1629 * Add tests * Fixes * Change API for creating polls * Use name instead of content for votes * Remove poll validation for remote polls * Add polls to public pages * When updating the poll, update options just in case they were changed * Fix public pages showing both poll and other media
1 parent 9102575 commit 4254ae5

47 files changed

Lines changed: 1038 additions & 19 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# frozen_string_literal: true
2+
3+
class Api::V1::Polls::VotesController < Api::BaseController
4+
include Authorization
5+
6+
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
7+
before_action :require_user!
8+
before_action :set_poll
9+
10+
respond_to :json
11+
12+
def create
13+
VoteService.new.call(current_account, @poll, vote_params[:choices])
14+
render json: @poll, serializer: REST::PollSerializer
15+
end
16+
17+
private
18+
19+
def set_poll
20+
@poll = Poll.attached.find(params[:poll_id])
21+
authorize @poll.status, :show?
22+
rescue Mastodon::NotPermittedError
23+
raise ActiveRecord::RecordNotFound
24+
end
25+
26+
def vote_params
27+
params.permit(choices: [])
28+
end
29+
end
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
3+
class Api::V1::PollsController < Api::BaseController
4+
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, only: :show
5+
6+
respond_to :json
7+
8+
def show
9+
@poll = Poll.attached.find(params[:id])
10+
ActivityPub::FetchRemotePollService.new.call(@poll, current_account) if user_signed_in? && @poll.possibly_stale?
11+
render json: @poll, serializer: REST::PollSerializer, include_results: true
12+
end
13+
end

app/controllers/api/v1/statuses_controller.rb

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def create
5353
visibility: status_params[:visibility],
5454
scheduled_at: status_params[:scheduled_at],
5555
application: doorkeeper_token.application,
56+
poll: status_params[:poll],
5657
idempotency: request.headers['Idempotency-Key'])
5758

5859
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
@@ -73,12 +74,25 @@ def set_status
7374
@status = Status.find(params[:id])
7475
authorize @status, :show?
7576
rescue Mastodon::NotPermittedError
76-
# Reraise in order to get a 404 instead of a 403 error code
7777
raise ActiveRecord::RecordNotFound
7878
end
7979

8080
def status_params
81-
params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, :scheduled_at, media_ids: [])
81+
params.permit(
82+
:status,
83+
:in_reply_to_id,
84+
:sensitive,
85+
:spoiler_text,
86+
:visibility,
87+
:scheduled_at,
88+
media_ids: [],
89+
poll: [
90+
:multiple,
91+
:hide_totals,
92+
:expires_in,
93+
options: [],
94+
]
95+
)
8296
end
8397

8498
def pagination_params(core_params)

app/javascript/mastodon/actions/importer/index.js

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
// import { autoPlayGif } from '../../initial_state';
2-
// import { putAccounts, putStatuses } from '../../storage/modifier';
31
import { normalizeAccount, normalizeStatus } from './normalizer';
42

5-
export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
3+
export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
64
export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
7-
export const STATUS_IMPORT = 'STATUS_IMPORT';
5+
export const STATUS_IMPORT = 'STATUS_IMPORT';
86
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
7+
export const POLLS_IMPORT = 'POLLS_IMPORT';
98

109
function pushUnique(array, object) {
1110
if (array.every(element => element.id !== object.id)) {
@@ -29,6 +28,10 @@ export function importStatuses(statuses) {
2928
return { type: STATUSES_IMPORT, statuses };
3029
}
3130

31+
export function importPolls(polls) {
32+
return { type: POLLS_IMPORT, polls };
33+
}
34+
3235
export function importFetchedAccount(account) {
3336
return importFetchedAccounts([account]);
3437
}
@@ -45,7 +48,6 @@ export function importFetchedAccounts(accounts) {
4548
}
4649

4750
accounts.forEach(processAccount);
48-
//putAccounts(normalAccounts, !autoPlayGif);
4951

5052
return importAccounts(normalAccounts);
5153
}
@@ -58,6 +60,7 @@ export function importFetchedStatuses(statuses) {
5860
return (dispatch, getState) => {
5961
const accounts = [];
6062
const normalStatuses = [];
63+
const polls = [];
6164

6265
function processStatus(status) {
6366
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id])));
@@ -66,12 +69,16 @@ export function importFetchedStatuses(statuses) {
6669
if (status.reblog && status.reblog.id) {
6770
processStatus(status.reblog);
6871
}
72+
73+
if (status.poll && status.poll.id) {
74+
pushUnique(polls, status.poll);
75+
}
6976
}
7077

7178
statuses.forEach(processStatus);
72-
//putStatuses(normalStatuses);
7379

7480
dispatch(importFetchedAccounts(accounts));
7581
dispatch(importStatuses(normalStatuses));
82+
dispatch(importPolls(polls));
7683
};
7784
}

app/javascript/mastodon/actions/importer/normalizer.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export function normalizeStatus(status, normalOldStatus) {
4343
normalStatus.reblog = status.reblog.id;
4444
}
4545

46+
if (status.poll && status.poll.id) {
47+
normalStatus.poll = status.poll.id;
48+
}
49+
4650
// Only calculate these values when status first encountered
4751
// Otherwise keep the ones already in the reducer
4852
if (normalOldStatus) {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import api from '../api';
2+
3+
export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';
4+
export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS';
5+
export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL';
6+
7+
export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST';
8+
export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS';
9+
export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL';
10+
11+
export const vote = (pollId, choices) => (dispatch, getState) => {
12+
dispatch(voteRequest());
13+
14+
api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices })
15+
.then(({ data }) => dispatch(voteSuccess(data)))
16+
.catch(err => dispatch(voteFail(err)));
17+
};
18+
19+
export const fetchPoll = pollId => (dispatch, getState) => {
20+
dispatch(fetchPollRequest());
21+
22+
api(getState).get(`/api/v1/polls/${pollId}`)
23+
.then(({ data }) => dispatch(fetchPollSuccess(data)))
24+
.catch(err => dispatch(fetchPollFail(err)));
25+
};
26+
27+
export const voteRequest = () => ({
28+
type: POLL_VOTE_REQUEST,
29+
});
30+
31+
export const voteSuccess = poll => ({
32+
type: POLL_VOTE_SUCCESS,
33+
poll,
34+
});
35+
36+
export const voteFail = error => ({
37+
type: POLL_VOTE_FAIL,
38+
error,
39+
});
40+
41+
export const fetchPollRequest = () => ({
42+
type: POLL_FETCH_REQUEST,
43+
});
44+
45+
export const fetchPollSuccess = poll => ({
46+
type: POLL_FETCH_SUCCESS,
47+
poll,
48+
});
49+
50+
export const fetchPollFail = error => ({
51+
type: POLL_FETCH_FAIL,
52+
error,
53+
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import ImmutablePropTypes from 'react-immutable-proptypes';
4+
import ImmutablePureComponent from 'react-immutable-pure-component';
5+
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
6+
import classNames from 'classnames';
7+
import { vote, fetchPoll } from 'mastodon/actions/polls';
8+
import Motion from 'mastodon/features/ui/util/optional_motion';
9+
import spring from 'react-motion/lib/spring';
10+
11+
const messages = defineMessages({
12+
moments: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
13+
seconds: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
14+
minutes: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
15+
hours: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
16+
days: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
17+
});
18+
19+
const SECOND = 1000;
20+
const MINUTE = 1000 * 60;
21+
const HOUR = 1000 * 60 * 60;
22+
const DAY = 1000 * 60 * 60 * 24;
23+
24+
const timeRemainingString = (intl, date, now) => {
25+
const delta = date.getTime() - now;
26+
27+
let relativeTime;
28+
29+
if (delta < 10 * SECOND) {
30+
relativeTime = intl.formatMessage(messages.moments);
31+
} else if (delta < MINUTE) {
32+
relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
33+
} else if (delta < HOUR) {
34+
relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
35+
} else if (delta < DAY) {
36+
relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
37+
} else {
38+
relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
39+
}
40+
41+
return relativeTime;
42+
};
43+
44+
export default @injectIntl
45+
class Poll extends ImmutablePureComponent {
46+
47+
static propTypes = {
48+
poll: ImmutablePropTypes.map.isRequired,
49+
intl: PropTypes.object.isRequired,
50+
dispatch: PropTypes.func,
51+
disabled: PropTypes.bool,
52+
};
53+
54+
state = {
55+
selected: {},
56+
};
57+
58+
handleOptionChange = e => {
59+
const { target: { value } } = e;
60+
61+
if (this.props.poll.get('multiple')) {
62+
const tmp = { ...this.state.selected };
63+
tmp[value] = true;
64+
this.setState({ selected: tmp });
65+
} else {
66+
const tmp = {};
67+
tmp[value] = true;
68+
this.setState({ selected: tmp });
69+
}
70+
};
71+
72+
handleVote = () => {
73+
if (this.props.disabled) {
74+
return;
75+
}
76+
77+
this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected)));
78+
};
79+
80+
handleRefresh = () => {
81+
if (this.props.disabled) {
82+
return;
83+
}
84+
85+
this.props.dispatch(fetchPoll(this.props.poll.get('id')));
86+
};
87+
88+
renderOption (option, optionIndex) {
89+
const { poll } = this.props;
90+
const percent = (option.get('votes_count') / poll.get('votes_count')) * 100;
91+
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count'));
92+
const active = !!this.state.selected[`${optionIndex}`];
93+
const showResults = poll.get('voted') || poll.get('expired');
94+
95+
return (
96+
<li key={option.get('title')}>
97+
{showResults && (
98+
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
99+
{({ width }) =>
100+
<span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} />
101+
}
102+
</Motion>
103+
)}
104+
105+
<label className={classNames('poll__text', { selectable: !showResults })}>
106+
<input
107+
name='vote-options'
108+
type={poll.get('multiple') ? 'checkbox' : 'radio'}
109+
value={optionIndex}
110+
checked={active}
111+
onChange={this.handleOptionChange}
112+
/>
113+
114+
{!showResults && <span className={classNames('poll__input', { active })} />}
115+
{showResults && <span className='poll__number'>{Math.floor(percent)}%</span>}
116+
117+
{option.get('title')}
118+
</label>
119+
</li>
120+
);
121+
}
122+
123+
render () {
124+
const { poll, intl } = this.props;
125+
const timeRemaining = timeRemainingString(intl, new Date(poll.get('expires_at')), intl.now());
126+
const showResults = poll.get('voted') || poll.get('expired');
127+
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
128+
129+
return (
130+
<div className='poll'>
131+
<ul>
132+
{poll.get('options').map((option, i) => this.renderOption(option, i))}
133+
</ul>
134+
135+
<div className='poll__footer'>
136+
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
137+
{showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>}
138+
<FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} /> · {timeRemaining}
139+
</div>
140+
</div>
141+
);
142+
}
143+
144+
}

app/javascript/mastodon/components/status.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { MediaGallery, Video } from '../features/ui/util/async-components';
1616
import { HotKeys } from 'react-hotkeys';
1717
import classNames from 'classnames';
1818
import Icon from 'mastodon/components/icon';
19+
import PollContainer from 'mastodon/containers/poll_container';
1920

2021
// We use the component (and not the container) since we do not want
2122
// to use the progress bar to show download progress
@@ -270,7 +271,9 @@ class Status extends ImmutablePureComponent {
270271
status = status.get('reblog');
271272
}
272273

273-
if (status.get('media_attachments').size > 0) {
274+
if (status.get('poll')) {
275+
media = <PollContainer pollId={status.get('poll')} />;
276+
} else if (status.get('media_attachments').size > 0) {
274277
if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
275278
media = (
276279
<AttachmentList

app/javascript/mastodon/containers/media_container.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import { getLocale } from '../locales';
66
import MediaGallery from '../components/media_gallery';
77
import Video from '../features/video';
88
import Card from '../features/status/components/card';
9+
import Poll from 'mastodon/components/poll';
910
import ModalRoot from '../components/modal_root';
1011
import MediaModal from '../features/ui/components/media_modal';
1112
import { List as ImmutableList, fromJS } from 'immutable';
1213

1314
const { localeData, messages } = getLocale();
1415
addLocaleData(localeData);
1516

16-
const MEDIA_COMPONENTS = { MediaGallery, Video, Card };
17+
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll };
1718

1819
export default class MediaContainer extends PureComponent {
1920

@@ -54,11 +55,12 @@ export default class MediaContainer extends PureComponent {
5455
{[].map.call(components, (component, i) => {
5556
const componentName = component.getAttribute('data-component');
5657
const Component = MEDIA_COMPONENTS[componentName];
57-
const { media, card, ...props } = JSON.parse(component.getAttribute('data-props'));
58+
const { media, card, poll, ...props } = JSON.parse(component.getAttribute('data-props'));
5859

5960
Object.assign(props, {
6061
...(media ? { media: fromJS(media) } : {}),
6162
...(card ? { card: fromJS(card) } : {}),
63+
...(poll ? { poll: fromJS(poll) } : {}),
6264

6365
...(componentName === 'Video' ? {
6466
onOpenVideo: this.handleOpenVideo,

0 commit comments

Comments
 (0)