diff --git a/CHANGELOG.md b/CHANGELOG.md index b7040f924..7b10adbbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ Changelog All notable changes to this project will be documented in this file. +## [2.7.4] - 2019-03-05 +### Fixed + +- Fix web UI not cleaning up notifications after block ([Gargron](https://github.com/tootsuite/mastodon/pull/10108)) +- Fix redundant HTTP requests when resolving private statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10115)) +- Fix performance of account media query ([abcang](https://github.com/tootsuite/mastodon/pull/10121)) +- Fix mention processing for unknown accounts ([ThibG](https://github.com/tootsuite/mastodon/pull/10125)) +- Fix getting started column not scrolling on short screens ([trwnh](https://github.com/tootsuite/mastodon/pull/10075)) +- Fix direct messages pagination in the web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10126)) +- Fix serialization of Announce activities ([ThibG](https://github.com/tootsuite/mastodon/pull/10129)) +- Fix home timeline perpetually reloading when empty in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10130)) +- Fix lists export ([ThibG](https://github.com/tootsuite/mastodon/pull/10136)) +- Fix edit profile page crash for suspended-then-unsuspended users ([ThibG](https://github.com/tootsuite/mastodon/pull/10178)) + ## [2.7.3] - 2019-02-23 ### Added diff --git a/Gemfile.lock b/Gemfile.lock index 2e1cfd158..bf733fb44 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -567,7 +567,7 @@ GEM rufus-scheduler (~> 3.2) sidekiq (>= 3) tilt (>= 1.4.0) - sidekiq-unique-jobs (6.0.11) + sidekiq-unique-jobs (6.0.12) concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 4.0, < 7.0) thor (~> 0) diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb new file mode 100644 index 000000000..3fa0b6a76 --- /dev/null +++ b/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 diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb new file mode 100644 index 000000000..4f4a6858d --- /dev/null +++ b/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 diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 29b420c67..f9506971a 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -53,6 +53,7 @@ class Api::V1::StatusesController < Api::BaseController 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 @@ class Api::V1::StatusesController < Api::BaseController @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) diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 59e4ae685..f0a19e332 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -63,13 +63,19 @@ module JsonLdHelper json.present? && json['id'] == uri ? json : nil end - def fetch_resource_without_id_validation(uri, on_behalf_of = nil) + def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false) build_request(uri, on_behalf_of).perform do |response| + unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error + raise Mastodon::UnexpectedResponseError, response + end return body_to_json(response.body_with_limit) if response.code == 200 end # If request failed, retry without doing it on behalf of a user return if on_behalf_of.nil? build_request(uri).perform do |response| + unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error + raise Mastodon::UnexpectedResponseError, response + end response.code == 200 ? body_to_json(response.body_with_limit) : nil end end @@ -92,6 +98,14 @@ module JsonLdHelper private + def response_successful?(response) + (200...300).cover?(response.code) + end + + def response_error_unsalvageable?(response) + (400...500).cover?(response.code) && response.code != 429 + end + def build_request(uri, on_behalf_of = nil) request = Request.new(:get, uri) request.on_behalf_of(on_behalf_of) if on_behalf_of diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index e2a303a77..1e49e4fc6 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -110,9 +110,19 @@ module StreamEntriesHelper I18n.t('statuses.content_warning', warning: status.spoiler_text) end + def poll_summary(status) + return unless status.poll + status.poll.options.map { |o| "[ ] #{o}" }.join("\n") + end + def status_description(status) components = [[media_summary(status), status_text_summary(status)].reject(&:blank?).join(' · ')] - components << status.text if status.spoiler_text.blank? + + if status.spoiler_text.blank? + components << status.text + components << poll_summary(status) + end + components.reject(&:blank?).join("\n\n") end diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 931711f4b..abadee817 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/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)) { @@ -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,11 +69,15 @@ 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(importPolls(polls)); dispatch(importFetchedAccounts(accounts)); dispatch(importStatuses(normalStatuses)); }; diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 34a4150fa..3085cd537 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -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) { diff --git a/app/javascript/mastodon/actions/polls.js b/app/javascript/mastodon/actions/polls.js new file mode 100644 index 000000000..bee4c48a6 --- /dev/null +++ b/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, +}); diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js new file mode 100644 index 000000000..182491af8 --- /dev/null +++ b/app/javascript/mastodon/components/poll.js @@ -0,0 +1,156 @@ +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' }, + closed: { id: 'poll.closed', defaultMessage: 'Closed' }, +}); + +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, + 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 }; + if (tmp[value]) { + delete tmp[value]; + } else { + 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, disabled } = 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 ( +
  • + {showResults && ( + + {({ width }) => + + } + + )} + + +
  • + ); + } + + render () { + const { poll, intl } = this.props; + + if (!poll) { + return null; + } + + const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : 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 ( +
    + + +
    + {!showResults && } + {showResults && !this.props.disabled && · } + + {poll.get('expires_at') && · {timeRemaining}} +
    +
    + ); + } + +} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 6270d3c92..e10faedf8 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -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 = ; + } else if (status.get('media_attachments').size > 0) { if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) { media = ( { 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, diff --git a/app/javascript/mastodon/containers/poll_container.js b/app/javascript/mastodon/containers/poll_container.js new file mode 100644 index 000000000..cd7216de7 --- /dev/null +++ b/app/javascript/mastodon/containers/poll_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import Poll from 'mastodon/components/poll'; + +const mapStateToProps = (state, { pollId }) => ({ + poll: state.getIn(['polls', pollId]), +}); + +export default connect(mapStateToProps)(Poll); diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js index 3ffa7a681..097f91c16 100644 --- a/app/javascript/mastodon/features/home_timeline/index.js +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -16,7 +16,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, - isPartial: state.getIn(['timelines', 'home', 'items', 0], null) === null, + isPartial: state.getIn(['timelines', 'home', 'isPartial']), }); export default @connect(mapStateToProps) diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 49bc43a7b..5cd50f055 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -14,6 +14,7 @@ import Video from '../../video'; import scheduleIdleTask from '../../ui/util/schedule_idle_task'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; +import PollContainer from 'mastodon/containers/poll_container'; export default class DetailedStatus extends ImmutablePureComponent { @@ -105,7 +106,9 @@ export default class DetailedStatus extends ImmutablePureComponent { outerStyle.height = `${this.state.height}px`; } - if (status.get('media_attachments').size > 0) { + if (status.get('poll')) { + media = ; + } else if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { media = ; } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 0f0de849f..a7e9c4d0f 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -29,6 +29,7 @@ import listAdder from './list_adder'; import filters from './filters'; import conversations from './conversations'; import suggestions from './suggestions'; +import polls from './polls'; const reducers = { dropdown_menu, @@ -61,6 +62,7 @@ const reducers = { filters, conversations, suggestions, + polls, }; export default combineReducers(reducers); diff --git a/app/javascript/mastodon/reducers/polls.js b/app/javascript/mastodon/reducers/polls.js new file mode 100644 index 000000000..53d9b1d8c --- /dev/null +++ b/app/javascript/mastodon/reducers/polls.js @@ -0,0 +1,19 @@ +import { POLL_VOTE_SUCCESS, POLL_FETCH_SUCCESS } from 'mastodon/actions/polls'; +import { POLLS_IMPORT } from 'mastodon/actions/importer'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const importPolls = (state, polls) => state.withMutations(map => polls.forEach(poll => map.set(poll.id, fromJS(poll)))); + +const initialState = ImmutableMap(); + +export default function polls(state = initialState, action) { + switch(action.type) { + case POLLS_IMPORT: + return importPolls(state, action.polls); + case POLL_VOTE_SUCCESS: + case POLL_FETCH_SUCCESS: + return importPolls(state, [action.poll]); + default: + return state; + } +} diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index 1f7ece812..38af9cd09 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -29,6 +29,8 @@ const initialTimeline = ImmutableMap({ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent) => { return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { mMap.set('isLoading', false); + mMap.set('isPartial', isPartial); + if (!next && !isLoadingRecent) mMap.set('hasMore', false); if (!statuses.isEmpty()) { diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss index 4bce74187..6db3bc3dc 100644 --- a/app/javascript/styles/application.scss +++ b/app/javascript/styles/application.scss @@ -16,6 +16,7 @@ @import 'mastodon/stream_entries'; @import 'mastodon/boost'; @import 'mastodon/components'; +@import 'mastodon/polls'; @import 'mastodon/introduction'; @import 'mastodon/modal'; @import 'mastodon/emoji_picker'; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 5eef07a6e..cec59eb1a 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -105,6 +105,10 @@ border-color: lighten($ui-primary-color, 4%); color: lighten($darker-text-color, 4%); } + + &:disabled { + opacity: 0.5; + } } &.button--block { diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss new file mode 100644 index 000000000..7c6e61d63 --- /dev/null +++ b/app/javascript/styles/mastodon/polls.scss @@ -0,0 +1,100 @@ +.poll { + margin-top: 16px; + font-size: 14px; + + li { + margin-bottom: 10px; + position: relative; + } + + &__chart { + position: absolute; + top: 0; + left: 0; + height: 100%; + display: inline-block; + border-radius: 4px; + background: darken($ui-primary-color, 14%); + + &.leading { + background: $ui-highlight-color; + } + } + + &__text { + position: relative; + display: inline-block; + padding: 6px 0; + line-height: 18px; + cursor: default; + + input[type=radio], + input[type=checkbox] { + display: none; + } + + &.selectable { + cursor: pointer; + } + } + + &__input { + display: inline-block; + position: relative; + border: 1px solid $ui-primary-color; + box-sizing: border-box; + width: 18px; + height: 18px; + margin-right: 10px; + top: -1px; + border-radius: 50%; + vertical-align: middle; + + &.checkbox { + border-radius: 4px; + } + + &.active { + border-color: $valid-value-color; + background: $valid-value-color; + } + } + + &__number { + display: inline-block; + width: 36px; + font-weight: 700; + padding: 0 10px; + text-align: right; + } + + &__footer { + padding-top: 6px; + padding-bottom: 5px; + color: $dark-text-color; + } + + &__link { + display: inline; + background: transparent; + padding: 0; + margin: 0; + border: 0; + color: $dark-text-color; + text-decoration: underline; + font-size: inherit; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + + .button { + height: 36px; + padding: 0 16px; + margin-right: 10px; + font-size: 14px; + } +} diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 11fa3363a..54b175613 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -4,7 +4,7 @@ class ActivityPub::Activity include JsonLdHelper include Redisable - SUPPORTED_TYPES = %w(Note).freeze + SUPPORTED_TYPES = %w(Note Question).freeze CONVERTED_TYPES = %w(Image Video Article Page).freeze def initialize(json, account, **options) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 0980f94ba..07ef16bf3 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -6,7 +6,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity RedisLock.acquire(lock_options) do |lock| if lock.acquired? - return if delete_arrived_first?(object_uri) + return if delete_arrived_first?(object_uri) || poll_vote? @status = find_existing_status @@ -68,6 +68,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity thread: replied_to_status, conversation: conversation_from_uri(@object['conversation']), media_attachment_ids: process_attachments.take(4).map(&:id), + owned_poll: process_poll, } end end @@ -209,6 +210,40 @@ class ActivityPub::Activity::Create < ActivityPub::Activity media_attachments end + def process_poll + return unless @object['type'] == 'Question' && (@object['anyOf'].is_a?(Array) || @object['oneOf'].is_a?(Array)) + + expires_at = begin + if @object['closed'].is_a?(String) + @object['closed'] + elsif !@object['closed'].nil? && !@object['closed'].is_a?(FalseClass) + Time.now.utc + else + @object['endTime'] + end + end + + if @object['anyOf'].is_a?(Array) + multiple = true + items = @object['anyOf'] + else + multiple = false + items = @object['oneOf'] + end + + @account.polls.new( + multiple: multiple, + expires_at: expires_at, + options: items.map { |item| item['name'].presence || item['content'] }, + cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 } + ) + end + + def poll_vote? + return false if replied_to_status.nil? || replied_to_status.poll.nil? || !replied_to_status.local? || !replied_to_status.poll.options.include?(@object['name']) + replied_to_status.poll.votes.create!(account: @account, choice: replied_to_status.poll.options.index(@object['name']), uri: @object['id']) + end + def resolve_thread(status) return unless status.reply? && status.thread.nil? && Request.valid_url?(in_reply_to_uri) ThreadResolveWorker.perform_async(status.id, in_reply_to_uri) diff --git a/app/models/account.rb b/app/models/account.rb index 0c08c0991..bf2a17a7f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -245,6 +245,7 @@ class Account < ApplicationRecord def fields_attributes=(attributes) fields = [] old_fields = self[:fields] || [] + old_fields = [] if old_fields.is_a?(Hash) if attributes.is_a?(Hash) attributes.each_value do |attr| diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index 3ab8a0daa..1b22f750c 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -27,6 +27,7 @@ module AccountAssociations # Media has_many :media_attachments, dependent: :destroy + has_many :polls, dependent: :destroy # PuSH subscriptions has_many :subscriptions, dependent: :destroy diff --git a/app/models/export.rb b/app/models/export.rb index fc4bb6964..9bf866d35 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -23,7 +23,7 @@ class Export def to_lists_csv CSV.generate do |csv| - account.owned_lists.select(:title).each do |list| + account.owned_lists.select(:title, :id).each do |list| list.accounts.select(:username, :domain).each do |account| csv << [list.title, acct(account)] end diff --git a/app/models/featured_tag.rb b/app/models/featured_tag.rb index b5a10ad2d..d06ae26a8 100644 --- a/app/models/featured_tag.rb +++ b/app/models/featured_tag.rb @@ -18,11 +18,12 @@ class FeaturedTag < ApplicationRecord delegate :name, to: :tag, allow_nil: true - validates :name, presence: true + validates_associated :tag, on: :create + validates :name, presence: true, on: :create validate :validate_featured_tags_limit, on: :create def name=(str) - self.tag = Tag.find_or_initialize_by(name: str.delete('#').mb_chars.downcase.to_s) + self.tag = Tag.find_or_initialize_by(name: str.strip.delete('#').mb_chars.downcase.to_s) end def increment(timestamp) diff --git a/app/models/poll.rb b/app/models/poll.rb new file mode 100644 index 000000000..ab7236d45 --- /dev/null +++ b/app/models/poll.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: polls +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# status_id :bigint(8) +# expires_at :datetime +# options :string default([]), not null, is an Array +# cached_tallies :bigint(8) default([]), not null, is an Array +# multiple :boolean default(FALSE), not null +# hide_totals :boolean default(FALSE), not null +# votes_count :bigint(8) default(0), not null +# last_fetched_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# + +class Poll < ApplicationRecord + include Expireable + + belongs_to :account + belongs_to :status + + has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :destroy + + validates :options, presence: true + validates :expires_at, presence: true, if: :local? + validates_with PollValidator, if: :local? + + scope :attached, -> { where.not(status_id: nil) } + scope :unattached, -> { where(status_id: nil) } + + before_validation :prepare_options + before_validation :prepare_votes_count + + after_initialize :prepare_cached_tallies + + after_commit :reset_parent_cache, on: :update + + def loaded_options + options.map.with_index { |title, key| Option.new(self, key.to_s, title, cached_tallies[key]) } + end + + def unloaded_options + options.map.with_index { |title, key| Option.new(self, key.to_s, title, nil) } + end + + def possibly_stale? + remote? && last_fetched_before_expiration? && time_passed_since_last_fetch? + end + + delegate :local?, to: :account + + def remote? + !local? + end + + class Option < ActiveModelSerializers::Model + attributes :id, :title, :votes_count, :poll + + def initialize(poll, id, title, votes_count) + @poll = poll + @id = id + @title = title + @votes_count = votes_count + end + end + + private + + def prepare_cached_tallies + self.cached_tallies = options.map { 0 } if cached_tallies.empty? + end + + def prepare_votes_count + self.votes_count = cached_tallies.sum unless cached_tallies.empty? + end + + def prepare_options + self.options = options.map(&:strip).reject(&:blank?) + end + + def reset_parent_cache + return if status_id.nil? + Rails.cache.delete("statuses/#{status_id}") + end + + def last_fetched_before_expiration? + last_fetched_at.nil? || expires_at.nil? || last_fetched_at < expires_at + end + + def time_passed_since_last_fetch? + last_fetched_at.nil? || last_fetched_at < 1.minute.ago + end +end diff --git a/app/models/poll_vote.rb b/app/models/poll_vote.rb new file mode 100644 index 000000000..9ad66bbf8 --- /dev/null +++ b/app/models/poll_vote.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: poll_votes +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# poll_id :bigint(8) +# choice :integer default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# uri :string +# + +class PollVote < ApplicationRecord + belongs_to :account + belongs_to :poll, inverse_of: :votes + + validates :choice, presence: true + validates_with VoteValidator + + after_create_commit :increment_counter_cache + + delegate :local?, to: :account + + def object_type + :vote + end + + private + + def increment_counter_cache + poll.cached_tallies[choice] = (poll.cached_tallies[choice] || 0) + 1 + poll.save + end +end diff --git a/app/models/status.rb b/app/models/status.rb index 4566c0d20..f576489b4 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -23,6 +23,7 @@ # in_reply_to_account_id :bigint(8) # local_only :boolean # full_status_text :text default(""), not null +# poll_id :bigint(8) # class Status < ApplicationRecord @@ -46,6 +47,7 @@ class Status < ApplicationRecord belongs_to :account, inverse_of: :statuses belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true belongs_to :conversation, optional: true + belongs_to :poll, optional: true belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true @@ -64,12 +66,14 @@ class Status < ApplicationRecord has_one :notification, as: :activity, dependent: :destroy has_one :stream_entry, as: :activity, inverse_of: :status has_one :status_stat, inverse_of: :status + has_one :owned_poll, class_name: 'Poll', inverse_of: :status, dependent: :destroy validates :uri, uniqueness: true, presence: true, unless: :local? validates :text, presence: true, unless: -> { with_media? || reblog? } validates_with StatusLengthValidator validates_with DisallowedHashtagsValidator validates :reblog, uniqueness: { scope: :account }, if: :reblog? + validates_associated :owned_poll default_scope { recent } @@ -106,6 +110,7 @@ class Status < ApplicationRecord :tags, :preview_cards, :stream_entry, + :poll, account: :account_stat, active_mentions: { account: :account_stat }, reblog: [ @@ -116,6 +121,7 @@ class Status < ApplicationRecord :media_attachments, :conversation, :status_stat, + :poll, account: :account_stat, active_mentions: { account: :account_stat }, ], @@ -257,6 +263,8 @@ class Status < ApplicationRecord before_validation :set_conversation before_validation :set_local + after_create :set_poll_id + class << self def selectable_visibilities visibilities.keys - %w(direct limited) @@ -458,6 +466,10 @@ class Status < ApplicationRecord self.reblog = reblog.reblog if reblog? && reblog.reblog? end + def set_poll_id + update_column(:poll_id, owned_poll.id) unless owned_poll.nil? + end + def set_visibility self.visibility = (account.locked? ? :private : :public) if visibility.nil? self.visibility = reblog.visibility if reblog? diff --git a/app/policies/poll_policy.rb b/app/policies/poll_policy.rb new file mode 100644 index 000000000..9d69eb5bb --- /dev/null +++ b/app/policies/poll_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class PollPolicy < ApplicationPolicy + def vote? + StatusPolicy.new(current_account, record.status).show? && !current_account.blocking?(record.account) && !record.account.blocking?(current_account) + end +end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 4aab993a9..3a9e388a5 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -15,12 +15,18 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer has_one :replies, serializer: ActivityPub::CollectionSerializer, if: :local? + has_many :poll_options, key: :one_of, if: :poll_and_not_multiple? + has_many :poll_options, key: :any_of, if: :poll_and_multiple? + + attribute :end_time, if: :poll_and_expires? + attribute :closed, if: :poll_and_expired? + def id ActivityPub::TagManager.instance.uri_for(object) end def type - 'Note' + object.poll ? 'Question' : 'Note' end def summary @@ -38,6 +44,7 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer def replies replies = object.self_replies(5).pluck(:id, :uri) last_id = replies.last&.first + ActivityPub::CollectionPresenter.new( type: :unordered, id: ActivityPub::TagManager.instance.replies_uri_for(object), @@ -114,6 +121,36 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer object.account.local? end + def poll_options + if !object.poll.expired? && object.poll.hide_totals? + object.poll.unloaded_options + else + object.poll.loaded_options + end + end + + def poll_and_multiple? + object.poll&.multiple? + end + + def poll_and_not_multiple? + object.poll && !object.poll.multiple? + end + + def closed + object.poll.expires_at.iso8601 + end + + alias end_time closed + + def poll_and_expires? + object.poll&.expires_at&.present? + end + + def poll_and_expired? + object.poll&.expired? + end + class MediaAttachmentSerializer < ActiveModel::Serializer include RoutingHelper @@ -181,4 +218,34 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer class CustomEmojiSerializer < ActivityPub::EmojiSerializer end + + class OptionSerializer < ActiveModel::Serializer + class RepliesSerializer < ActiveModel::Serializer + attributes :type, :total_items + + def type + 'Collection' + end + + def total_items + object.votes_count + end + end + + attributes :type, :name + + has_one :replies, serializer: ActivityPub::NoteSerializer::OptionSerializer::RepliesSerializer + + def type + 'Note' + end + + def name + object.title + end + + def replies + object + end + end end diff --git a/app/serializers/activitypub/vote_serializer.rb b/app/serializers/activitypub/vote_serializer.rb new file mode 100644 index 000000000..248190404 --- /dev/null +++ b/app/serializers/activitypub/vote_serializer.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class ActivityPub::VoteSerializer < ActiveModel::Serializer + class NoteSerializer < ActiveModel::Serializer + attributes :id, :type, :name, :attributed_to, + :in_reply_to, :to + + def id + ActivityPub::TagManager.instance.uri_for(object) || [ActivityPub::TagManager.instance.uri_for(object.account), '#votes/', object.id].join + end + + def type + 'Note' + end + + def name + object.poll.options[object.choice.to_i] + end + + def attributed_to + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def in_reply_to + ActivityPub::TagManager.instance.uri_for(object.poll.status) + end + + def to + ActivityPub::TagManager.instance.uri_for(object.poll.account) + end + end + + attributes :id, :type, :actor, :to + + has_one :object, serializer: ActivityPub::VoteSerializer::NoteSerializer + + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#votes/', object.id, '/activity'].join + end + + def type + 'Create' + end + + def actor + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def to + ActivityPub::TagManager.instance.uri_for(object.poll.account) + end +end diff --git a/app/serializers/rest/poll_serializer.rb b/app/serializers/rest/poll_serializer.rb new file mode 100644 index 000000000..b02e8ca93 --- /dev/null +++ b/app/serializers/rest/poll_serializer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class REST::PollSerializer < ActiveModel::Serializer + attributes :id, :expires_at, :expired, + :multiple, :votes_count + + has_many :dynamic_options, key: :options + + attribute :voted, if: :current_user? + + def id + object.id.to_s + end + + def dynamic_options + if !object.expired? && object.hide_totals? + object.unloaded_options + else + object.loaded_options + end + end + + def expired + object.expired? + end + + def voted + object.votes.where(account: current_user.account).exists? + end + + def current_user? + !current_user.nil? + end + + class OptionSerializer < ActiveModel::Serializer + attributes :title, :votes_count + end +end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index b72eebb10..7185121d6 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -23,6 +23,7 @@ class REST::StatusSerializer < ActiveModel::Serializer has_many :emojis, serializer: REST::CustomEmojiSerializer has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer + has_one :poll, serializer: REST::PollSerializer def id object.id.to_s diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb new file mode 100644 index 000000000..1dd587d73 --- /dev/null +++ b/app/services/activitypub/fetch_remote_poll_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemotePollService < BaseService + include JsonLdHelper + + def call(poll, on_behalf_of = nil) + @json = fetch_resource(poll.status.uri, true, on_behalf_of) + + return unless supported_context? && expected_type? + + expires_at = begin + if @json['closed'].is_a?(String) + @json['closed'] + elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass) + Time.now.utc + else + @json['endTime'] + end + end + + items = begin + if @json['anyOf'].is_a?(Array) + @json['anyOf'] + else + @json['oneOf'] + end + end + + latest_options = items.map { |item| item['name'].presence || item['content'] } + + # If for some reasons the options were changed, it invalidates all previous + # votes, so we need to remove them + poll.votes.delete_all if latest_options != poll.options + + poll.update!( + last_fetched_at: Time.now.utc, + expires_at: expires_at, + options: latest_options, + cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 } + ) + end + + private + + def supported_context? + super(@json) + end + + def expected_type? + equals_or_includes_any?(@json['type'], %w(Question)) + end +end diff --git a/app/services/activitypub/fetch_replies_service.rb b/app/services/activitypub/fetch_replies_service.rb index 95c486a43..569d0d7c1 100644 --- a/app/services/activitypub/fetch_replies_service.rb +++ b/app/services/activitypub/fetch_replies_service.rb @@ -36,9 +36,7 @@ class ActivityPub::FetchRepliesService < BaseService return collection_or_uri if collection_or_uri.is_a?(Hash) return unless @allow_synchronous_requests return if invalid_origin?(collection_or_uri) - collection = fetch_resource_without_id_validation(collection_or_uri) - raise Mastodon::UnexpectedResponseError if collection.nil? - collection + fetch_resource_without_id_validation(collection_or_uri, nil, true) end def filtered_replies diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index cfb266fbb..8a9d26c56 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -15,6 +15,7 @@ class PostStatusService < BaseService # @option [String] :spoiler_text # @option [String] :language # @option [String] :scheduled_at + # @option [Hash] :poll Optional poll to attach # @option [Enumerable] :media_ids Optional array of media IDs to attach # @option [Doorkeeper::Application] :application # @option [String] :idempotency Optional idempotency key @@ -28,6 +29,7 @@ class PostStatusService < BaseService return idempotency_duplicate if idempotency_given? && idempotency_duplicate? validate_media! + validate_poll! preprocess_attributes! if scheduled? @@ -98,13 +100,19 @@ class PostStatusService < BaseService def validate_media! return if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable) - raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 + raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll].present? @media = @account.media_attachments.where(status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i)) raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if @media.size > 1 && @media.find(&:video?) end + def validate_poll! + return if @options[:poll].blank? + + @poll = @account.polls.new(@options[:poll]) + end + def language_from_option(str) ISO_639.find(str)&.alpha2 end @@ -157,6 +165,7 @@ class PostStatusService < BaseService text: @text, media_attachments: @media || [], thread: @in_reply_to, + owned_poll: @poll, sensitive: (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?, spoiler_text: @options[:spoiler_text] || '', visibility: @visibility, diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb index ed0c56923..b98759bf6 100644 --- a/app/services/resolve_url_service.rb +++ b/app/services/resolve_url_service.rb @@ -20,7 +20,7 @@ class ResolveURLService < BaseService def process_url if equals_or_includes_any?(type, %w(Application Group Organization Person Service)) FetchRemoteAccountService.new.call(atom_url, body, protocol) - elsif equals_or_includes_any?(type, %w(Note Article Image Video Page)) + elsif equals_or_includes_any?(type, %w(Note Article Image Video Page Question)) FetchRemoteStatusService.new.call(atom_url, body, protocol) end end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index fc3bc03a5..b2ae3a47c 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -84,7 +84,7 @@ class SuspendAccountService < BaseService @account.locked = false @account.display_name = '' @account.note = '' - @account.fields = {} + @account.fields = [] @account.statuses_count = 0 @account.followers_count = 0 @account.following_count = 0 diff --git a/app/services/vote_service.rb b/app/services/vote_service.rb new file mode 100644 index 000000000..8bab2810e --- /dev/null +++ b/app/services/vote_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class VoteService < BaseService + include Authorization + + def call(account, poll, choices) + authorize_with account, poll, :vote? + + @account = account + @poll = poll + @choices = choices + @votes = [] + + ApplicationRecord.transaction do + @choices.each do |choice| + @votes << @poll.votes.create!(account: @account, choice: choice) + end + end + + return if @poll.account.local? + + @votes.each do |vote| + ActivityPub::DeliveryWorker.perform_async( + build_json(vote), + @account.id, + @poll.account.inbox_url + ) + end + end + + private + + def build_json(vote) + ActiveModelSerializers::SerializableResource.new( + vote, + serializer: ActivityPub::VoteSerializer, + adapter: ActivityPub::Adapter + ).to_json + end +end diff --git a/app/validators/poll_validator.rb b/app/validators/poll_validator.rb new file mode 100644 index 000000000..d4ae4c16a --- /dev/null +++ b/app/validators/poll_validator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class PollValidator < ActiveModel::Validator + MAX_OPTIONS = 4 + MAX_OPTION_CHARS = 25 + MAX_EXPIRATION = 1.month.freeze + MIN_EXPIRATION = 5.minutes.freeze + + def validate(poll) + current_time = Time.now.utc + + poll.errors.add(:options, I18n.t('polls.errors.too_few_options')) unless poll.options.size > 1 + poll.errors.add(:options, I18n.t('polls.errors.too_many_options', max: MAX_OPTIONS)) if poll.options.size > MAX_OPTIONS + poll.errors.add(:options, I18n.t('polls.errors.over_character_limit', max: MAX_OPTION_CHARS)) if poll.options.any? { |option| option.mb_chars.grapheme_length > MAX_OPTION_CHARS } + poll.errors.add(:options, I18n.t('polls.errors.duplicate_options')) unless poll.options.uniq.size == poll.options.size + poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_long')) if poll.expires_at.nil? || poll.expires_at - current_time >= MAX_EXPIRATION + poll.errors.add(:expires_at, I18n.t('polls.errors.duration_too_short')) if poll.expires_at.present? && poll.expires_at - current_time <= MIN_EXPIRATION + end +end diff --git a/app/validators/vote_validator.rb b/app/validators/vote_validator.rb new file mode 100644 index 000000000..2e1818bdb --- /dev/null +++ b/app/validators/vote_validator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class VoteValidator < ActiveModel::Validator + def validate(vote) + vote.errors.add(:base, I18n.t('polls.errors.expired')) if vote.poll.expired? + + if vote.poll.multiple? && vote.poll.votes.where(account: vote.account, choice: vote.choice).exists? + vote.errors.add(:base, I18n.t('polls.errors.already_voted')) + elsif !vote.poll.multiple? && vote.poll.votes.where(account: vote.account).exists? + vote.errors.add(:base, I18n.t('polls.errors.already_voted')) + end + end +end diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index e123d657f..b19d2452a 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -22,7 +22,10 @@ %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') .e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) - - if !status.media_attachments.empty? + - if status.poll + = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do + = render partial: 'stream_entries/poll', locals: { poll: status.poll } + - elsif !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do diff --git a/app/views/stream_entries/_poll.html.haml b/app/views/stream_entries/_poll.html.haml new file mode 100644 index 000000000..c7e5e0c63 --- /dev/null +++ b/app/views/stream_entries/_poll.html.haml @@ -0,0 +1,29 @@ +- options = (!poll.expired? && poll.hide_totals?) ? poll.unloaded_options : poll.loaded_options +- voted = user_signed_in? && poll.votes.where(account: current_account).exists? +- show_results = voted || poll.expired? + +.poll + %ul + - options.each do |option| + %li + - if show_results + - percent = 100 * option.votes_count / poll.votes_count + %span.poll__chart{ style: "width: #{percent}%" } + + %label.poll__text>< + %span.poll__number= percent.round + = option.title + - else + %label.poll__text>< + %span.poll__input{ class: poll.multiple? ? 'checkbox' : nil}>< + = option.title + .poll__footer + - unless show_results + %button.button.button-secondary{ disabled: true } + = t('statuses.poll.vote') + + %span= t('statuses.poll.total_votes', count: poll.votes_count) + + - unless poll.expires_at.nil? + · + %span= l poll.expires_at diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index 28b4e3217..d3441ca90 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -27,7 +27,10 @@ .e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }< = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) - - if !status.media_attachments.empty? + - if status.poll + = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do + = render partial: 'stream_entries/poll', locals: { poll: status.poll } + - elsif !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do diff --git a/app/workers/activitypub/fetch_replies_worker.rb b/app/workers/activitypub/fetch_replies_worker.rb index bf466db54..54d98f228 100644 --- a/app/workers/activitypub/fetch_replies_worker.rb +++ b/app/workers/activitypub/fetch_replies_worker.rb @@ -8,5 +8,7 @@ class ActivityPub::FetchRepliesWorker def perform(parent_status_id, replies_uri) ActivityPub::FetchRepliesService.new.call(Status.find(parent_status_id), replies_uri) + rescue ActiveRecord::RecordNotFound + true end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 1121ef3db..05f6243df 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -738,6 +738,16 @@ en: older: Older prev: Prev truncate: "…" + polls: + errors: + already_voted: You have already voted on this poll + duplicate_options: contain duplicate items + duration_too_long: is too far into the future + duration_too_short: is too soon + expired: The poll has already ended + over_character_limit: cannot be longer than %{MAX} characters each + too_few_options: must have more than one item + too_many_options: can't contain more than %{MAX} items preferences: languages: Languages other: Other @@ -848,6 +858,11 @@ en: ownership: Someone else's toot cannot be pinned private: Non-public toot cannot be pinned reblog: A boost cannot be pinned + poll: + total_votes: + one: "%{count} vote" + other: "%{count} votes" + vote: Vote show_more: Show more sign_in_to_participate: Sign in to participate in the conversation title: '%{name}: "%{quote}"' diff --git a/config/routes.rb b/config/routes.rb index 766825cf3..09bcf8b12 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -374,6 +374,10 @@ Rails.application.routes.draw do resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts' end + resources :polls, only: [:create, :show] do + resources :votes, only: :create, controller: 'polls/votes' + end + namespace :push do resource :subscription, only: [:create, :show, :update, :destroy] end diff --git a/db/migrate/20190225031541_create_polls.rb b/db/migrate/20190225031541_create_polls.rb new file mode 100644 index 000000000..ea9ad0425 --- /dev/null +++ b/db/migrate/20190225031541_create_polls.rb @@ -0,0 +1,17 @@ +class CreatePolls < ActiveRecord::Migration[5.2] + def change + create_table :polls do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade } + t.belongs_to :status, foreign_key: { on_delete: :cascade } + t.datetime :expires_at + t.string :options, null: false, array: true, default: [] + t.bigint :cached_tallies, null: false, array: true, default: [] + t.boolean :multiple, null: false, default: false + t.boolean :hide_totals, null: false, default: false + t.bigint :votes_count, null: false, default: 0 + t.datetime :last_fetched_at + + t.timestamps + end + end +end diff --git a/db/migrate/20190225031625_create_poll_votes.rb b/db/migrate/20190225031625_create_poll_votes.rb new file mode 100644 index 000000000..a0849d3a5 --- /dev/null +++ b/db/migrate/20190225031625_create_poll_votes.rb @@ -0,0 +1,11 @@ +class CreatePollVotes < ActiveRecord::Migration[5.2] + def change + create_table :poll_votes do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade } + t.belongs_to :poll, foreign_key: { on_delete: :cascade } + t.integer :choice, null: false, default: 0 + + t.timestamps + end + end +end diff --git a/db/migrate/20190226003449_add_poll_id_to_statuses.rb b/db/migrate/20190226003449_add_poll_id_to_statuses.rb new file mode 100644 index 000000000..692e8f814 --- /dev/null +++ b/db/migrate/20190226003449_add_poll_id_to_statuses.rb @@ -0,0 +1,5 @@ +class AddPollIdToStatuses < ActiveRecord::Migration[5.2] + def change + add_column :statuses, :poll_id, :bigint + end +end diff --git a/db/migrate/20190304152020_add_uri_to_poll_votes.rb b/db/migrate/20190304152020_add_uri_to_poll_votes.rb new file mode 100644 index 000000000..f6b81f1ba --- /dev/null +++ b/db/migrate/20190304152020_add_uri_to_poll_votes.rb @@ -0,0 +1,5 @@ +class AddUriToPollVotes < ActiveRecord::Migration[5.2] + def change + add_column :poll_votes, :uri, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 05d4deb1a..48c00511b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_02_03_180359) do +ActiveRecord::Schema.define(version: 2019_03_04_152020) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -452,6 +452,33 @@ ActiveRecord::Schema.define(version: 2019_02_03_180359) do t.index ["database", "captured_at"], name: "index_pghero_space_stats_on_database_and_captured_at" end + create_table "poll_votes", force: :cascade do |t| + t.bigint "account_id" + t.bigint "poll_id" + t.integer "choice", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "uri" + t.index ["account_id"], name: "index_poll_votes_on_account_id" + t.index ["poll_id"], name: "index_poll_votes_on_poll_id" + end + + create_table "polls", force: :cascade do |t| + t.bigint "account_id" + t.bigint "status_id" + t.datetime "expires_at" + t.string "options", default: [], null: false, array: true + t.bigint "cached_tallies", default: [], null: false, array: true + t.boolean "multiple", default: false, null: false + t.boolean "hide_totals", default: false, null: false + t.bigint "votes_count", default: 0, null: false + t.datetime "last_fetched_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_polls_on_account_id" + t.index ["status_id"], name: "index_polls_on_status_id" + end + create_table "preview_cards", force: :cascade do |t| t.string "url", default: "", null: false t.string "title", default: "", null: false @@ -593,6 +620,7 @@ ActiveRecord::Schema.define(version: 2019_02_03_180359) do t.bigint "application_id" t.bigint "in_reply_to_account_id" t.boolean "local_only" + t.bigint "poll_id" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20180106", order: { id: :desc } t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id" t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id" @@ -760,6 +788,10 @@ ActiveRecord::Schema.define(version: 2019_02_03_180359) do add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id", name: "fk_f5fc4c1ee3", on_delete: :cascade add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id", name: "fk_e84df68546", on_delete: :cascade add_foreign_key "oauth_applications", "users", column: "owner_id", name: "fk_b0988c7c0a", on_delete: :cascade + add_foreign_key "poll_votes", "accounts", on_delete: :cascade + add_foreign_key "poll_votes", "polls", on_delete: :cascade + add_foreign_key "polls", "accounts", on_delete: :cascade + add_foreign_key "polls", "statuses", on_delete: :cascade add_foreign_key "report_notes", "accounts", on_delete: :cascade add_foreign_key "report_notes", "reports", on_delete: :cascade add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index a0e32a7e0..b35dd0561 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 3 + 4 end def pre diff --git a/spec/controllers/api/v1/filter_controller_spec.rb b/spec/controllers/api/v1/filters_controller_spec.rb similarity index 100% rename from spec/controllers/api/v1/filter_controller_spec.rb rename to spec/controllers/api/v1/filters_controller_spec.rb diff --git a/spec/controllers/api/v1/polls/votes_controller_spec.rb b/spec/controllers/api/v1/polls/votes_controller_spec.rb new file mode 100644 index 000000000..0ee3aa040 --- /dev/null +++ b/spec/controllers/api/v1/polls/votes_controller_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe Api::V1::Polls::VotesController, type: :controller do + render_views + + let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:scopes) { 'write:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + + before { allow(controller).to receive(:doorkeeper_token) { token } } + + describe 'POST #create' do + let(:poll) { Fabricate(:poll) } + + before do + post :create, params: { poll_id: poll.id, choices: %w(1) } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + + it 'creates a vote' do + vote = poll.votes.where(account: user.account).first + + expect(vote).to_not be_nil + expect(vote.choice).to eq 1 + end + + it 'updates poll tallies' do + expect(poll.reload.cached_tallies).to eq [0, 1] + end + end +end diff --git a/spec/controllers/api/v1/polls_controller_spec.rb b/spec/controllers/api/v1/polls_controller_spec.rb new file mode 100644 index 000000000..2b8d5f3ef --- /dev/null +++ b/spec/controllers/api/v1/polls_controller_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe Api::V1::PollsController, type: :controller do + render_views + + let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:scopes) { 'read:statuses' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + + before { allow(controller).to receive(:doorkeeper_token) { token } } + + describe 'GET #show' do + let(:poll) { Fabricate(:poll) } + + before do + get :show, params: { id: poll.id } + end + + it 'returns http success' do + expect(response).to have_http_status(200) + end + end +end diff --git a/spec/fabricators/poll_fabricator.rb b/spec/fabricators/poll_fabricator.rb new file mode 100644 index 000000000..746610f7c --- /dev/null +++ b/spec/fabricators/poll_fabricator.rb @@ -0,0 +1,8 @@ +Fabricator(:poll) do + account + status + expires_at { 7.days.from_now } + options %w(Foo Bar) + multiple false + hide_totals false +end diff --git a/spec/fabricators/poll_vote_fabricator.rb b/spec/fabricators/poll_vote_fabricator.rb new file mode 100644 index 000000000..51f9b006e --- /dev/null +++ b/spec/fabricators/poll_vote_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:poll_vote) do + account + poll + choice 0 +end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 26cb84871..56c7bfc61 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe ActivityPub::Activity::Create do - let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers') } + let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers', domain: 'example.com', uri: 'https://example.com/actor') } let(:json) do { @@ -28,6 +28,20 @@ RSpec.describe ActivityPub::Activity::Create do subject.perform end + context 'unknown object type' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Banana', + content: 'Lorem ipsum', + } + end + + it 'does not create a status' do + expect(sender.statuses.count).to be_zero + end + end + context 'standalone' do let(:object_json) do { @@ -407,6 +421,67 @@ RSpec.describe ActivityPub::Activity::Create do expect(status).to_not be_nil end end + + context 'with poll' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Question', + content: 'Which color was the submarine?', + oneOf: [ + { + name: 'Yellow', + replies: { + type: 'Collection', + totalItems: 10, + }, + }, + { + name: 'Blue', + replies: { + type: 'Collection', + totalItems: 3, + } + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + expect(status).to_not be_nil + expect(status.poll).to_not be_nil + end + + it 'creates a poll' do + poll = sender.polls.first + expect(poll).to_not be_nil + expect(poll.status).to_not be_nil + expect(poll.options).to eq %w(Yellow Blue) + expect(poll.cached_tallies).to eq [10, 3] + end + end + + context 'when a vote to a local poll' do + let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) } + let!(:local_status) { Fabricate(:status, owned_poll: poll) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + name: 'Yellow', + inReplyTo: ActivityPub::TagManager.instance.uri_for(local_status) + } + end + + it 'adds a vote to the poll with correct uri' do + vote = poll.votes.first + expect(vote).to_not be_nil + expect(vote.uri).to eq object_json[:id] + expect(poll.reload.cached_tallies).to eq [1, 0] + end + end end context 'when sender is followed by local users' do diff --git a/spec/models/poll_spec.rb b/spec/models/poll_spec.rb new file mode 100644 index 000000000..666f8ca68 --- /dev/null +++ b/spec/models/poll_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Poll, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/poll_vote_spec.rb b/spec/models/poll_vote_spec.rb new file mode 100644 index 000000000..354afd535 --- /dev/null +++ b/spec/models/poll_vote_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe PollVote, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end