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
					
						
							
								81cc86bb1f
							
						
					
				
			
			
				commit
				
					
						f52c988e12
					
				
			
		
					 65 changed files with 1779 additions and 22 deletions
				
			
		
							
								
								
									
										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, | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue