Skip to content

Commit

Permalink
Add polls (mastodon#10111)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Gargron authored and hiyuki2578 committed Oct 2, 2019
1 parent 4d2c90c commit 0a2d55b
Show file tree
Hide file tree
Showing 47 changed files with 1,038 additions and 19 deletions.
29 changes: 29 additions & 0 deletions app/controllers/api/v1/polls/votes_controller.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions app/controllers/api/v1/polls_controller.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 16 additions & 2 deletions app/controllers/api/v1/statuses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
19 changes: 13 additions & 6 deletions app/javascript/mastodon/actions/importer/index.js
Original file line number Diff line number Diff line change
@@ -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)) {
Expand All @@ -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]);
}
Expand All @@ -45,7 +48,6 @@ export function importFetchedAccounts(accounts) {
}

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

return importAccounts(normalAccounts);
}
Expand All @@ -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])));
Expand All @@ -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));
};
}
4 changes: 4 additions & 0 deletions app/javascript/mastodon/actions/importer/normalizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
53 changes: 53 additions & 0 deletions app/javascript/mastodon/actions/polls.js
Original file line number Diff line number Diff line change
@@ -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,
});
144 changes: 144 additions & 0 deletions app/javascript/mastodon/components/poll.js
Original file line number Diff line number Diff line change
@@ -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>
);
}

}
5 changes: 4 additions & 1 deletion app/javascript/mastodon/components/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions app/javascript/mastodon/containers/media_container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 0a2d55b

Please sign in to comment.