Add announcements (#12662)

* Add announcements

Fix #11006

* Add reactions to announcements

* Add admin UI for announcements

* Add unit tests

* Fix issues

- Add `with_dismissed` param to announcements API
- Fix end date not being formatted when time range is given
- Fix announcement delete causing reactions to send streaming updates
- Fix announcements container growing too wide and mascot too small
- Fix `all_day` being settable when no time range is given
- Change text "Update" to "Announcement"

* Fix scheduler unpublishing announcements before they are due

* Fix filter params not being passed to announcements filter
master
Eugen Rochko 4 years ago committed by GitHub
parent 81cc86bb1f
commit f52c988e12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 69
      app/controllers/admin/announcements_controller.rb
  2. 2
      app/controllers/api/base_controller.rb
  3. 29
      app/controllers/api/v1/announcements/reactions_controller.rb
  4. 33
      app/controllers/api/v1/announcements_controller.rb
  5. 8
      app/helpers/admin/action_logs_helper.rb
  6. 11
      app/helpers/admin/announcements_helper.rb
  7. 1
      app/helpers/admin/filter_helper.rb
  8. 2
      app/javascript/images/elephant_ui_plane.svg
  9. 133
      app/javascript/mastodon/actions/announcements.js
  10. 10
      app/javascript/mastodon/actions/importer/normalizer.js
  11. 3
      app/javascript/mastodon/actions/notifications.js
  12. 11
      app/javascript/mastodon/actions/streaming.js
  13. 2
      app/javascript/mastodon/actions/timelines.js
  14. 2
      app/javascript/mastodon/components/error_boundary.js
  15. 7
      app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
  16. 395
      app/javascript/mastodon/features/getting_started/components/announcements.js
  17. 21
      app/javascript/mastodon/features/getting_started/containers/announcements_container.js
  18. 2
      app/javascript/mastodon/features/getting_started/containers/trends_container.js
  19. 3
      app/javascript/mastodon/features/home_timeline/index.js
  20. 1
      app/javascript/mastodon/features/ui/components/media_modal.js
  21. 72
      app/javascript/mastodon/reducers/announcements.js
  22. 2
      app/javascript/mastodon/reducers/index.js
  23. 213
      app/javascript/styles/mastodon/components.scss
  24. 6
      app/javascript/styles/mastodon/forms.scss
  25. 2
      app/lib/entity_cache.rb
  26. 4
      app/lib/inline_renderer.rb
  27. 6
      app/models/account.rb
  28. 85
      app/models/announcement.rb
  29. 39
      app/models/announcement_filter.rb
  30. 19
      app/models/announcement_mute.rb
  31. 37
      app/models/announcement_reaction.rb
  32. 2
      app/models/backup.rb
  33. 6
      app/models/bookmark.rb
  34. 1
      app/models/concerns/account_interactions.rb
  35. 2
      app/models/custom_emoji.rb
  36. 19
      app/policies/announcement_policy.rb
  37. 34
      app/serializers/rest/announcement_serializer.rb
  38. 31
      app/serializers/rest/reaction_serializer.rb
  39. 17
      app/validators/reaction_validator.rb
  40. 14
      app/views/admin/announcements/_announcement.html.haml
  41. 22
      app/views/admin/announcements/edit.html.haml
  42. 30
      app/views/admin/announcements/index.html.haml
  43. 21
      app/views/admin/announcements/new.html.haml
  44. 22
      app/workers/publish_announcement_reaction_worker.rb
  45. 18
      app/workers/publish_scheduled_announcement_worker.rb
  46. 28
      app/workers/scheduler/scheduled_statuses_scheduler.rb
  47. 2
      config/initializers/simple_form.rb
  48. 22
      config/locales/en.yml
  49. 12
      config/locales/simple_form.en.yml
  50. 1
      config/navigation.rb
  51. 13
      config/routes.rb
  52. 16
      db/migrate/20191218153258_create_announcements.rb
  53. 12
      db/migrate/20200113125135_create_announcement_mutes.rb
  54. 15
      db/migrate/20200114113335_create_announcement_reactions.rb
  55. 41
      db/schema.rb
  56. 1
      lib/tasks/auto_annotate_models.rake
  57. 65
      spec/controllers/api/v1/announcements/reactions_controller_spec.rb
  58. 59
      spec/controllers/api/v1/announcements_controller_spec.rb
  59. 18
      spec/controllers/api/v1/trends_controller_spec.rb
  60. 6
      spec/fabricators/announcement_fabricator.rb
  61. 4
      spec/fabricators/announcement_mute_fabricator.rb
  62. 5
      spec/fabricators/announcement_reaction_fabricator.rb
  63. 4
      spec/models/announcement_mute_spec.rb
  64. 4
      spec/models/announcement_reaction_spec.rb
  65. 4
      spec/models/announcement_spec.rb

@ -0,0 +1,69 @@
# frozen_string_literal: true
class Admin::AnnouncementsController < Admin::BaseController
before_action :set_announcements, only: :index
before_action :set_announcement, except: [:index, :new, :create]
def index
authorize :announcement, :index?
end
def new
authorize :announcement, :create?
@announcement = Announcement.new
end
def create
authorize :announcement, :create?
@announcement = Announcement.new(resource_params)
if @announcement.save
log_action :create, @announcement
redirect_to admin_announcements_path
else
render :new
end
end
def edit
authorize :announcement, :update?
end
def update
authorize :announcement, :update?
if @announcement.update(resource_params)
log_action :update, @announcement
redirect_to admin_announcements_path
else
render :edit
end
end
def destroy
authorize :announcement, :destroy?
@announcement.destroy!
log_action :destroy, @announcement
redirect_to admin_announcements_path
end
private
def set_announcements
@announcements = AnnouncementFilter.new(filter_params).results.page(params[:page])
end
def set_announcement
@announcement = Announcement.find(params[:id])
end
def filter_params
params.slice(*AnnouncementFilter::KEYS).permit(*AnnouncementFilter::KEYS)
end
def resource_params
params.require(:announcement).permit(:text, :scheduled_at, :starts_at, :ends_at, :all_day)
end
end

@ -85,7 +85,7 @@ class Api::BaseController < ApplicationController
end
def require_authenticated_user!
render json: { error: 'This API requires an authenticated user' }, status: 401 unless current_user
render json: { error: 'This method requires an authenticated user' }, status: 401 unless current_user
end
def require_user!

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Api::V1::Announcements::ReactionsController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
before_action :require_user!
before_action :set_announcement
before_action :set_reaction, except: :update
def update
@announcement.announcement_reactions.create!(account: current_account, name: params[:id])
render_empty
end
def destroy
@reaction.destroy!
render_empty
end
private
def set_reaction
@reaction = @announcement.announcement_reactions.where(account: current_account).find_by!(name: params[:id])
end
def set_announcement
@announcement = Announcement.published.find(params[:announcement_id])
end
end

@ -0,0 +1,33 @@
# frozen_string_literal: true
class Api::V1::AnnouncementsController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: :dismiss
before_action :require_user!
before_action :set_announcements, only: :index
before_action :set_announcement, except: :index
def index
render json: @announcements, each_serializer: REST::AnnouncementSerializer
end
def dismiss
AnnouncementMute.create!(account: current_account, announcement: @announcement)
render_empty
end
private
def set_announcements
@announcements = begin
scope = Announcement.published
scope.merge!(Announcement.without_muted(current_account)) unless truthy_param?(:with_dismissed)
scope.chronological
end
end
def set_announcement
@announcement = Announcement.published.find(params[:id])
end
end

@ -22,6 +22,8 @@ module Admin::ActionLogsHelper
log.recorded_changes.slice('severity', 'reject_media')
elsif log.target_type == 'Status' && log.action == :update
log.recorded_changes.slice('sensitive')
elsif log.target_type == 'Announcement' && log.action == :update
log.recorded_changes.slice('text', 'starts_at', 'ends_at', 'all_day')
end
end
@ -52,6 +54,8 @@ module Admin::ActionLogsHelper
'pencil'
when 'AccountWarning'
'warning'
when 'Announcement'
'bullhorn'
end
end
@ -94,6 +98,8 @@ module Admin::ActionLogsHelper
link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record)
when 'AccountWarning'
link_to record.target_account.acct, admin_account_path(record.target_account_id)
when 'Announcement'
link_to "##{record.id}", edit_admin_announcement_path(record.id)
end
end
@ -111,6 +117,8 @@ module Admin::ActionLogsHelper
else
I18n.t('admin.action_logs.deleted_status')
end
when 'Announcement'
"##{attributes['id']}"
end
end
end

