Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add polls #10111

Merged
merged 9 commits into from Mar 3, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
29 changes: 29 additions & 0 deletions app/controllers/api/v1/polls/votes_controller.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
13 changes: 13 additions & 0 deletions app/controllers/api/v1/polls_controller.rb
@@ -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
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
Gargron marked this conversation as resolved.
Show resolved Hide resolved
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
@@ -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
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
@@ -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
@@ -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
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
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