Skip to content
Permalink
Browse files

Add polls (#10111)

* Add polls

Fix #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
  • Loading branch information...
Gargron committed Mar 3, 2019
1 parent 99dc212 commit 230a012f0090c496fc5cdb011bcc8ed732fd0f5c
Showing with 1,038 additions and 19 deletions.
  1. +29 −0 app/controllers/api/v1/polls/votes_controller.rb
  2. +13 −0 app/controllers/api/v1/polls_controller.rb
  3. +16 −2 app/controllers/api/v1/statuses_controller.rb
  4. +13 −6 app/javascript/mastodon/actions/importer/index.js
  5. +4 −0 app/javascript/mastodon/actions/importer/normalizer.js
  6. +53 −0 app/javascript/mastodon/actions/polls.js
  7. +144 −0 app/javascript/mastodon/components/poll.js
  8. +4 −1 app/javascript/mastodon/components/status.js
  9. +4 −2 app/javascript/mastodon/containers/media_container.js
  10. +8 −0 app/javascript/mastodon/containers/poll_container.js
  11. +4 −1 app/javascript/mastodon/features/status/components/detailed_status.js
  12. +2 −0 app/javascript/mastodon/reducers/index.js
  13. +19 −0 app/javascript/mastodon/reducers/polls.js
  14. +1 −0 app/javascript/styles/application.scss
  15. +4 −0 app/javascript/styles/mastodon/components.scss
  16. +95 −0 app/javascript/styles/mastodon/polls.scss
  17. +1 −1 app/lib/activitypub/activity.rb
  18. +37 −1 app/lib/activitypub/activity/create.rb
  19. +1 −0 app/models/concerns/account_associations.rb
  20. +90 −0 app/models/poll.rb
  21. +29 −0 app/models/poll_vote.rb
  22. +11 −0 app/models/status.rb
  23. +7 −0 app/policies/poll_policy.rb
  24. +64 −1 app/serializers/activitypub/note_serializer.rb
  25. +48 −0 app/serializers/activitypub/vote_serializer.rb
  26. +38 −0 app/serializers/rest/poll_serializer.rb
  27. +1 −0 app/serializers/rest/status_serializer.rb
  28. +51 −0 app/services/activitypub/fetch_remote_poll_service.rb
  29. +10 −1 app/services/post_status_service.rb
  30. +40 −0 app/services/vote_service.rb
  31. +19 −0 app/validators/poll_validator.rb
  32. +13 −0 app/validators/vote_validator.rb
  33. +3 −1 app/views/stream_entries/_detailed_status.html.haml
  34. +3 −1 app/views/stream_entries/_simple_status.html.haml
  35. +10 −0 config/locales/en.yml
  36. +4 −0 config/routes.rb
  37. +17 −0 db/migrate/20190225031541_create_polls.rb
  38. +11 −0 db/migrate/20190225031625_create_poll_votes.rb
  39. +5 −0 db/migrate/20190226003449_add_poll_id_to_statuses.rb
  40. +32 −1 db/schema.rb
  41. 0 spec/controllers/api/v1/{filter_controller_spec.rb → filters_controller_spec.rb}
  42. +34 −0 spec/controllers/api/v1/polls/votes_controller_spec.rb
  43. +23 −0 spec/controllers/api/v1/polls_controller_spec.rb
  44. +8 −0 spec/fabricators/poll_fabricator.rb
  45. +5 −0 spec/fabricators/poll_vote_fabricator.rb
  46. +5 −0 spec/models/poll_spec.rb
  47. +5 −0 spec/models/poll_vote_spec.rb
@@ -0,0 +1,29 @@
# frozen_string_literal: true

class Api::V1::Polls::VotesController < Api::BaseController
include Authorization

before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
before_action :require_user!
before_action :set_poll

respond_to :json

def create
VoteService.new.call(current_account, @poll, vote_params[:choices])
render json: @poll, serializer: REST::PollSerializer
end

private

def set_poll
@poll = Poll.attached.find(params[:poll_id])
authorize @poll.status, :show?
rescue Mastodon::NotPermittedError
raise ActiveRecord::RecordNotFound
end

def vote_params
params.permit(choices: [])
end
end
@@ -0,0 +1,13 @@
# frozen_string_literal: true

class Api::V1::PollsController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, only: :show

respond_to :json

def show
@poll = Poll.attached.find(params[:id])
ActivityPub::FetchRemotePollService.new.call(@poll, current_account) if user_signed_in? && @poll.possibly_stale?
render json: @poll, serializer: REST::PollSerializer, include_results: true
end
end
@@ -53,6 +53,7 @@ def create
visibility: status_params[:visibility],
scheduled_at: status_params[:scheduled_at],
application: doorkeeper_token.application,
poll: status_params[:poll],
idempotency: request.headers['Idempotency-Key'])

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