@ -0,0 +1,11 @@
# frozen_string_literal: true
module Admin::AnnouncementsHelper
def time_range(announcement)
if announcement.all_day?
safe_join([l(announcement.starts_at.to_date), ' - ', l(announcement.ends_at.to_date)])
else
safe_join([l(announcement.starts_at), ' - ', l(announcement.ends_at)])
end
end
end

@ -9,6 +9,7 @@ module Admin::FilterHelper
InstanceFilter::KEYS,
InviteFilter::KEYS,
RelationshipFilter::KEYS,
AnnouncementFilter::KEYS,
].flatten.freeze
def filter_link_to(text, link_to_params, link_class_params = link_to_params)

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@ -0,0 +1,133 @@
import api from '../api';
import { normalizeAnnouncement } from './importer/normalizer';
export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';
export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS';
export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL';
export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE';
export const ANNOUNCEMENTS_DISMISS = 'ANNOUNCEMENTS_DISMISS';
export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL';
export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST';
export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS';
export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL';
export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE';
const noOp = () => {};
export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => {
dispatch(fetchAnnouncementsRequest());
api(getState).get('/api/v1/announcements').then(response => {
dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x))));
}).catch(error => {
dispatch(fetchAnnouncementsFail(error));
}).finally(() => {
done();
});
};
export const fetchAnnouncementsRequest = () => ({
type: ANNOUNCEMENTS_FETCH_REQUEST,
skipLoading: true,
});
export const fetchAnnouncementsSuccess = announcements => ({
type: ANNOUNCEMENTS_FETCH_SUCCESS,
announcements,
skipLoading: true,
});
export const fetchAnnouncementsFail= error => ({
type: ANNOUNCEMENTS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
});
export const updateAnnouncements = announcement => ({
type: ANNOUNCEMENTS_UPDATE,
announcement: normalizeAnnouncement(announcement),
});
export const dismissAnnouncement = announcementId => (dispatch, getState) => {
dispatch({
type: ANNOUNCEMENTS_DISMISS,
id: announcementId,
});
api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`);
};
export const addReaction = (announcementId, name) => (dispatch, getState) => {
dispatch(addReactionRequest(announcementId, name));
api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
dispatch(addReactionSuccess(announcementId, name));
}).catch(err => {
dispatch(addReactionFail(announcementId, name, err));
});
};
export const addReactionRequest = (announcementId, name) => ({
type: ANNOUNCEMENTS_REACTION_ADD_REQUEST,
id: announcementId,
name,
skipLoading: true,
});
export const addReactionSuccess = (announcementId, name) => ({
type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS,
id: announcementId,
name,
skipLoading: true,
});
export const addReactionFail = (announcementId, name, error) => ({
type: ANNOUNCEMENTS_REACTION_ADD_FAIL,
id: announcementId,
name,
error,
skipLoading: true,
});
export const removeReaction = (announcementId, name) => (dispatch, getState) => {
dispatch(removeReactionRequest(announcementId, name));
api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
dispatch(removeReactionSuccess(announcementId, name));
}).catch(err => {
dispatch(removeReactionFail(announcementId, name, err));
});
};
export const removeReactionRequest = (announcementId, name) => ({
type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
id: announcementId,
name,
skipLoading: true,
});
export const removeReactionSuccess = (announcementId, name) => ({
type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS,
id: announcementId,
name,
skipLoading: true,
});
export const removeReactionFail = (announcementId, name, error) => ({
type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
id: announcementId,
name,
error,
skipLoading: true,
});
export const updateReaction = reaction => ({
type: ANNOUNCEMENTS_REACTION_UPDATE,
reaction,
});

@ -76,7 +76,6 @@ export function normalizeStatus(status, normalOldStatus) {
export function normalizePoll(poll) {
const normalPoll = { ...poll };
const emojiMap = makeEmojiMap(normalPoll);
normalPoll.options = poll.options.map((option, index) => ({
@ -87,3 +86,12 @@ export function normalizePoll(poll) {
return normalPoll;
}
export function normalizeAnnouncement(announcement) {
const normalAnnouncement = { ...announcement };
const emojiMap = makeEmojiMap(normalAnnouncement);
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
return normalAnnouncement;
}

@ -157,9 +157,9 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
fetchRelatedRelationships(dispatch, response.data);
done();
}).catch(error => {
dispatch(expandNotificationsFail(error, isLoadingMore));
}).finally(() => {
done();
});
};
@ -188,6 +188,7 @@ export function expandNotificationsFail(error, isLoadingMore) {
type: NOTIFICATIONS_EXPAND_FAIL,
error,
skipLoading: !isLoadingMore,
skipAlert: !isLoadingMore,
};
};

@ -8,6 +8,7 @@ import {
} from './timelines';
import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
import { fetchAnnouncements, updateAnnouncements, updateReaction as updateAnnouncementsReaction } from './announcements';
import { fetchFilters } from './filters';
import { getLocale } from '../locales';
@ -44,6 +45,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
case 'filters_changed':
dispatch(fetchFilters());
break;
case 'announcement':
dispatch(updateAnnouncements(JSON.parse(data.payload)));
break;
case 'announcement.reaction':
dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
break;
}
},
};
@ -51,7 +58,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
}
const refreshHomeTimelineAndNotification = (dispatch, done) => {
dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
dispatch(expandHomeTimeline({}, () =>
dispatch(expandNotifications({}, () =>
dispatch(fetchAnnouncements(done))))));
};
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);

@ -98,9 +98,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedStatuses(response.data));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
done();
}).catch(error => {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
}).finally(() => {
done();
});
};

@ -58,7 +58,7 @@ export default class ErrorBoundary extends React.PureComponent {
<div>
<p className='error-boundary__error'><FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /></p>
<p><FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /></p>
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied && 'copied'}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
</div>
</div>
);

@ -290,6 +290,7 @@ class EmojiPickerDropdown extends React.PureComponent {
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
};
state = {
@ -350,18 +351,18 @@ class EmojiPickerDropdown extends React.PureComponent {
}
render () {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;
return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
<img
{button || <img
className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🙂'
src={`${assetHost}/emoji/1f602.svg`}
/>
/>}
</div>
<Overlay show={active} placement={placement} target={this.findTarget}>

@ -0,0 +1,395 @@
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ReactSwipeableViews from 'react-swipeable-views';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from 'mastodon/components/icon_button';
import Icon from 'mastodon/components/icon';
import { defineMessages, injectIntl, FormattedMessage, FormattedDate, FormattedNumber } from 'react-intl';
import { autoPlayGif } from 'mastodon/initial_state';
import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
import { mascot } from 'mastodon/initial_state';
import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
import classNames from 'classnames';
import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_picker_dropdown_container';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
});
class Content extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
announcement: ImmutablePropTypes.map.isRequired,
};
setRef = c => {
this.node = c;
}
componentDidMount () {
this._updateLinks();
this._updateEmojis();
}
componentDidUpdate () {
this._updateLinks();
this._updateEmojis();
}
_updateEmojis () {
const node = this.node;
if (!node || autoPlayGif) {
return;
}
const emojis = node.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
if (emoji.classList.contains('status-emoji')) {
continue;
}
emoji.classList.add('status-emoji');
emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
}
}
_updateLinks () {
const node = this.node;
if (!node) {
return;
}
const links = node.querySelectorAll('a');
for (var i = 0; i < links.length; ++i) {
let link = links[i];
if (link.classList.contains('status-link')) {
continue;
}