diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb
new file mode 100644
index 0000000000..02198f0b52
--- /dev/null
+++ b/app/controllers/admin/announcements_controller.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
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 144fdd6ac9..68bf425f4d 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -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!
diff --git a/app/controllers/api/v1/announcements/reactions_controller.rb b/app/controllers/api/v1/announcements/reactions_controller.rb
new file mode 100644
index 0000000000..e4a72e595c
--- /dev/null
+++ b/app/controllers/api/v1/announcements/reactions_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/announcements_controller.rb b/app/controllers/api/v1/announcements_controller.rb
new file mode 100644
index 0000000000..6724fac2ec
--- /dev/null
+++ b/app/controllers/api/v1/announcements_controller.rb
@@ -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
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index 608a99dd5b..6bc75aa566 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -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
diff --git a/app/helpers/admin/announcements_helper.rb b/app/helpers/admin/announcements_helper.rb
new file mode 100644
index 0000000000..0c053ddec3
--- /dev/null
+++ b/app/helpers/admin/announcements_helper.rb
@@ -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
diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb
index 130686a02e..6ab92939d8 100644
--- a/app/helpers/admin/filter_helper.rb
+++ b/app/helpers/admin/filter_helper.rb
@@ -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)
diff --git a/app/javascript/images/elephant_ui_plane.svg b/app/javascript/images/elephant_ui_plane.svg
index a2624d170e..ca675c9eb3 100644
--- a/app/javascript/images/elephant_ui_plane.svg
+++ b/app/javascript/images/elephant_ui_plane.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/javascript/mastodon/actions/announcements.js b/app/javascript/mastodon/actions/announcements.js
new file mode 100644
index 0000000000..c65bc052ec
--- /dev/null
+++ b/app/javascript/mastodon/actions/announcements.js
@@ -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,
+});
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 78f321da4d..f7cbe4c1cd 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -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;
+}
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 798f9b37ea..8a066b896a 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -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,
};
};
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index c678e93932..ac325f74cd 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -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);
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index bc2ac5e823..0546686559 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -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();
});
};
diff --git a/app/javascript/mastodon/components/error_boundary.js b/app/javascript/mastodon/components/error_boundary.js
index 800b1c2706..4e1c882e22 100644
--- a/app/javascript/mastodon/components/error_boundary.js
+++ b/app/javascript/mastodon/components/error_boundary.js
@@ -58,7 +58,7 @@ export default class ErrorBoundary extends React.PureComponent {
-
Mastodon v{version} 路 路
+
Mastodon v{version} 路 路
);
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index e57c3c20c3..582bb0d39c 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -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 (
-
+ />}
diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js
new file mode 100644
index 0000000000..ee444e3f03
--- /dev/null
+++ b/app/javascript/mastodon/features/getting_started/components/announcements.js
@@ -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;
+ }
+
+ link.classList.add('status-link');
+
+ let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));
+
+ if (mention) {
+ link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+ link.setAttribute('title', mention.get('acct'));
+ } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+ link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+ } else {
+ link.setAttribute('title', link.href);
+ link.classList.add('unhandled-link');
+ }
+
+ link.setAttribute('target', '_blank');
+ link.setAttribute('rel', 'noopener noreferrer');
+ }
+ }
+
+ onMentionClick = (mention, e) => {
+ if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${mention.get('id')}`);
+ }
+ }
+
+ onHashtagClick = (hashtag, e) => {
+ hashtag = hashtag.replace(/^#/, '');
+
+ if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ this.context.router.history.push(`/timelines/tag/${hashtag}`);
+ }
+ }
+
+ handleEmojiMouseEnter = ({ target }) => {
+ target.src = target.getAttribute('data-original');
+ }
+
+ handleEmojiMouseLeave = ({ target }) => {
+ target.src = target.getAttribute('data-static');
+ }
+
+ render () {
+ const { announcement } = this.props;
+
+ return (
+
+ );
+ }
+
+}
+
+const assetHost = process.env.CDN_HOST || '';
+
+class Emoji extends React.PureComponent {
+
+ static propTypes = {
+ emoji: PropTypes.string.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ hovered: PropTypes.bool.isRequired,
+ };
+
+ render () {
+ const { emoji, emojiMap, hovered } = this.props;
+
+ if (unicodeMapping[emoji]) {
+ const { filename, shortCode } = unicodeMapping[this.props.emoji];
+ const title = shortCode ? `:${shortCode}:` : '';
+
+ return (
+
+ );
+ } else if (emojiMap.get(emoji)) {
+ const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
+ const shortCode = `:${emoji}:`;
+
+ return (
+
+ );
+ } else {
+ return null;
+ }
+ }
+
+}
+
+class Reaction extends ImmutablePureComponent {
+
+ static propTypes = {
+ announcementId: PropTypes.string.isRequired,
+ reaction: ImmutablePropTypes.map.isRequired,
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ };
+
+ state = {
+ hovered: false,
+ };
+
+ handleClick = () => {
+ const { reaction, announcementId, addReaction, removeReaction } = this.props;
+
+ if (reaction.get('me')) {
+ removeReaction(announcementId, reaction.get('name'));
+ } else {
+ addReaction(announcementId, reaction.get('name'));
+ }
+ }
+
+ handleMouseEnter = () => this.setState({ hovered: true })
+
+ handleMouseLeave = () => this.setState({ hovered: false })
+
+ render () {
+ const { reaction } = this.props;
+
+ let shortCode = reaction.get('name');
+
+ if (unicodeMapping[shortCode]) {
+ shortCode = unicodeMapping[shortCode].shortCode;
+ }
+
+ return (
+
+ );
+ }
+
+}
+
+class ReactionsBar extends ImmutablePureComponent {
+
+ static propTypes = {
+ announcementId: PropTypes.string.isRequired,
+ reactions: ImmutablePropTypes.list.isRequired,
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ };
+
+ handleEmojiPick = data => {
+ const { addReaction, announcementId } = this.props;
+ addReaction(announcementId, data.native.replace(/:/g, ''));
+ }
+
+ render () {
+ const { reactions } = this.props;
+ const visibleReactions = reactions.filter(x => x.get('count') > 0);
+
+ return (
+
+ {visibleReactions.map(reaction => (
+
+ ))}
+
+ } />
+
+ );
+ }
+
+}
+
+class Announcement extends ImmutablePureComponent {
+
+ static propTypes = {
+ announcement: ImmutablePropTypes.map.isRequired,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ dismissAnnouncement: PropTypes.func.isRequired,
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleDismissClick = () => {
+ const { dismissAnnouncement, announcement } = this.props;
+ dismissAnnouncement(announcement.get('id'));
+ }
+
+ render () {
+ const { announcement, intl } = this.props;
+ const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
+ const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
+ const now = new Date();
+ const hasTimeRange = startsAt && endsAt;
+ const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
+ const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
+ const skipTime = announcement.get('all_day');
+
+ return (
+
+
+
+ {hasTimeRange && 路 - }
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+export default @injectIntl
+class Announcements extends ImmutablePureComponent {
+
+ static propTypes = {
+ announcements: ImmutablePropTypes.list,
+ emojiMap: ImmutablePropTypes.map.isRequired,
+ fetchAnnouncements: PropTypes.func.isRequired,
+ dismissAnnouncement: PropTypes.func.isRequired,
+ addReaction: PropTypes.func.isRequired,
+ removeReaction: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ index: 0,
+ };
+
+ componentDidMount () {
+ const { fetchAnnouncements } = this.props;
+ fetchAnnouncements();
+ }
+
+ handleChangeIndex = index => {
+ this.setState({ index: index % this.props.announcements.size });
+ }
+
+ handleNextClick = () => {
+ this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
+ }
+
+ handlePrevClick = () => {
+ this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
+ }
+
+ render () {
+ const { announcements, intl } = this.props;
+ const { index } = this.state;
+
+ if (announcements.isEmpty()) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {announcements.map(announcement => (
+
+ ))}
+
+
+
+
+ {index + 1} / {announcements.size}
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/getting_started/containers/announcements_container.js b/app/javascript/mastodon/features/getting_started/containers/announcements_container.js
new file mode 100644
index 0000000000..b10d1d4cea
--- /dev/null
+++ b/app/javascript/mastodon/features/getting_started/containers/announcements_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'mastodon/actions/announcements';
+import Announcements from '../components/announcements';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+
+const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
+
+const mapStateToProps = state => ({
+ announcements: state.getIn(['announcements', 'items']),
+ emojiMap: customEmojiMap(state),
+});
+
+const mapDispatchToProps = dispatch => ({
+ fetchAnnouncements: () => dispatch(fetchAnnouncements()),
+ dismissAnnouncement: id => dispatch(dismissAnnouncement(id)),
+ addReaction: (id, name) => dispatch(addReaction(id, name)),
+ removeReaction: (id, name) => dispatch(removeReaction(id, name)),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Announcements);
diff --git a/app/javascript/mastodon/features/getting_started/containers/trends_container.js b/app/javascript/mastodon/features/getting_started/containers/trends_container.js
index 1df3fb4fe2..7a52687808 100644
--- a/app/javascript/mastodon/features/getting_started/containers/trends_container.js
+++ b/app/javascript/mastodon/features/getting_started/containers/trends_container.js
@@ -1,5 +1,5 @@
import { connect } from 'react-redux';
-import { fetchTrends } from '../../../actions/trends';
+import { fetchTrends } from 'mastodon/actions/trends';
import Trends from '../components/trends';
const mapStateToProps = state => ({
diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js
index 1cafb88eda..b7f9d50952 100644
--- a/app/javascript/mastodon/features/home_timeline/index.js
+++ b/app/javascript/mastodon/features/home_timeline/index.js
@@ -9,6 +9,7 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { Link } from 'react-router-dom';
+import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' },
@@ -113,6 +114,8 @@ class HomeTimeline extends React.PureComponent {
}
+ alwaysPrepend
trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`}
onLoadMore={this.handleLoadMore}
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index a785551c0f..d7f97f210c 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -211,7 +211,6 @@ class MediaModal extends ImmutablePureComponent {
style={swipeableViewsStyle}
containerStyle={containerStyle}
onChangeIndex={this.handleSwipe}
- onSwitching={this.handleSwitching}
index={index}
>
{content}
diff --git a/app/javascript/mastodon/reducers/announcements.js b/app/javascript/mastodon/reducers/announcements.js
new file mode 100644
index 0000000000..aa674e516f
--- /dev/null
+++ b/app/javascript/mastodon/reducers/announcements.js
@@ -0,0 +1,72 @@
+import {
+ ANNOUNCEMENTS_FETCH_REQUEST,
+ ANNOUNCEMENTS_FETCH_SUCCESS,
+ ANNOUNCEMENTS_FETCH_FAIL,
+ ANNOUNCEMENTS_UPDATE,
+ ANNOUNCEMENTS_DISMISS,
+ ANNOUNCEMENTS_REACTION_UPDATE,
+ ANNOUNCEMENTS_REACTION_ADD_REQUEST,
+ ANNOUNCEMENTS_REACTION_ADD_FAIL,
+ ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
+ ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
+} from '../actions/announcements';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+ items: ImmutableList(),
+ isLoading: false,
+});
+
+const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => {
+ if (announcement.get('id') === id) {
+ return announcement.update('reactions', reactions => {
+ if (reactions.find(reaction => reaction.get('name') === name)) {
+ return reactions.map(reaction => {
+ if (reaction.get('name') === name) {
+ return updater(reaction);
+ }
+
+ return reaction;
+ });
+ }
+
+ return reactions.push(updater(fromJS({ name, count: 0 })));
+ });
+ }
+
+ return announcement;
+}));
+
+const updateReactionCount = (state, reaction) => updateReaction(state, reaction.announcement_id, reaction.name, x => x.set('count', reaction.count));
+
+const addReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', true).update('count', y => y + 1));
+
+const removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).update('count', y => y - 1));
+
+export default function announcementsReducer(state = initialState, action) {
+ switch(action.type) {
+ case ANNOUNCEMENTS_FETCH_REQUEST:
+ return state.set('isLoading', true);
+ case ANNOUNCEMENTS_FETCH_SUCCESS:
+ return state.withMutations(map => {
+ map.set('items', fromJS(action.announcements));
+ map.set('isLoading', false);
+ });
+ case ANNOUNCEMENTS_FETCH_FAIL:
+ return state.set('isLoading', false);
+ case ANNOUNCEMENTS_UPDATE:
+ return state.update('items', list => list.unshift(fromJS(action.announcement)).sortBy(announcement => announcement.get('starts_at')));
+ case ANNOUNCEMENTS_DISMISS:
+ return state.update('items', list => list.filterNot(announcement => announcement.get('id') === action.id));
+ case ANNOUNCEMENTS_REACTION_UPDATE:
+ return updateReactionCount(state, action.reaction);
+ case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
+ case ANNOUNCEMENTS_REACTION_REMOVE_FAIL:
+ return addReaction(state, action.id, action.name);
+ case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST:
+ case ANNOUNCEMENTS_REACTION_ADD_FAIL:
+ return removeReaction(state, action.id, action.name);
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index b8d6088881..b9817cd384 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -34,8 +34,10 @@ import polls from './polls';
import identity_proofs from './identity_proofs';
import trends from './trends';
import missed_updates from './missed_updates';
+import announcements from './announcements';
const reducers = {
+ announcements,
dropdown_menu,
timelines,
meta,
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 94671c350d..922d48ad74 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -859,6 +859,44 @@
}
}
+.announcements__item__content {
+ word-wrap: break-word;
+
+ .emojione {
+ width: 20px;
+ height: 20px;
+ margin: -3px 0 0;
+ }
+
+ p {
+ margin-bottom: 10px;
+ white-space: pre-wrap;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ a {
+ color: $highlight-text-color;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &.mention {
+ &:hover {
+ text-decoration: none;
+
+ span {
+ text-decoration: underline;
+ }
+ }
+ }
+ }
+}
+
.status__content.status__content--collapsed {
max-height: 20px * 15; // 15 lines is roughly above 500 characters
}
@@ -6581,3 +6619,178 @@ noscript {
}
}
}
+
+.announcements {
+ background: lighten($ui-base-color, 4%);
+ border-top: 1px solid $ui-base-color;
+ font-size: 13px;
+ display: flex;
+ align-items: flex-end;
+
+ &__mastodon {
+ width: 124px;
+ flex: 0 0 auto;
+
+ @media screen and (max-width: 124px + 300px) {
+ display: none;
+ }
+ }
+
+ &__container {
+ width: calc(100% - 124px);
+ flex: 0 0 auto;
+ position: relative;
+
+ @media screen and (max-width: 124px + 300px) {
+ width: 100%;
+ }
+ }
+
+ &__item {
+ box-sizing: border-box;
+ width: 100%;
+ padding: 15px;
+ padding-right: 15px + 18px;
+ position: relative;
+
+ &__range {
+ display: block;
+ font-weight: 500;
+ margin-bottom: 10px;
+ }
+
+ &__dismiss-icon {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ }
+ }
+
+ &__pagination {
+ padding: 15px;
+ color: $darker-text-color;
+ position: absolute;
+ bottom: 3px;
+ right: 0;
+ }
+}
+
+.layout-multiple-columns .announcements__mastodon {
+ display: none;
+}
+
+.layout-multiple-columns .announcements__container {
+ width: 100%;
+}
+
+.reactions-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ margin-top: 15px;
+ margin-left: -2px;
+ width: calc(100% - (90px - 33px));
+
+ &__item {
+ flex-shrink: 0;
+ background: lighten($ui-base-color, 12%);
+ border: 0;
+ border-radius: 3px;
+ margin: 2px;
+ cursor: pointer;
+ user-select: none;
+ padding: 0 6px;
+ display: flex;
+ align-items: center;
+ transition: all 100ms ease-in;
+ transition-property: background-color, color;
+
+ &__emoji {
+ display: block;
+ margin: 3px 0;
+ width: 16px;
+ height: 16px;
+
+ img {
+ display: block;
+ margin: 0;
+ width: 100%;
+ height: 100%;
+ min-width: auto;
+ min-height: auto;
+ vertical-align: bottom;
+ object-fit: contain;
+ }
+ }
+
+ &__count {
+ display: block;
+ min-width: 9px;
+ font-size: 13px;
+ font-weight: 500;
+ text-align: center;
+ margin-left: 6px;
+ color: $darker-text-color;
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ background: lighten($ui-base-color, 16%);
+ transition: all 200ms ease-out;
+ transition-property: background-color, color;
+
+ &__count {
+ color: lighten($darker-text-color, 4%);
+ }
+ }
+
+ &.active {
+ transition: all 100ms ease-in;
+ transition-property: background-color, color;
+ background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 90%);
+
+ .reactions-bar__item__count {
+ color: $highlight-text-color;
+ }
+ }
+ }
+
+ .emoji-picker-dropdown {
+ margin: 2px;
+ }
+
+ &:hover .emoji-button {
+ opacity: 0.85;
+ }
+
+ .emoji-button {
+ color: $darker-text-color;
+ margin: 0;
+ font-size: 16px;
+ width: auto;
+ flex-shrink: 0;
+ padding: 0 6px;
+ height: 22px;
+ display: flex;
+ align-items: center;
+ opacity: 0.5;
+ transition: all 100ms ease-in;
+ transition-property: background-color, color;
+
+ &:hover,
+ &:active,
+ &:focus {
+ opacity: 1;
+ color: lighten($darker-text-color, 4%);
+ transition: all 200ms ease-out;
+ transition-property: background-color, color;
+ }
+ }
+
+ &--empty {
+ .emoji-button {
+ padding: 0;
+ }
+ }
+}
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 8965ce6751..65cefbd7c5 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -222,6 +222,12 @@ code {
}
}
+ .input.datetime .label_input select {
+ display: inline-block;
+ width: auto;
+ flex: 0;
+ }
+
.required abbr {
text-decoration: none;
color: lighten($error-value-color, 12%);
diff --git a/app/lib/entity_cache.rb b/app/lib/entity_cache.rb
index 8fff544a05..35a3773d2d 100644
--- a/app/lib/entity_cache.rb
+++ b/app/lib/entity_cache.rb
@@ -8,7 +8,7 @@ class EntityCache
MAX_EXPIRATION = 7.days.freeze
def mention(username, domain)
- Rails.cache.fetch(to_key(:mention, username, domain), expires_in: MAX_EXPIRATION) { Account.select(:username, :domain, :url).find_remote(username, domain) }
+ Rails.cache.fetch(to_key(:mention, username, domain), expires_in: MAX_EXPIRATION) { Account.select(:id, :username, :domain, :url).find_remote(username, domain) }
end
def emoji(shortcodes, domain)
diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb
index 761a8822df..27e334a4de 100644
--- a/app/lib/inline_renderer.rb
+++ b/app/lib/inline_renderer.rb
@@ -15,6 +15,10 @@ class InlineRenderer
serializer = REST::NotificationSerializer
when :conversation
serializer = REST::ConversationSerializer
+ when :announcement
+ serializer = REST::AnnouncementSerializer
+ when :reaction
+ serializer = REST::ReactionSerializer
else
return
end
diff --git a/app/models/account.rb b/app/models/account.rb
index 1e8abe6ec6..da6f51a9cb 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -476,6 +476,12 @@ class Account < ApplicationRecord
records
end
+ def from_text(text)
+ return [] if text.blank?
+
+ text.scan(MENTION_RE).map { |match| match.first.split('@', 2) }.uniq.map { |(username, domain)| EntityCache.instance.mention(username, domain) }
+ end
+
private
def generate_query_for_search(terms)
diff --git a/app/models/announcement.rb b/app/models/announcement.rb
new file mode 100644
index 0000000000..4da9f94d6d
--- /dev/null
+++ b/app/models/announcement.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: announcements
+#
+# id :bigint(8) not null, primary key
+# text :text default(""), not null
+# published :boolean default(FALSE), not null
+# all_day :boolean default(FALSE), not null
+# scheduled_at :datetime
+# starts_at :datetime
+# ends_at :datetime
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class Announcement < ApplicationRecord
+ after_commit :queue_publish, on: :create
+
+ scope :unpublished, -> { where(published: false) }
+ scope :published, -> { where(published: true) }
+ scope :without_muted, ->(account) { joins("LEFT OUTER JOIN announcement_mutes ON announcement_mutes.announcement_id = announcements.id AND announcement_mutes.account_id = #{account.id}").where('announcement_mutes.id IS NULL') }
+ scope :chronological, -> { order(Arel.sql('COALESCE(announcements.starts_at, announcements.scheduled_at, announcements.created_at) ASC')) }
+
+ has_many :announcement_mutes, dependent: :destroy
+ has_many :announcement_reactions, dependent: :destroy
+
+ validates :text, presence: true
+ validates :starts_at, presence: true, if: -> { ends_at.present? }
+ validates :ends_at, presence: true, if: -> { starts_at.present? }
+
+ before_validation :set_all_day
+ before_validation :set_starts_at, on: :create
+ before_validation :set_ends_at, on: :create
+
+ def time_range?
+ starts_at.present? && ends_at.present?
+ end
+
+ def mentions
+ @mentions ||= Account.from_text(text)
+ end
+
+ def tags
+ @tags ||= Tag.find_or_create_by_names(Extractor.extract_hashtags(text))
+ end
+
+ def emojis
+ @emojis ||= CustomEmoji.from_text(text)
+ end
+
+ def reactions(account = nil)
+ records = begin
+ scope = announcement_reactions.group(:announcement_id, :name, :custom_emoji_id).order(Arel.sql('MIN(created_at) ASC'))
+
+ if account.nil?
+ scope.select('name, custom_emoji_id, count(*) as count, false as me')
+ else
+ scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from announcement_reactions r where r.account_id = #{account.id} and r.announcement_id = announcement_reactions.announcement_id and r.name = announcement_reactions.name) as me")
+ end
+ end
+
+ ActiveRecord::Associations::Preloader.new.preload(records, :custom_emoji)
+ records
+ end
+
+ private
+
+ def set_all_day
+ self.all_day = false if starts_at.blank? || ends_at.blank?
+ end
+
+ def set_starts_at
+ self.starts_at = starts_at.change(hour: 0, min: 0, sec: 0) if all_day? && starts_at.present?
+ end
+
+ def set_ends_at
+ self.ends_at = ends_at.change(hour: 23, min: 59, sec: 59) if all_day? && ends_at.present?
+ end
+
+ def queue_publish
+ PublishScheduledAnnouncementWorker.perform_async(id) if scheduled_at.blank?
+ end
+end
diff --git a/app/models/announcement_filter.rb b/app/models/announcement_filter.rb
new file mode 100644
index 0000000000..950852460d
--- /dev/null
+++ b/app/models/announcement_filter.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class AnnouncementFilter
+ KEYS = %i(
+ published
+ unpublished
+ ).freeze
+
+ attr_reader :params
+
+ def initialize(params)
+ @params = params
+ end
+
+ def results
+ scope = Announcement.unscoped
+
+ params.each do |key, value|
+ next if key.to_s == 'page'
+
+ scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+ end
+
+ scope.chronological
+ end
+
+ private
+
+ def scope_for(key, _value)
+ case key.to_s
+ when 'published'
+ Announcement.published
+ when 'unpublished'
+ Announcement.unpublished
+ else
+ raise "Unknown filter: #{key}"
+ end
+ end
+end
diff --git a/app/models/announcement_mute.rb b/app/models/announcement_mute.rb
new file mode 100644
index 0000000000..46fda2f5d6
--- /dev/null
+++ b/app/models/announcement_mute.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: announcement_mutes
+#
+# id :bigint(8) not null, primary key
+# account_id :bigint(8)
+# announcement_id :bigint(8)
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class AnnouncementMute < ApplicationRecord
+ belongs_to :account
+ belongs_to :announcement, inverse_of: :announcement_mutes
+
+ validates :account_id, uniqueness: { scope: :announcement_id }
+end
diff --git a/app/models/announcement_reaction.rb b/app/models/announcement_reaction.rb
new file mode 100644
index 0000000000..d22771034f
--- /dev/null
+++ b/app/models/announcement_reaction.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: announcement_reactions
+#
+# id :bigint(8) not null, primary key
+# account_id :bigint(8)
+# announcement_id :bigint(8)
+# name :string default(""), not null
+# custom_emoji_id :bigint(8)
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class AnnouncementReaction < ApplicationRecord
+ after_commit :queue_publish
+
+ belongs_to :account
+ belongs_to :announcement, inverse_of: :announcement_reactions
+ belongs_to :custom_emoji, optional: true
+
+ validates :name, presence: true
+ validates_with ReactionValidator
+
+ before_validation :set_custom_emoji
+
+ private
+
+ def set_custom_emoji
+ self.custom_emoji = CustomEmoji.local.find_by(disabled: false, shortcode: name) if name.present?
+ end
+
+ def queue_publish
+ PublishAnnouncementReactionWorker.perform_async(announcement_id, name) unless announcement.destroyed?
+ end
+end
diff --git a/app/models/backup.rb b/app/models/backup.rb
index 8eeb1748aa..d242fd62c1 100644
--- a/app/models/backup.rb
+++ b/app/models/backup.rb
@@ -7,11 +7,11 @@
# user_id :bigint(8)
# dump_file_name :string
# dump_content_type :string
-# dump_file_size :bigint
# dump_updated_at :datetime
# processed :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
+# dump_file_size :bigint(8)
#
class Backup < ApplicationRecord
diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb
index 01dc48ee70..916261a17b 100644
--- a/app/models/bookmark.rb
+++ b/app/models/bookmark.rb
@@ -3,11 +3,11 @@
#
# Table name: bookmarks
#
-# id :integer not null, primary key
+# id :bigint(8) not null, primary key
+# account_id :bigint(8) not null
+# status_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
-# account_id :integer not null
-# status_id :integer not null
#
class Bookmark < ApplicationRecord
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index f27d39483a..14bcf7bb10 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -84,6 +84,7 @@ module AccountInteractions
has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account
has_many :conversation_mutes, dependent: :destroy
has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
+ has_many :announcement_mutes, dependent: :destroy
end
def follow!(other_account, reblogs: nil, uri: nil)
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 0dacaf654b..d177cf2815 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -67,7 +67,7 @@ class CustomEmoji < ApplicationRecord
end
class << self
- def from_text(text, domain)
+ def from_text(text, domain = nil)
return [] if text.blank?
shortcodes = text.scan(SCAN_RE).map(&:first).uniq
diff --git a/app/policies/announcement_policy.rb b/app/policies/announcement_policy.rb
new file mode 100644
index 0000000000..0a4e4575ca
--- /dev/null
+++ b/app/policies/announcement_policy.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class AnnouncementPolicy < ApplicationPolicy
+ def index?
+ staff?
+ end
+
+ def create?
+ admin?
+ end
+
+ def update?
+ admin?
+ end
+
+ def destroy?
+ admin?
+ end
+end
diff --git a/app/serializers/rest/announcement_serializer.rb b/app/serializers/rest/announcement_serializer.rb
new file mode 100644
index 0000000000..924d87b341
--- /dev/null
+++ b/app/serializers/rest/announcement_serializer.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class REST::AnnouncementSerializer < ActiveModel::Serializer
+ attributes :id, :content, :starts_at, :ends_at, :all_day
+
+ has_many :mentions
+ has_many :tags, serializer: REST::StatusSerializer::TagSerializer
+ has_many :emojis, serializer: REST::CustomEmojiSerializer
+ has_many :reactions, serializer: REST::ReactionSerializer
+
+ def id
+ object.id.to_s
+ end
+
+ def content
+ Formatter.instance.linkify(object.text)
+ end
+
+ def reactions
+ object.reactions(current_user&.account)
+ end
+
+ class AccountSerializer < ActiveModel::Serializer
+ attributes :id, :username, :url, :acct
+
+ def id
+ object.id.to_s
+ end
+
+ def url
+ ActivityPub::TagManager.instance.url_for(object)
+ end
+ end
+end
diff --git a/app/serializers/rest/reaction_serializer.rb b/app/serializers/rest/reaction_serializer.rb
new file mode 100644
index 0000000000..1a5dca0183
--- /dev/null
+++ b/app/serializers/rest/reaction_serializer.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class REST::ReactionSerializer < ActiveModel::Serializer
+ include RoutingHelper
+
+ attributes :name, :count
+
+ attribute :me, if: :current_user?
+ attribute :url, if: :custom_emoji?
+ attribute :static_url, if: :custom_emoji?
+
+ def count
+ object.respond_to?(:count) ? object.count : 0
+ end
+
+ def current_user?
+ !current_user.nil?
+ end
+
+ def custom_emoji?
+ object.custom_emoji.present?
+ end
+
+ def url
+ full_asset_url(object.custom_emoji.image.url)
+ end
+
+ def static_url
+ full_asset_url(object.custom_emoji.image.url(:static))
+ end
+end
diff --git a/app/validators/reaction_validator.rb b/app/validators/reaction_validator.rb
new file mode 100644
index 0000000000..de0f2c94b9
--- /dev/null
+++ b/app/validators/reaction_validator.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ReactionValidator < ActiveModel::Validator
+ SUPPORTED_EMOJIS = Oj.load(File.read(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json'))).keys.freeze
+
+ def validate(reaction)
+ return if reaction.name.blank? || reaction.custom_emoji_id.present?
+
+ reaction.errors.add(:name, I18n.t('reactions.errors.unrecognized_emoji')) unless unicode_emoji?(reaction.name)
+ end
+
+ private
+
+ def unicode_emoji?(name)
+ SUPPORTED_EMOJIS.include?(name)
+ end
+end
diff --git a/app/views/admin/announcements/_announcement.html.haml b/app/views/admin/announcements/_announcement.html.haml
new file mode 100644
index 0000000000..75768c7ba2
--- /dev/null
+++ b/app/views/admin/announcements/_announcement.html.haml
@@ -0,0 +1,14 @@
+%tr
+ %td
+ = truncate(announcement.text)
+ %td
+ = time_range(announcement) if announcement.time_range?
+ %td
+ - if announcement.scheduled_at.present?
+ = fa_icon('clock-o') if announcement.scheduled_at > Time.now.utc
+ = l(announcement.scheduled_at)
+ - else
+ = l(announcement.created_at)
+ %td
+ = table_link_to 'pencil', t('generic.edit'), edit_admin_announcement_path(announcement) if can?(:update, announcement)
+ = table_link_to 'trash', t('generic.delete'), admin_announcement_path(announcement), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, announcement)
diff --git a/app/views/admin/announcements/edit.html.haml b/app/views/admin/announcements/edit.html.haml
new file mode 100644
index 0000000000..c5c605e939
--- /dev/null
+++ b/app/views/admin/announcements/edit.html.haml
@@ -0,0 +1,22 @@
+- content_for :page_title do
+ = t('.title')
+
+= simple_form_for @announcement, url: admin_announcement_path(@announcement) do |f|
+ = render 'shared/error_messages', object: @announcement
+
+ .fields-group
+ = f.input :starts_at, include_blank: true, wrapper: :with_block_label
+ = f.input :ends_at, include_blank: true, wrapper: :with_block_label
+
+ .fields-group
+ = f.input :all_day, as: :boolean, wrapper: :with_label
+
+ .fields-group
+ = f.input :text, wrapper: :with_block_label
+
+ - if @announcement.scheduled_at.present? && !@announcement.published?
+ .fields-group
+ = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label
+
+ .actions
+ = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/admin/announcements/index.html.haml b/app/views/admin/announcements/index.html.haml
new file mode 100644
index 0000000000..634f586fbc
--- /dev/null
+++ b/app/views/admin/announcements/index.html.haml
@@ -0,0 +1,30 @@
+- content_for :page_title do
+ = t('admin.announcements.title')
+
+- content_for :heading_actions do
+ = link_to t('admin.announcements.new.title'), new_admin_announcement_path, class: 'button'
+
+.filters
+ .filter-subset
+ %strong= t('admin.relays.status')
+ %ul
+ %li= filter_link_to t('generic.all'), published: nil, unpublished: nil
+ %li= filter_link_to safe_join([t('admin.announcements.live'), "(#{number_with_delimiter(Announcement.published.count)})"], ' '), published: '1', unpublished: nil
+
+- if @announcements.empty?
+ %div.muted-hint.center-text
+ = t 'admin.announcements.empty'
+- else
+ .table-wrapper
+ %table.table
+ %thead
+ %tr
+ %th= t('simple_form.labels.announcement.text')
+ %th= t('admin.announcements.time_range')
+ %th= t('admin.announcements.published')
+ %th
+ %tbody
+ = render partial: 'announcement', collection: @announcements
+
+= paginate @announcements
+
diff --git a/app/views/admin/announcements/new.html.haml b/app/views/admin/announcements/new.html.haml
new file mode 100644
index 0000000000..a5298c5f66
--- /dev/null
+++ b/app/views/admin/announcements/new.html.haml
@@ -0,0 +1,21 @@
+- content_for :page_title do
+ = t('.title')
+
+= simple_form_for @announcement, url: admin_announcements_path do |f|
+ = render 'shared/error_messages', object: @announcement
+
+ .fields-group
+ = f.input :starts_at, include_blank: true, wrapper: :with_block_label
+ = f.input :ends_at, include_blank: true, wrapper: :with_block_label
+
+ .fields-group
+ = f.input :all_day, as: :boolean, wrapper: :with_label
+
+ .fields-group
+ = f.input :text, wrapper: :with_block_label
+
+ .fields-group
+ = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label
+
+ .actions
+ = f.button :button, t('.create'), type: :submit
diff --git a/app/workers/publish_announcement_reaction_worker.rb b/app/workers/publish_announcement_reaction_worker.rb
new file mode 100644
index 0000000000..6f3b6dc5b6
--- /dev/null
+++ b/app/workers/publish_announcement_reaction_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class PublishAnnouncementReactionWorker
+ include Sidekiq::Worker
+ include Redisable
+
+ def perform(announcement_id, name)
+ announcement = Announcement.find(announcement_id)
+
+ reaction, = announcement.announcement_reactions.where(name: name).group(:announcement_id, :name, :custom_emoji_id).select('name, custom_emoji_id, count(*) as count, false as me')
+ reaction ||= announcement.announcement_reactions.new(name: name)
+
+ payload = InlineRenderer.render(reaction, nil, :reaction).tap { |h| h[:announcement_id] = announcement_id }
+ payload = Oj.dump(event: :'announcement.reaction', payload: payload)
+
+ Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each do |account|
+ redis.publish("timeline:#{account.id}", payload) if redis.exists("subscribed:timeline:#{account.id}")
+ end
+ rescue ActiveRecord::RecordNotFound
+ true
+ end
+end
diff --git a/app/workers/publish_scheduled_announcement_worker.rb b/app/workers/publish_scheduled_announcement_worker.rb
new file mode 100644
index 0000000000..4b2014e34a
--- /dev/null
+++ b/app/workers/publish_scheduled_announcement_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class PublishScheduledAnnouncementWorker
+ include Sidekiq::Worker
+ include Redisable
+
+ def perform(announcement_id)
+ announcement = Announcement.find(announcement_id)
+ announcement.update(published: true)
+
+ payload = InlineRenderer.render(announcement, nil, :announcement)
+ payload = Oj.dump(event: :announcement, payload: payload)
+
+ Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each do |account|
+ redis.publish("timeline:#{account.id}", payload) if redis.exists("subscribed:timeline:#{account.id}")
+ end
+ end
+end
diff --git a/app/workers/scheduler/scheduled_statuses_scheduler.rb b/app/workers/scheduler/scheduled_statuses_scheduler.rb
index 1772a246bc..4262f1d01b 100644
--- a/app/workers/scheduler/scheduled_statuses_scheduler.rb
+++ b/app/workers/scheduler/scheduled_statuses_scheduler.rb
@@ -6,14 +6,38 @@ class Scheduler::ScheduledStatusesScheduler
sidekiq_options unique: :until_executed, retry: 0
def perform
+ publish_scheduled_statuses!
+ publish_scheduled_announcements!
+ unpublish_expired_announcements!
+ end
+
+ private
+
+ def publish_scheduled_statuses!
due_statuses.find_each do |scheduled_status|
PublishScheduledStatusWorker.perform_at(scheduled_status.scheduled_at, scheduled_status.id)
end
end
- private
-
def due_statuses
ScheduledStatus.where('scheduled_at <= ?', Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET)
end
+
+ def publish_scheduled_announcements!
+ due_announcements.find_each do |announcement|
+ PublishScheduledAnnouncementWorker.perform_at(announcement.scheduled_at, announcement.id)
+ end
+ end
+
+ def due_announcements
+ Announcement.unpublished.where('scheduled_at IS NOT NULL AND scheduled_at <= ?', Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET)
+ end
+
+ def unpublish_expired_announcements!
+ expired_announcements.in_batches.update_all(published: false)
+ end
+
+ def expired_announcements
+ Announcement.published.where('ends_at IS NOT NULL AND ends_at <= ?', Time.now.utc)
+ end
end
diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb
index 9645268195..3dc48ef08f 100644
--- a/config/initializers/simple_form.rb
+++ b/config/initializers/simple_form.rb
@@ -98,7 +98,7 @@ SimpleForm.setup do |config|
b.use :html5
b.use :label
b.use :hint, wrap_with: { tag: :span, class: :hint }
- b.use :input
+ b.use :input, wrap_with: { tag: :div, class: :label_input }
b.use :error, wrap_with: { tag: :span, class: :error }
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 2bd84c2643..c4e846354c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -198,11 +198,13 @@ en:
change_email_user: "%{name} changed the e-mail address of user %{target}"
confirm_user: "%{name} confirmed e-mail address of user %{target}"
create_account_warning: "%{name} sent a warning to %{target}"
+ create_announcement: "%{name} created new announcement %{target}"
create_custom_emoji: "%{name} uploaded new emoji %{target}"
create_domain_allow: "%{name} whitelisted domain %{target}"
create_domain_block: "%{name} blocked domain %{target}"
create_email_domain_block: "%{name} blacklisted e-mail domain %{target}"
demote_user: "%{name} demoted user %{target}"
+ destroy_announcement: "%{name} deleted announcement %{target}"
destroy_custom_emoji: "%{name} destroyed emoji %{target}"
destroy_domain_allow: "%{name} removed domain %{target} from whitelist"
destroy_domain_block: "%{name} unblocked domain %{target}"
@@ -224,10 +226,22 @@ en:
unassigned_report: "%{name} unassigned report %{target}"
unsilence_account: "%{name} unsilenced %{target}'s account"
unsuspend_account: "%{name} unsuspended %{target}'s account"
+ update_announcement: "%{name} updated announcement %{target}"
update_custom_emoji: "%{name} updated emoji %{target}"
update_status: "%{name} updated status by %{target}"
deleted_status: "(deleted status)"
title: Audit log
+ announcements:
+ edit:
+ title: Edit announcement
+ empty: No announcements found.
+ live: Live
+ new:
+ create: Create announcement
+ title: New announcement
+ published: Published
+ time_range: Time range
+ title: Announcements
custom_emojis:
assign_category: Assign category
by_domain: Domain
@@ -657,6 +671,9 @@ en:
hint_html: "Tip: We won't ask you for your password again for the next hour."
invalid_password: Invalid password
prompt: Confirm password to continue
+ date:
+ formats:
+ default: "%b %d, %Y"
datetime:
distance_in_words:
about_x_hours: "%{count}h"
@@ -758,6 +775,8 @@ en:
all: All
changes_saved_msg: Changes successfully saved!
copy: Copy
+ delete: Delete
+ edit: Edit
no_batch_actions_available: No batch actions available on this page
order_by: Order by
save_changes: Save changes
@@ -930,6 +949,9 @@ en:
other: Other
posting_defaults: Posting defaults
public_timelines: Public timelines
+ reactions:
+ errors:
+ unrecognized_emoji: is not a recognized emoji
relationships:
activity: Account activity
dormant: Dormant
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 66f518c1b9..f050ec8a36 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -14,6 +14,12 @@ en:
text_html: Optional. You can use toot syntax. You can add warning presets to save time
type_html: Choose what to do with %{acct}
warning_preset_id: Optional. You can still add custom text to end of the preset
+ announcement:
+ all_day: When checked, only the dates of the time range will be displayed
+ ends_at: Optional. Announcement will be automatically unpublished at this time
+ scheduled_at: Leave blank to publish the announcement immediately
+ starts_at: Optional. In case your announcement is bound to a specific time range
+ text: You can use toot syntax. Please be mindful of the space the announcement will take up on the user's screen
defaults:
autofollow: People who sign up through the invite will automatically follow you
avatar: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px
@@ -83,6 +89,12 @@ en:
silence: Silence
suspend: Suspend and irreversibly delete account data
warning_preset_id: Use a warning preset
+ announcement:
+ all_day: All-day event
+ ends_at: End of event
+ scheduled_at: Schedule publication
+ starts_at: Begin of event
+ text: Announcement
defaults:
autofollow: Invite to follow your account
avatar: Avatar
diff --git a/config/navigation.rb b/config/navigation.rb
index eebd4f75e3..8fd296d5a1 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -46,6 +46,7 @@ SimpleNavigation::Configuration.run do |navigation|
n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_dashboard_url, if: proc { current_user.staff? } do |s|
s.item :dashboard, safe_join([fa_icon('tachometer fw'), t('admin.dashboard.title')]), admin_dashboard_url
s.item :settings, safe_join([fa_icon('cogs fw'), t('admin.settings.title')]), edit_admin_settings_url, if: -> { current_user.admin? }, highlights_on: %r{/admin/settings}
+ s.item :announcements, safe_join([fa_icon('bullhorn fw'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}
s.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), admin_custom_emojis_url, highlights_on: %r{/admin/custom_emojis}
s.item :relays, safe_join([fa_icon('exchange fw'), t('admin.relays.title')]), admin_relays_url, if: -> { current_user.admin? && !whitelist_mode? }, highlights_on: %r{/admin/relays}
s.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? }
diff --git a/config/routes.rb b/config/routes.rb
index f79af192d8..da7bf6f889 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -173,9 +173,12 @@ Rails.application.routes.draw do
get :edit
end
end
+
resources :email_domain_blocks, only: [:index, :new, :create, :destroy]
resources :action_logs, only: [:index]
resources :warning_presets, except: [:new]
+ resources :announcements, except: [:show]
+
resource :settings, only: [:edit, :update]
resources :invites, only: [:index, :create, :destroy] do
@@ -317,6 +320,16 @@ Rails.application.routes.draw do
resources :scheduled_statuses, only: [:index, :show, :update, :destroy]
resources :preferences, only: [:index]
+ resources :announcements, only: [:index] do
+ scope module: :announcements do
+ resources :reactions, only: [:update, :destroy]
+ end
+
+ member do
+ post :dismiss
+ end
+ end
+
resources :conversations, only: [:index, :destroy] do
member do
post :read
diff --git a/db/migrate/20191218153258_create_announcements.rb b/db/migrate/20191218153258_create_announcements.rb
new file mode 100644
index 0000000000..58e143c920
--- /dev/null
+++ b/db/migrate/20191218153258_create_announcements.rb
@@ -0,0 +1,16 @@
+class CreateAnnouncements < ActiveRecord::Migration[5.2]
+ def change
+ create_table :announcements do |t|
+ t.text :text, null: false, default: ''
+
+ t.boolean :published, null: false, default: false
+ t.boolean :all_day, null: false, default: false
+
+ t.datetime :scheduled_at
+ t.datetime :starts_at
+ t.datetime :ends_at
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20200113125135_create_announcement_mutes.rb b/db/migrate/20200113125135_create_announcement_mutes.rb
new file mode 100644
index 0000000000..c588e7fcd3
--- /dev/null
+++ b/db/migrate/20200113125135_create_announcement_mutes.rb
@@ -0,0 +1,12 @@
+class CreateAnnouncementMutes < ActiveRecord::Migration[5.2]
+ def change
+ create_table :announcement_mutes do |t|
+ t.belongs_to :account, foreign_key: { on_delete: :cascade, index: false }
+ t.belongs_to :announcement, foreign_key: { on_delete: :cascade }
+
+ t.timestamps
+ end
+
+ add_index :announcement_mutes, [:account_id, :announcement_id], unique: true
+ end
+end
diff --git a/db/migrate/20200114113335_create_announcement_reactions.rb b/db/migrate/20200114113335_create_announcement_reactions.rb
new file mode 100644
index 0000000000..226c81a18e
--- /dev/null
+++ b/db/migrate/20200114113335_create_announcement_reactions.rb
@@ -0,0 +1,15 @@
+class CreateAnnouncementReactions < ActiveRecord::Migration[5.2]
+ def change
+ create_table :announcement_reactions do |t|
+ t.belongs_to :account, foreign_key: { on_delete: :cascade, index: false }
+ t.belongs_to :announcement, foreign_key: { on_delete: :cascade }
+
+ t.string :name, null: false, default: ''
+ t.belongs_to :custom_emoji, foreign_key: { on_delete: :cascade }
+
+ t.timestamps
+ end
+
+ add_index :announcement_reactions, [:account_id, :announcement_id, :name], unique: true, name: :index_announcement_reactions_on_account_id_and_announcement_id
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index fc2d3a5118..d3a2c05b31 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -196,15 +196,49 @@ ActiveRecord::Schema.define(version: 2020_01_19_112504) do
t.index ["target_type", "target_id"], name: "index_admin_action_logs_on_target_type_and_target_id"
end
+ create_table "announcement_mutes", force: :cascade do |t|
+ t.bigint "account_id"
+ t.bigint "announcement_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "announcement_id"], name: "index_announcement_mutes_on_account_id_and_announcement_id", unique: true
+ t.index ["account_id"], name: "index_announcement_mutes_on_account_id"
+ t.index ["announcement_id"], name: "index_announcement_mutes_on_announcement_id"
+ end
+
+ create_table "announcement_reactions", force: :cascade do |t|
+ t.bigint "account_id"
+ t.bigint "announcement_id"
+ t.string "name", default: "", null: false
+ t.bigint "custom_emoji_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "announcement_id", "name"], name: "index_announcement_reactions_on_account_id_and_announcement_id", unique: true
+ t.index ["account_id"], name: "index_announcement_reactions_on_account_id"
+ t.index ["announcement_id"], name: "index_announcement_reactions_on_announcement_id"
+ t.index ["custom_emoji_id"], name: "index_announcement_reactions_on_custom_emoji_id"
+ end
+
+ create_table "announcements", force: :cascade do |t|
+ t.text "text", default: "", null: false
+ t.boolean "published", default: false, null: false
+ t.boolean "all_day", default: false, null: false
+ t.datetime "scheduled_at"
+ t.datetime "starts_at"
+ t.datetime "ends_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
create_table "backups", force: :cascade do |t|
t.bigint "user_id"
t.string "dump_file_name"
t.string "dump_content_type"
- t.bigint "dump_file_size"
t.datetime "dump_updated_at"
t.boolean "processed", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.bigint "dump_file_size"
end
create_table "blocks", force: :cascade do |t|
@@ -818,6 +852,11 @@ ActiveRecord::Schema.define(version: 2020_01_19_112504) do
add_foreign_key "account_warnings", "accounts", on_delete: :nullify
add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify
add_foreign_key "admin_action_logs", "accounts", on_delete: :cascade
+ add_foreign_key "announcement_mutes", "accounts", on_delete: :cascade
+ add_foreign_key "announcement_mutes", "announcements", on_delete: :cascade
+ add_foreign_key "announcement_reactions", "accounts", on_delete: :cascade
+ add_foreign_key "announcement_reactions", "announcements", on_delete: :cascade
+ add_foreign_key "announcement_reactions", "custom_emojis", on_delete: :cascade
add_foreign_key "backups", "users", on_delete: :nullify
add_foreign_key "blocks", "accounts", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade
add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
diff --git a/lib/tasks/auto_annotate_models.rake b/lib/tasks/auto_annotate_models.rake
index fb9c89aa4c..a374e33ad2 100644
--- a/lib/tasks/auto_annotate_models.rake
+++ b/lib/tasks/auto_annotate_models.rake
@@ -4,6 +4,7 @@ if Rails.env.development?
task :set_annotation_options do
Annotate.set_defaults(
'routes' => 'false',
+ 'models' => 'true',
'position_in_routes' => 'before',
'position_in_class' => 'before',
'position_in_test' => 'before',
diff --git a/spec/controllers/api/v1/announcements/reactions_controller_spec.rb b/spec/controllers/api/v1/announcements/reactions_controller_spec.rb
new file mode 100644
index 0000000000..72620e2421
--- /dev/null
+++ b/spec/controllers/api/v1/announcements/reactions_controller_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::V1::Announcements::ReactionsController, type: :controller do
+ render_views
+
+ let(:user) { Fabricate(:user) }
+ let(:scopes) { 'write:favourites' }
+ let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+
+ let!(:announcement) { Fabricate(:announcement) }
+
+ describe 'PUT #update' do
+ context 'without token' do
+ it 'returns http unauthorized' do
+ put :update, params: { announcement_id: announcement.id, id: '馃槀' }
+ expect(response).to have_http_status :unauthorized
+ end
+ end
+
+ context 'with token' do
+ before do
+ allow(controller).to receive(:doorkeeper_token) { token }
+ put :update, params: { announcement_id: announcement.id, id: '馃槀' }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'creates reaction' do
+ expect(announcement.announcement_reactions.find_by(name: '馃槀', account: user.account)).to_not be_nil
+ end
+ end
+ end
+
+ describe 'DELETE #destroy' do
+ before do
+ announcement.announcement_reactions.create!(account: user.account, name: '馃槀')
+ end
+
+ context 'without token' do
+ it 'returns http unauthorized' do
+ delete :destroy, params: { announcement_id: announcement.id, id: '馃槀' }
+ expect(response).to have_http_status :unauthorized
+ end
+ end
+
+ context 'with token' do
+ before do
+ allow(controller).to receive(:doorkeeper_token) { token }
+ delete :destroy, params: { announcement_id: announcement.id, id: '馃槀' }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'creates reaction' do
+ expect(announcement.announcement_reactions.find_by(name: '馃槀', account: user.account)).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/announcements_controller_spec.rb b/spec/controllers/api/v1/announcements_controller_spec.rb
new file mode 100644
index 0000000000..6ee46b60eb
--- /dev/null
+++ b/spec/controllers/api/v1/announcements_controller_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::V1::AnnouncementsController, type: :controller do
+ render_views
+
+ let(:user) { Fabricate(:user) }
+ let(:scopes) { 'read' }
+ let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+
+ let!(:announcement) { Fabricate(:announcement) }
+
+ describe 'GET #index' do
+ context 'without token' do
+ it 'returns http unprocessable entity' do
+ get :index
+ expect(response).to have_http_status :unprocessable_entity
+ end
+ end
+
+ context 'with token' do
+ before do
+ allow(controller).to receive(:doorkeeper_token) { token }
+ get :index
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+ end
+ end
+
+ describe 'POST #dismiss' do
+ context 'without token' do
+ it 'returns http unauthorized' do
+ post :dismiss, params: { id: announcement.id }
+ expect(response).to have_http_status :unauthorized
+ end
+ end
+
+ context 'with token' do
+ let(:scopes) { 'write:accounts' }
+
+ before do
+ allow(controller).to receive(:doorkeeper_token) { token }
+ post :dismiss, params: { id: announcement.id }
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+
+ it 'dismisses announcement' do
+ expect(announcement.announcement_mutes.find_by(account: user.account)).to_not be_nil
+ end
+ end
+ end
+end
diff --git a/spec/controllers/api/v1/trends_controller_spec.rb b/spec/controllers/api/v1/trends_controller_spec.rb
new file mode 100644
index 0000000000..91e0d18fe7
--- /dev/null
+++ b/spec/controllers/api/v1/trends_controller_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::V1::TrendsController, type: :controller do
+ render_views
+
+ describe 'GET #index' do
+ before do
+ allow(TrendingTags).to receive(:get).and_return(Fabricate.times(10, :tag))
+ get :index
+ end
+
+ it 'returns http success' do
+ expect(response).to have_http_status(200)
+ end
+ end
+end
diff --git a/spec/fabricators/announcement_fabricator.rb b/spec/fabricators/announcement_fabricator.rb
new file mode 100644
index 0000000000..5a3871d901
--- /dev/null
+++ b/spec/fabricators/announcement_fabricator.rb
@@ -0,0 +1,6 @@
+Fabricator(:announcement) do
+ text { Faker::Lorem.paragraph(sentence_count: 2) }
+ published true
+ starts_at nil
+ ends_at nil
+end
diff --git a/spec/fabricators/announcement_mute_fabricator.rb b/spec/fabricators/announcement_mute_fabricator.rb
new file mode 100644
index 0000000000..c4eafe8f4c
--- /dev/null
+++ b/spec/fabricators/announcement_mute_fabricator.rb
@@ -0,0 +1,4 @@
+Fabricator(:announcement_mute) do
+ account
+ announcement
+end
diff --git a/spec/fabricators/announcement_reaction_fabricator.rb b/spec/fabricators/announcement_reaction_fabricator.rb
new file mode 100644
index 0000000000..f923c59c60
--- /dev/null
+++ b/spec/fabricators/announcement_reaction_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:announcement_reaction) do
+ account
+ announcement
+ name '馃尶'
+end
diff --git a/spec/models/announcement_mute_spec.rb b/spec/models/announcement_mute_spec.rb
new file mode 100644
index 0000000000..9d0e4c9037
--- /dev/null
+++ b/spec/models/announcement_mute_spec.rb
@@ -0,0 +1,4 @@
+require 'rails_helper'
+
+RSpec.describe AnnouncementMute, type: :model do
+end
diff --git a/spec/models/announcement_reaction_spec.rb b/spec/models/announcement_reaction_spec.rb
new file mode 100644
index 0000000000..f6e1515840
--- /dev/null
+++ b/spec/models/announcement_reaction_spec.rb
@@ -0,0 +1,4 @@
+require 'rails_helper'
+
+RSpec.describe AnnouncementReaction, type: :model do
+end
diff --git a/spec/models/announcement_spec.rb b/spec/models/announcement_spec.rb
new file mode 100644
index 0000000000..7f7b647a9e
--- /dev/null
+++ b/spec/models/announcement_spec.rb
@@ -0,0 +1,4 @@
+require 'rails_helper'
+
+RSpec.describe Announcement, type: :model do
+end