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
This commit is contained in:
		
							parent
							
								
									930ba0630f
								
							
						
					
					
						commit
						f95fd62bac
					
				
					 65 changed files with 1779 additions and 22 deletions
				
			
		
							
								
								
									
										69
									
								
								app/controllers/admin/announcements_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								app/controllers/admin/announcements_controller.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,69 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class Admin::AnnouncementsController < Admin::BaseController | ||||
|   before_action :set_announcements, only: :index | ||||
|   before_action :set_announcement, except: [:index, :new, :create] | ||||
| 
 | ||||
|   def index | ||||
|     authorize :announcement, :index? | ||||
|   end | ||||
| 
 | ||||
|   def new | ||||
|     authorize :announcement, :create? | ||||
| 
 | ||||
|     @announcement = Announcement.new | ||||
|   end | ||||
| 
 | ||||
|   def create | ||||
|     authorize :announcement, :create? | ||||
| 
 | ||||
|     @announcement = Announcement.new(resource_params) | ||||
| 
 | ||||
|     if @announcement.save | ||||
|       log_action :create, @announcement | ||||
|       redirect_to admin_announcements_path | ||||
|     else | ||||
|       render :new | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def edit | ||||
|     authorize :announcement, :update? | ||||
|   end | ||||
| 
 | ||||
|   def update | ||||
|     authorize :announcement, :update? | ||||
| 
 | ||||
|     if @announcement.update(resource_params) | ||||
|       log_action :update, @announcement | ||||
|       redirect_to admin_announcements_path | ||||
|     else | ||||
|       render :edit | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def destroy | ||||
|     authorize :announcement, :destroy? | ||||
|     @announcement.destroy! | ||||
|     log_action :destroy, @announcement | ||||
|     redirect_to admin_announcements_path | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def set_announcements | ||||
|     @announcements = AnnouncementFilter.new(filter_params).results.page(params[:page]) | ||||
|   end | ||||
| 
 | ||||
|   def set_announcement | ||||
|     @announcement = Announcement.find(params[:id]) | ||||
|   end | ||||
| 
 | ||||
|   def filter_params | ||||
|     params.slice(*AnnouncementFilter::KEYS).permit(*AnnouncementFilter::KEYS) | ||||
|   end | ||||
| 
 | ||||
|   def resource_params | ||||
|     params.require(:announcement).permit(:text, :scheduled_at, :starts_at, :ends_at, :all_day) | ||||
|   end | ||||
| end | ||||
|  | @ -85,7 +85,7 @@ class Api::BaseController < ApplicationController | |||
|   end | ||||
| 
 | ||||
|   def require_authenticated_user! | ||||
|     render json: { error: 'This API requires an authenticated user' }, status: 401 unless current_user | ||||
|     render json: { error: 'This method requires an authenticated user' }, status: 401 unless current_user | ||||
|   end | ||||
| 
 | ||||
|   def require_user! | ||||
|  |  | |||
							
								
								
									
										29
									
								
								app/controllers/api/v1/announcements/reactions_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								app/controllers/api/v1/announcements/reactions_controller.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										33
									
								
								app/controllers/api/v1/announcements_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								app/controllers/api/v1/announcements_controller.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,33 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class Api::V1::AnnouncementsController < Api::BaseController | ||||
|   before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: :dismiss | ||||
|   before_action :require_user! | ||||
|   before_action :set_announcements, only: :index | ||||
|   before_action :set_announcement, except: :index | ||||
| 
 | ||||
|   def index | ||||
|     render json: @announcements, each_serializer: REST::AnnouncementSerializer | ||||
|   end | ||||
| 
 | ||||
|   def dismiss | ||||
|     AnnouncementMute.create!(account: current_account, announcement: @announcement) | ||||
|     render_empty | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def set_announcements | ||||
|     @announcements = begin | ||||
|       scope = Announcement.published | ||||
| 
 | ||||
|       scope.merge!(Announcement.without_muted(current_account)) unless truthy_param?(:with_dismissed) | ||||
| 
 | ||||
|       scope.chronological | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def set_announcement | ||||
|     @announcement = Announcement.published.find(params[:id]) | ||||
|   end | ||||
| end | ||||
|  | @ -22,6 +22,8 @@ module Admin::ActionLogsHelper | |||
|       log.recorded_changes.slice('severity', 'reject_media') | ||||
|     elsif log.target_type == 'Status' && log.action == :update | ||||
|       log.recorded_changes.slice('sensitive') | ||||
|     elsif log.target_type == 'Announcement' && log.action == :update | ||||
|       log.recorded_changes.slice('text', 'starts_at', 'ends_at', 'all_day') | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|  | @ -52,6 +54,8 @@ module Admin::ActionLogsHelper | |||
|       'pencil' | ||||
|     when 'AccountWarning' | ||||
|       'warning' | ||||
|     when 'Announcement' | ||||
|       'bullhorn' | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|  | @ -94,6 +98,8 @@ module Admin::ActionLogsHelper | |||
|       link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record) | ||||
|     when 'AccountWarning' | ||||
|       link_to record.target_account.acct, admin_account_path(record.target_account_id) | ||||
|     when 'Announcement' | ||||
|       link_to "##{record.id}", edit_admin_announcement_path(record.id) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|  | @ -111,6 +117,8 @@ module Admin::ActionLogsHelper | |||
|       else | ||||
|         I18n.t('admin.action_logs.deleted_status') | ||||
|       end | ||||
|     when 'Announcement' | ||||
|       "##{attributes['id']}" | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  |  | |||
							
								
								
									
										11
									
								
								app/helpers/admin/announcements_helper.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								app/helpers/admin/announcements_helper.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Admin::AnnouncementsHelper | ||||
|   def time_range(announcement) | ||||
|     if announcement.all_day? | ||||
|       safe_join([l(announcement.starts_at.to_date), ' - ', l(announcement.ends_at.to_date)]) | ||||
|     else | ||||
|       safe_join([l(announcement.starts_at), ' - ', l(announcement.ends_at)]) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -9,6 +9,7 @@ module Admin::FilterHelper | |||
|     InstanceFilter::KEYS, | ||||
|     InviteFilter::KEYS, | ||||
|     RelationshipFilter::KEYS, | ||||
|     AnnouncementFilter::KEYS, | ||||
|   ].flatten.freeze | ||||
| 
 | ||||
|   def filter_link_to(text, link_to_params, link_class_params = link_to_params) | ||||
|  |  | |||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB | 
							
								
								
									
										133
									
								
								app/javascript/mastodon/actions/announcements.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								app/javascript/mastodon/actions/announcements.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,133 @@ | |||
| import api from '../api'; | ||||
| import { normalizeAnnouncement } from './importer/normalizer'; | ||||
| 
 | ||||
| export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST'; | ||||
| export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS'; | ||||
| export const ANNOUNCEMENTS_FETCH_FAIL    = 'ANNOUNCEMENTS_FETCH_FAIL'; | ||||
| export const ANNOUNCEMENTS_UPDATE        = 'ANNOUNCEMENTS_UPDATE'; | ||||
| export const ANNOUNCEMENTS_DISMISS       = 'ANNOUNCEMENTS_DISMISS'; | ||||
| 
 | ||||
| export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST'; | ||||
| export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS'; | ||||
| export const ANNOUNCEMENTS_REACTION_ADD_FAIL    = 'ANNOUNCEMENTS_REACTION_ADD_FAIL'; | ||||
| 
 | ||||
| export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST'; | ||||
| export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS'; | ||||
| export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL    = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL'; | ||||
| 
 | ||||
| export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE'; | ||||
| 
 | ||||
| const noOp = () => {}; | ||||
| 
 | ||||
| export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => { | ||||
|   dispatch(fetchAnnouncementsRequest()); | ||||
| 
 | ||||
|   api(getState).get('/api/v1/announcements').then(response => { | ||||
|     dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x)))); | ||||
|   }).catch(error => { | ||||
|     dispatch(fetchAnnouncementsFail(error)); | ||||
|   }).finally(() => { | ||||
|     done(); | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| export const fetchAnnouncementsRequest = () => ({ | ||||
|   type: ANNOUNCEMENTS_FETCH_REQUEST, | ||||
|   skipLoading: true, | ||||
| }); | ||||
| 
 | ||||
| export const fetchAnnouncementsSuccess = announcements => ({ | ||||
|   type: ANNOUNCEMENTS_FETCH_SUCCESS, | ||||
|   announcements, | ||||
|   skipLoading: true, | ||||
| }); | ||||
| 
 | ||||
| export const fetchAnnouncementsFail= error => ({ | ||||
|   type: ANNOUNCEMENTS_FETCH_FAIL, | ||||
|   error, | ||||
|   skipLoading: true, | ||||
|   skipAlert: true, | ||||
| }); | ||||
| 
 | ||||
| export const updateAnnouncements = announcement => ({ | ||||
|   type: ANNOUNCEMENTS_UPDATE, | ||||
|   announcement: normalizeAnnouncement(announcement), | ||||
| }); | ||||
| 
 | ||||
| export const dismissAnnouncement = announcementId => (dispatch, getState) => { | ||||
|   dispatch({ | ||||
|     type: ANNOUNCEMENTS_DISMISS, | ||||
|     id: announcementId, | ||||
|   }); | ||||
| 
 | ||||
|   api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`); | ||||
| }; | ||||
| 
 | ||||
| export const addReaction = (announcementId, name) => (dispatch, getState) => { | ||||
|   dispatch(addReactionRequest(announcementId, name)); | ||||
| 
 | ||||
|   api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { | ||||
|     dispatch(addReactionSuccess(announcementId, name)); | ||||
|   }).catch(err => { | ||||
|     dispatch(addReactionFail(announcementId, name, err)); | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| export const addReactionRequest = (announcementId, name) => ({ | ||||
|   type: ANNOUNCEMENTS_REACTION_ADD_REQUEST, | ||||
|   id: announcementId, | ||||
|   name, | ||||
|   skipLoading: true, | ||||
| }); | ||||
| 
 | ||||
| export const addReactionSuccess = (announcementId, name) => ({ | ||||
|   type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS, | ||||
|   id: announcementId, | ||||
|   name, | ||||
|   skipLoading: true, | ||||
| }); | ||||
| 
 | ||||
| export const addReactionFail = (announcementId, name, error) => ({ | ||||
|   type: ANNOUNCEMENTS_REACTION_ADD_FAIL, | ||||
|   id: announcementId, | ||||
|   name, | ||||
|   error, | ||||
|   skipLoading: true, | ||||
| }); | ||||
| 
 | ||||
| export const removeReaction = (announcementId, name) => (dispatch, getState) => { | ||||
|   dispatch(removeReactionRequest(announcementId, name)); | ||||
| 
 | ||||
|   api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { | ||||
|     dispatch(removeReactionSuccess(announcementId, name)); | ||||
|   }).catch(err => { | ||||
|     dispatch(removeReactionFail(announcementId, name, err)); | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| export const removeReactionRequest = (announcementId, name) => ({ | ||||
|   type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, | ||||
|   id: announcementId, | ||||
|   name, | ||||
|   skipLoading: true, | ||||
| }); | ||||
| 
 | ||||
| export const removeReactionSuccess = (announcementId, name) => ({ | ||||
|   type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS, | ||||
|   id: announcementId, | ||||
|   name, | ||||
|   skipLoading: true, | ||||
| }); | ||||
| 
 | ||||
| export const removeReactionFail = (announcementId, name, error) => ({ | ||||
|   type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL, | ||||
|   id: announcementId, | ||||
|   name, | ||||
|   error, | ||||
|   skipLoading: true, | ||||
| }); | ||||
| 
 | ||||
| export const updateReaction = reaction => ({ | ||||
|   type: ANNOUNCEMENTS_REACTION_UPDATE, | ||||
|   reaction, | ||||
| }); | ||||
|  | @ -76,7 +76,6 @@ export function normalizeStatus(status, normalOldStatus) { | |||
| 
 | ||||
| export function normalizePoll(poll) { | ||||
|   const normalPoll = { ...poll }; | ||||
| 
 | ||||
|   const emojiMap = makeEmojiMap(normalPoll); | ||||
| 
 | ||||
|   normalPoll.options = poll.options.map((option, index) => ({ | ||||
|  | @ -87,3 +86,12 @@ export function normalizePoll(poll) { | |||
| 
 | ||||
|   return normalPoll; | ||||
| } | ||||
| 
 | ||||
| export function normalizeAnnouncement(announcement) { | ||||
|   const normalAnnouncement = { ...announcement }; | ||||
|   const emojiMap = makeEmojiMap(normalAnnouncement); | ||||
| 
 | ||||
|   normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); | ||||
| 
 | ||||
|   return normalAnnouncement; | ||||
| } | ||||
|  |  | |||
|  | @ -157,9 +157,9 @@ export function expandNotifications({ maxId } = {}, done = noOp) { | |||
| 
 | ||||
|       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); | ||||
|       fetchRelatedRelationships(dispatch, response.data); | ||||
|       done(); | ||||
|     }).catch(error => { | ||||
|       dispatch(expandNotificationsFail(error, isLoadingMore)); | ||||
|     }).finally(() => { | ||||
|       done(); | ||||
|     }); | ||||
|   }; | ||||
|  | @ -188,6 +188,7 @@ export function expandNotificationsFail(error, isLoadingMore) { | |||
|     type: NOTIFICATIONS_EXPAND_FAIL, | ||||
|     error, | ||||
|     skipLoading: !isLoadingMore, | ||||
|     skipAlert: !isLoadingMore, | ||||
|   }; | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -8,6 +8,7 @@ import { | |||
| } from './timelines'; | ||||
| import { updateNotifications, expandNotifications } from './notifications'; | ||||
| import { updateConversations } from './conversations'; | ||||
| import { fetchAnnouncements, updateAnnouncements, updateReaction as updateAnnouncementsReaction } from './announcements'; | ||||
| import { fetchFilters } from './filters'; | ||||
| import { getLocale } from '../locales'; | ||||
| 
 | ||||
|  | @ -44,6 +45,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, | |||
|         case 'filters_changed': | ||||
|           dispatch(fetchFilters()); | ||||
|           break; | ||||
|         case 'announcement': | ||||
|           dispatch(updateAnnouncements(JSON.parse(data.payload))); | ||||
|           break; | ||||
|         case 'announcement.reaction': | ||||
|           dispatch(updateAnnouncementsReaction(JSON.parse(data.payload))); | ||||
|           break; | ||||
|         } | ||||
|       }, | ||||
|     }; | ||||
|  | @ -51,7 +58,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, | |||
| } | ||||
| 
 | ||||
| const refreshHomeTimelineAndNotification = (dispatch, done) => { | ||||
|   dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done)))); | ||||
|   dispatch(expandHomeTimeline({}, () => | ||||
|     dispatch(expandNotifications({}, () => | ||||
|       dispatch(fetchAnnouncements(done)))))); | ||||
| }; | ||||
| 
 | ||||
| export const connectUserStream      = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); | ||||
|  |  | |||
|  | @ -98,9 +98,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { | |||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||
|       dispatch(importFetchedStatuses(response.data)); | ||||
|       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); | ||||
|       done(); | ||||
|     }).catch(error => { | ||||
|       dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); | ||||
|     }).finally(() => { | ||||
|       done(); | ||||
|     }); | ||||
|   }; | ||||
|  |  | |||
|  | @ -58,7 +58,7 @@ export default class ErrorBoundary extends React.PureComponent { | |||
|         <div> | ||||
|           <p className='error-boundary__error'><FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /></p> | ||||
|           <p><FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /></p> | ||||
|           <p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied && 'copied'}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p> | ||||
|           <p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|  |  | |||
|  | @ -290,6 +290,7 @@ class EmojiPickerDropdown extends React.PureComponent { | |||
|     onPickEmoji: PropTypes.func.isRequired, | ||||
|     onSkinTone: PropTypes.func.isRequired, | ||||
|     skinTone: PropTypes.number.isRequired, | ||||
|     button: PropTypes.node, | ||||
|   }; | ||||
| 
 | ||||
|   state = { | ||||
|  | @ -350,18 +351,18 @@ class EmojiPickerDropdown extends React.PureComponent { | |||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; | ||||
|     const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props; | ||||
|     const title = intl.formatMessage(messages.emoji); | ||||
|     const { active, loading, placement } = this.state; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> | ||||
|         <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> | ||||
|           <img | ||||
|           {button || <img | ||||
|             className={classNames('emojione', { 'pulse-loading': active && loading })} | ||||
|             alt='🙂' | ||||
|             src={`${assetHost}/emoji/1f602.svg`} | ||||
|           /> | ||||
|           />} | ||||
|         </div> | ||||
| 
 | ||||
|         <Overlay show={active} placement={placement} target={this.findTarget}> | ||||
|  |  | |||
|  | @ -0,0 +1,395 @@ | |||
| import React from 'react'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import ReactSwipeableViews from 'react-swipeable-views'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import IconButton from 'mastodon/components/icon_button'; | ||||
| import Icon from 'mastodon/components/icon'; | ||||
| import { defineMessages, injectIntl, FormattedMessage, FormattedDate, FormattedNumber } from 'react-intl'; | ||||
| import { autoPlayGif } from 'mastodon/initial_state'; | ||||
| import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg'; | ||||
| import { mascot } from 'mastodon/initial_state'; | ||||
| import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light'; | ||||
| import classNames from 'classnames'; | ||||
| import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_picker_dropdown_container'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   close: { id: 'lightbox.close', defaultMessage: 'Close' }, | ||||
|   previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, | ||||
|   next: { id: 'lightbox.next', defaultMessage: 'Next' }, | ||||
| }); | ||||
| 
 | ||||
| class Content extends ImmutablePureComponent { | ||||
| 
 | ||||
|   static contextTypes = { | ||||
|     router: PropTypes.object, | ||||
|   }; | ||||
| 
 | ||||
|   static propTypes = { | ||||
|     announcement: ImmutablePropTypes.map.isRequired, | ||||
|   }; | ||||
| 
 | ||||
|   setRef = c => { | ||||
|     this.node = c; | ||||
|   } | ||||
| 
 | ||||
|   componentDidMount () { | ||||
|     this._updateLinks(); | ||||
|     this._updateEmojis(); | ||||
|   } | ||||
| 
 | ||||
|   componentDidUpdate () { | ||||
|     this._updateLinks(); | ||||
|     this._updateEmojis(); | ||||
|   } | ||||
| 
 | ||||
|   _updateEmojis () { | ||||
|     const node = this.node; | ||||
| 
 | ||||
|     if (!node || autoPlayGif) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const emojis = node.querySelectorAll('.custom-emoji'); | ||||
| 
 | ||||
|     for (var i = 0; i < emojis.length; i++) { | ||||
|       let emoji = emojis[i]; | ||||
| 
 | ||||
|       if (emoji.classList.contains('status-emoji')) { | ||||
|         continue; | ||||
|       } | ||||
| 
 | ||||
|       emoji.classList.add('status-emoji'); | ||||
| 
 | ||||
|       emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false); | ||||
|       emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   _updateLinks () { | ||||
|     const node = this.node; | ||||
| 
 | ||||
|     if (!node) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const links = node.querySelectorAll('a'); | ||||
| 
 | ||||
|     for (var i = 0; i < links.length; ++i) { | ||||
|       let link = links[i]; | ||||
| 
 | ||||
|       if (link.classList.contains('status-link')) { | ||||
|         continue; | ||||
|       } | ||||
| 
 | ||||
|       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 ( | ||||
|       <div | ||||
|         className='announcements__item__content' | ||||
|         ref={this.setRef} | ||||
|         dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }} | ||||
|       /> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 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 ( | ||||
|         <img | ||||
|           draggable='false' | ||||
|           className='emojione' | ||||
|           alt={emoji} | ||||
|           title={title} | ||||
|           src={`${assetHost}/emoji/${filename}.svg`} | ||||
|         /> | ||||
|       ); | ||||
|     } else if (emojiMap.get(emoji)) { | ||||
|       const filename  = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']); | ||||
|       const shortCode = `:${emoji}:`; | ||||
| 
 | ||||
|       return ( | ||||
|         <img | ||||
|           draggable='false' | ||||
|           className='emojione custom-emoji' | ||||
|           alt={shortCode} | ||||
|           title={shortCode} | ||||
|           src={filename} | ||||
|         /> | ||||
|       ); | ||||
|     } 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 ( | ||||
|       <button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`}> | ||||
|         <span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span> | ||||
|         <span className='reactions-bar__item__count'><FormattedNumber value={reaction.get('count')} /></span> | ||||
|       </button> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 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 ( | ||||
|       <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}> | ||||
|         {visibleReactions.map(reaction => ( | ||||
|           <Reaction | ||||
|             key={reaction.get('name')} | ||||
|             reaction={reaction} | ||||
|             announcementId={this.props.announcementId} | ||||
|             addReaction={this.props.addReaction} | ||||
|             removeReaction={this.props.removeReaction} | ||||
|             emojiMap={this.props.emojiMap} | ||||
|           /> | ||||
|         ))} | ||||
| 
 | ||||
|         <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} /> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 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 ( | ||||
|       <div className='announcements__item'> | ||||
|         <strong className='announcements__item__range'> | ||||
|           <FormattedMessage id='announcement.announcement' defaultMessage='Announcement' /> | ||||
|           {hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>} | ||||
|         </strong> | ||||
| 
 | ||||
|         <Content announcement={announcement} /> | ||||
| 
 | ||||
|         <ReactionsBar | ||||
|           reactions={announcement.get('reactions')} | ||||
|           announcementId={announcement.get('id')} | ||||
|           addReaction={this.props.addReaction} | ||||
|           removeReaction={this.props.removeReaction} | ||||
|           emojiMap={this.props.emojiMap} | ||||
|         /> | ||||
| 
 | ||||
|         <IconButton title={intl.formatMessage(messages.close)} icon='times' className='announcements__item__dismiss-icon' onClick={this.handleDismissClick} /> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| 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 ( | ||||
|       <div className='announcements'> | ||||
|         <img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} /> | ||||
| 
 | ||||
|         <div className='announcements__container'> | ||||
|           <ReactSwipeableViews index={index} onChangeIndex={this.handleChangeIndex}> | ||||
|             {announcements.map(announcement => ( | ||||
|               <Announcement | ||||
|                 key={announcement.get('id')} | ||||
|                 announcement={announcement} | ||||
|                 emojiMap={this.props.emojiMap} | ||||
|                 dismissAnnouncement={this.props.dismissAnnouncement} | ||||
|                 addReaction={this.props.addReaction} | ||||
|                 removeReaction={this.props.removeReaction} | ||||
|                 intl={intl} | ||||
|               /> | ||||
|             ))} | ||||
|           </ReactSwipeableViews> | ||||
| 
 | ||||
|           <div className='announcements__pagination'> | ||||
|             <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} /> | ||||
|             <span>{index + 1} / {announcements.size}</span> | ||||
|             <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|  | @ -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); | ||||
|  | @ -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 => ({ | ||||
|  |  | |||
|  | @ -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 { | |||
|         </ColumnHeader> | ||||
| 
 | ||||
|         <StatusListContainer | ||||
|           prepend={<AnnouncementsContainer />} | ||||
|           alwaysPrepend | ||||
|           trackScroll={!pinned} | ||||
|           scrollKey={`home_timeline-${columnId}`} | ||||
|           onLoadMore={this.handleLoadMore} | ||||
|  |  | |||
|  | @ -211,7 +211,6 @@ class MediaModal extends ImmutablePureComponent { | |||
|             style={swipeableViewsStyle} | ||||
|             containerStyle={containerStyle} | ||||
|             onChangeIndex={this.handleSwipe} | ||||
|             onSwitching={this.handleSwitching} | ||||
|             index={index} | ||||
|           > | ||||
|             {content} | ||||
|  |  | |||
							
								
								
									
										72
									
								
								app/javascript/mastodon/reducers/announcements.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								app/javascript/mastodon/reducers/announcements.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -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; | ||||
|   } | ||||
| }; | ||||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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%); | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
							
								
								
									
										85
									
								
								app/models/announcement.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								app/models/announcement.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										39
									
								
								app/models/announcement_filter.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/models/announcement_filter.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										19
									
								
								app/models/announcement_mute.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/models/announcement_mute.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										37
									
								
								app/models/announcement_reaction.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								app/models/announcement_reaction.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
							
								
								
									
										19
									
								
								app/policies/announcement_policy.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/policies/announcement_policy.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										34
									
								
								app/serializers/rest/announcement_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								app/serializers/rest/announcement_serializer.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										31
									
								
								app/serializers/rest/reaction_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								app/serializers/rest/reaction_serializer.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										17
									
								
								app/validators/reaction_validator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/validators/reaction_validator.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										14
									
								
								app/views/admin/announcements/_announcement.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								app/views/admin/announcements/_announcement.html.haml
									
									
									
									
									
										Normal file
									
								
							|  | @ -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) | ||||
							
								
								
									
										22
									
								
								app/views/admin/announcements/edit.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/views/admin/announcements/edit.html.haml
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										30
									
								
								app/views/admin/announcements/index.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								app/views/admin/announcements/index.html.haml
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
| 
 | ||||
							
								
								
									
										21
									
								
								app/views/admin/announcements/new.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/views/admin/announcements/new.html.haml
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										22
									
								
								app/workers/publish_announcement_reaction_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/workers/publish_announcement_reaction_worker.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										18
									
								
								app/workers/publish_scheduled_announcement_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/workers/publish_scheduled_announcement_worker.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
| 
 | ||||
|  |  | |||
|  | @ -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: "<strong>Tip:</strong> 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 | ||||
|  |  | |||
|  | @ -14,6 +14,12 @@ en: | |||
|         text_html: Optional. You can use toot syntax. You can <a href="%{path}">add warning presets</a> to save time | ||||
|         type_html: Choose what to do with <strong>%{acct}</strong> | ||||
|         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 | ||||
|  |  | |||
|  | @ -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? } | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
							
								
								
									
										16
									
								
								db/migrate/20191218153258_create_announcements.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								db/migrate/20191218153258_create_announcements.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										12
									
								
								db/migrate/20200113125135_create_announcement_mutes.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								db/migrate/20200113125135_create_announcement_mutes.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										15
									
								
								db/migrate/20200114113335_create_announcement_reactions.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								db/migrate/20200114113335_create_announcement_reactions.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										41
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										41
									
								
								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 | ||||
|  |  | |||
|  | @ -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', | ||||
|  |  | |||
|  | @ -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 | ||||
							
								
								
									
										59
									
								
								spec/controllers/api/v1/announcements_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								spec/controllers/api/v1/announcements_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										18
									
								
								spec/controllers/api/v1/trends_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								spec/controllers/api/v1/trends_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										6
									
								
								spec/fabricators/announcement_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								spec/fabricators/announcement_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| Fabricator(:announcement) do | ||||
|   text      { Faker::Lorem.paragraph(sentence_count: 2) } | ||||
|   published true | ||||
|   starts_at nil | ||||
|   ends_at   nil | ||||
| end | ||||
							
								
								
									
										4
									
								
								spec/fabricators/announcement_mute_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								spec/fabricators/announcement_mute_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| Fabricator(:announcement_mute) do | ||||
|   account | ||||
|   announcement | ||||
| end | ||||
							
								
								
									
										5
									
								
								spec/fabricators/announcement_reaction_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								spec/fabricators/announcement_reaction_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| Fabricator(:announcement_reaction) do | ||||
|   account | ||||
|   announcement | ||||
|   name '🌿' | ||||
| end | ||||
							
								
								
									
										4
									
								
								spec/models/announcement_mute_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								spec/models/announcement_mute_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| require 'rails_helper' | ||||
| 
 | ||||
| RSpec.describe AnnouncementMute, type: :model do | ||||
| end | ||||
							
								
								
									
										4
									
								
								spec/models/announcement_reaction_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								spec/models/announcement_reaction_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| require 'rails_helper' | ||||
| 
 | ||||
| RSpec.describe AnnouncementReaction, type: :model do | ||||
| end | ||||
							
								
								
									
										4
									
								
								spec/models/announcement_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								spec/models/announcement_spec.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| require 'rails_helper' | ||||
| 
 | ||||
| RSpec.describe Announcement, type: :model do | ||||
| end | ||||
		Loading…
	
		Reference in a new issue