def status_params
params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, :scheduled_at, media_ids: [])
params.permit(
:status,
:in_reply_to_id,
:sensitive,
:spoiler_text,
:visibility,
:scheduled_at,
media_ids: [],
poll: [
:multiple,
:hide_totals,
:expires_in,
options: [],
]
)
end

def pagination_params(core_params)
@@ -1,11 +1,10 @@
// import { autoPlayGif } from '../../initial_state';
// import { putAccounts, putStatuses } from '../../storage/modifier';
import { normalizeAccount, normalizeStatus } from './normalizer';

export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
export const ACCOUNT_IMPORT = 'ACCOUNT_IMPORT';
export const ACCOUNTS_IMPORT = 'ACCOUNTS_IMPORT';
export const STATUS_IMPORT = 'STATUS_IMPORT';
export const STATUS_IMPORT = 'STATUS_IMPORT';
export const STATUSES_IMPORT = 'STATUSES_IMPORT';
export const POLLS_IMPORT = 'POLLS_IMPORT';

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

export function importPolls(polls) {
return { type: POLLS_IMPORT, polls };
}

export function importFetchedAccount(account) {
return importFetchedAccounts([account]);
}
@@ -45,7 +48,6 @@ export function importFetchedAccounts(accounts) {
}

accounts.forEach(processAccount);
//putAccounts(normalAccounts, !autoPlayGif);

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

function processStatus(status) {
pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id])));
@@ -66,12 +69,16 @@ export function importFetchedStatuses(statuses) {
if (status.reblog && status.reblog.id) {
processStatus(status.reblog);
}

if (status.poll && status.poll.id) {
pushUnique(polls, status.poll);
}
}

statuses.forEach(processStatus);
//putStatuses(normalStatuses);

dispatch(importFetchedAccounts(accounts));
dispatch(importStatuses(normalStatuses));
dispatch(importPolls(polls));
};
}
@@ -43,6 +43,10 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.reblog = status.reblog.id;
}

if (status.poll && status.poll.id) {
normalStatus.poll = status.poll.id;
}

// Only calculate these values when status first encountered
// Otherwise keep the ones already in the reducer
if (normalOldStatus) {
@@ -0,0 +1,53 @@
import api from '../api';

export const POLL_VOTE_REQUEST = 'POLL_VOTE_REQUEST';
export const POLL_VOTE_SUCCESS = 'POLL_VOTE_SUCCESS';
export const POLL_VOTE_FAIL = 'POLL_VOTE_FAIL';

export const POLL_FETCH_REQUEST = 'POLL_FETCH_REQUEST';
export const POLL_FETCH_SUCCESS = 'POLL_FETCH_SUCCESS';
export const POLL_FETCH_FAIL = 'POLL_FETCH_FAIL';

export const vote = (pollId, choices) => (dispatch, getState) => {
dispatch(voteRequest());

api(getState).post(`/api/v1/polls/${pollId}/votes`, { choices })
.then(({ data }) => dispatch(voteSuccess(data)))
.catch(err => dispatch(voteFail(err)));
};

export const fetchPoll = pollId => (dispatch, getState) => {
dispatch(fetchPollRequest());

api(getState).get(`/api/v1/polls/${pollId}`)
.then(({ data }) => dispatch(fetchPollSuccess(data)))
.catch(err => dispatch(fetchPollFail(err)));
};

export const voteRequest = () => ({
type: POLL_VOTE_REQUEST,
});

export const voteSuccess = poll => ({
type: POLL_VOTE_SUCCESS,
poll,
});

export const voteFail = error => ({
type: POLL_VOTE_FAIL,
error,
});

export const fetchPollRequest = () => ({
type: POLL_FETCH_REQUEST,
});

export const fetchPollSuccess = poll => ({
type: POLL_FETCH_SUCCESS,
poll,
});

export const fetchPollFail = error => ({
type: POLL_FETCH_FAIL,
error,
});
@@ -0,0 +1,144 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { vote, fetchPoll } from 'mastodon/actions/polls';
import Motion from 'mastodon/features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring';

const messages = defineMessages({
moments: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
seconds: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
minutes: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
hours: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
days: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
});

const SECOND = 1000;
const MINUTE = 1000 * 60;
const HOUR = 1000 * 60 * 60;
const DAY = 1000 * 60 * 60 * 24;

const timeRemainingString = (intl, date, now) => {
const delta = date.getTime() - now;

let relativeTime;

if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(messages.moments);
} else if (delta < MINUTE) {
relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
} else if (delta < HOUR) {
relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
} else if (delta < DAY) {
relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
} else {
relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
}

return relativeTime;
};

export default @injectIntl
class Poll extends ImmutablePureComponent {

static propTypes = {
poll: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func,
disabled: PropTypes.bool,
};

state = {
selected: {},
};

handleOptionChange = e => {
const { target: { value } } = e;

if (this.props.poll.get('multiple')) {
const tmp = { ...this.state.selected };
tmp[value] = true;
this.setState({ selected: tmp });
} else {
const tmp = {};
tmp[value] = true;
this.setState({ selected: tmp });
}
};

handleVote = () => {
if (this.props.disabled) {
return;
}

this.props.dispatch(vote(this.props.poll.get('id'), Object.keys(this.state.selected)));
};

handleRefresh = () => {
if (this.props.disabled) {
return;
}

this.props.dispatch(fetchPoll(this.props.poll.get('id')));
};

renderOption (option, optionIndex) {
const { poll } = this.props;
const percent = (option.get('votes_count') / poll.get('votes_count')) * 100;
const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count'));
const active = !!this.state.selected[`${optionIndex}`];
const showResults = poll.get('voted') || poll.get('expired');

return (
<li key={option.get('title')}>
{showResults && (
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
{({ width }) =>
<span className={classNames('poll__chart', { leading })} style={{ width: `${width}%` }} />
}
</Motion>
)}

<label className={classNames('poll__text', { selectable: !showResults })}>
<input
name='vote-options'
type={poll.get('multiple') ? 'checkbox' : 'radio'}
value={optionIndex}
checked={active}
onChange={this.handleOptionChange}
/>

{!showResults && <span className={classNames('poll__input', { active })} />}
{showResults && <span className='poll__number'>{Math.floor(percent)}%</span>}

{option.get('title')}
</label>
</li>
);
}

render () {
const { poll, intl } = this.props;
const timeRemaining = timeRemainingString(intl, new Date(poll.get('expires_at')), intl.now());
const showResults = poll.get('voted') || poll.get('expired');
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);

return (
<div className='poll'>
<ul>
{poll.get('options').map((option, i) => this.renderOption(option, i))}
</ul>

<div className='poll__footer'>
{!showResults && <button className='button button-secondary' disabled={disabled} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
{showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>}
<FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.get('votes_count') }} /> · {timeRemaining}
</div>
</div>
);
}

}
@@ -16,6 +16,7 @@ import { MediaGallery, Video } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import PollContainer from 'mastodon/containers/poll_container';

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

if (status.get('media_attachments').size > 0) {
if (status.get('poll')) {
media = <PollContainer pollId={status.get('poll')} />;
} else if (status.get('media_attachments').size > 0) {
if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
media = (
<AttachmentList
@@ -6,14 +6,15 @@ import { getLocale } from '../locales';
import MediaGallery from '../components/media_gallery';
import Video from '../features/video';
import Card from '../features/status/components/card';
import Poll from 'mastodon/components/poll';
import ModalRoot from '../components/modal_root';
import MediaModal from '../features/ui/components/media_modal';
import { List as ImmutableList, fromJS } from 'immutable';

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

const MEDIA_COMPONENTS = { MediaGallery, Video, Card };
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll };

export default class MediaContainer extends PureComponent {

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

Object.assign(props, {
...(media ? { media: fromJS(media) } : {}),
...(card ? { card: fromJS(card) } : {}),
...(poll ? { poll: fromJS(poll) } : {}),

...(componentName === 'Video' ? {
onOpenVideo: this.handleOpenVideo,
Oops, something went wrong.

0 comments on commit 230a012

Please sign in to comment.
You can’t perform that action at this time.