From f52c988e12e464e7baefc2fdb48ddf4a95584664 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 23 Jan 2020 22:00:13 +0100 Subject: 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 --- app/controllers/admin/announcements_controller.rb | 69 ++++ app/controllers/api/base_controller.rb | 2 +- .../api/v1/announcements/reactions_controller.rb | 29 ++ app/controllers/api/v1/announcements_controller.rb | 33 ++ app/helpers/admin/action_logs_helper.rb | 8 + app/helpers/admin/announcements_helper.rb | 11 + app/helpers/admin/filter_helper.rb | 1 + app/javascript/images/elephant_ui_plane.svg | 2 +- app/javascript/mastodon/actions/announcements.js | 133 +++++++ .../mastodon/actions/importer/normalizer.js | 10 +- app/javascript/mastodon/actions/notifications.js | 3 +- app/javascript/mastodon/actions/streaming.js | 11 +- app/javascript/mastodon/actions/timelines.js | 2 +- .../mastodon/components/error_boundary.js | 2 +- .../compose/components/emoji_picker_dropdown.js | 7 +- .../getting_started/components/announcements.js | 395 +++++++++++++++++++++ .../containers/announcements_container.js | 21 ++ .../getting_started/containers/trends_container.js | 2 +- .../mastodon/features/home_timeline/index.js | 3 + .../mastodon/features/ui/components/media_modal.js | 1 - app/javascript/mastodon/reducers/announcements.js | 72 ++++ app/javascript/mastodon/reducers/index.js | 2 + app/javascript/styles/mastodon/components.scss | 213 +++++++++++ app/javascript/styles/mastodon/forms.scss | 6 + app/lib/entity_cache.rb | 2 +- app/lib/inline_renderer.rb | 4 + app/models/account.rb | 6 + app/models/announcement.rb | 85 +++++ app/models/announcement_filter.rb | 39 ++ app/models/announcement_mute.rb | 19 + app/models/announcement_reaction.rb | 37 ++ app/models/backup.rb | 2 +- app/models/bookmark.rb | 6 +- app/models/concerns/account_interactions.rb | 1 + app/models/custom_emoji.rb | 2 +- app/policies/announcement_policy.rb | 19 + app/serializers/rest/announcement_serializer.rb | 34 ++ app/serializers/rest/reaction_serializer.rb | 31 ++ app/validators/reaction_validator.rb | 17 + .../admin/announcements/_announcement.html.haml | 14 + app/views/admin/announcements/edit.html.haml | 22 ++ app/views/admin/announcements/index.html.haml | 30 ++ app/views/admin/announcements/new.html.haml | 21 ++ .../publish_announcement_reaction_worker.rb | 22 ++ .../publish_scheduled_announcement_worker.rb | 18 + .../scheduler/scheduled_statuses_scheduler.rb | 28 +- 46 files changed, 1477 insertions(+), 20 deletions(-) create mode 100644 app/controllers/admin/announcements_controller.rb create mode 100644 app/controllers/api/v1/announcements/reactions_controller.rb create mode 100644 app/controllers/api/v1/announcements_controller.rb create mode 100644 app/helpers/admin/announcements_helper.rb create mode 100644 app/javascript/mastodon/actions/announcements.js create mode 100644 app/javascript/mastodon/features/getting_started/components/announcements.js create mode 100644 app/javascript/mastodon/features/getting_started/containers/announcements_container.js create mode 100644 app/javascript/mastodon/reducers/announcements.js create mode 100644 app/models/announcement.rb create mode 100644 app/models/announcement_filter.rb create mode 100644 app/models/announcement_mute.rb create mode 100644 app/models/announcement_reaction.rb create mode 100644 app/policies/announcement_policy.rb create mode 100644 app/serializers/rest/announcement_serializer.rb create mode 100644 app/serializers/rest/reaction_serializer.rb create mode 100644 app/validators/reaction_validator.rb create mode 100644 app/views/admin/announcements/_announcement.html.haml create mode 100644 app/views/admin/announcements/edit.html.haml create mode 100644 app/views/admin/announcements/index.html.haml create mode 100644 app/views/admin/announcements/new.html.haml create mode 100644 app/workers/publish_announcement_reaction_worker.rb create mode 100644 app/workers/publish_scheduled_announcement_worker.rb (limited to 'app') diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb new file mode 100644 index 000000000..02198f0b5 --- /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 144fdd6ac..68bf425f4 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 000000000..e4a72e595 --- /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 000000000..6724fac2e --- /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 608a99dd5..6bc75aa56 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 000000000..0c053ddec --- /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 130686a02..6ab92939d 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 a2624d170..ca675c9eb 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 000000000..c65bc052e --- /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 78f321da4..f7cbe4c1c 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 798f9b37e..8a066b896 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 c678e9393..ac325f74c 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 bc2ac5e82..054668655 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 800b1c270..4e1c882e2 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 e57c3c20c..582bb0d39 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 000000000..ee444e3f0 --- /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 ( + {emoji} + ); + } else if (emojiMap.get(emoji)) { + const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']); + const shortCode = `:${emoji}:`; + + return ( + {shortCode} + ); + } 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 000000000..b10d1d4ce --- /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 1df3fb4fe..7a5268780 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 1cafb88ed..b7f9d5095 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 a785551c0..d7f97f210 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 000000000..aa674e516 --- /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 b8d608888..b9817cd38 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 94671c350..922d48ad7 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 8965ce675..65cefbd7c 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 8fff544a0..35a3773d2 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 761a8822d..27e334a4d 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 1e8abe6ec..da6f51a9c 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 000000000..4da9f94d6 --- /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 000000000..950852460 --- /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 000000000..46fda2f5d --- /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 000000000..d22771034 --- /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 8eeb1748a..d242fd62c 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 01dc48ee7..916261a17 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 f27d39483..14bcf7bb1 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 0dacaf654..d177cf281 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 000000000..0a4e4575c --- /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 000000000..924d87b34 --- /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 000000000..1a5dca018 --- /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 000000000..de0f2c94b --- /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 000000000..75768c7ba --- /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 000000000..c5c605e93 --- /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 000000000..634f586fb --- /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 000000000..a5298c5f6 --- /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 000000000..6f3b6dc5b --- /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 000000000..4b2014e34 --- /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 1772a246b..4262f1d01 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 -- cgit