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 filtermaster
parent
81cc86bb1f
commit
f52c988e12
@ -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 |
@ -0,0 +1,29 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class Api::V1::Announcements::ReactionsController < Api::BaseController |
||||
before_action -> { doorkeeper_authorize! :write, :'write:favourites' } |
||||
before_action :require_user! |
||||
|
||||
before_action :set_announcement |
||||
before_action :set_reaction, except: :update |
||||
|
||||
def update |
||||
@announcement.announcement_reactions.create!(account: current_account, name: params[:id]) |
||||
render_empty |
||||
end |
||||
|
||||
def destroy |
||||
@reaction.destroy! |
||||
render_empty |
||||
end |
||||
|
||||
private |
||||
|
||||
def set_reaction |
||||
@reaction = @announcement.announcement_reactions.where(account: current_account).find_by!(name: params[:id]) |
||||
end |
||||
|
||||
def set_announcement |
||||
@announcement = Announcement.published.find(params[:announcement_id]) |
||||
end |
||||
end |
@ -0,0 +1,33 @@ |
||||
# frozen_string_literal: true |
||||
|
||||
class Api::V1::AnnouncementsController < Api::BaseController |
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: :dismiss |
||||
before_action :require_user! |
||||
before_action :set_announcements, only: :index |
||||
before_action :set_announcement, except: :index |
||||
|
||||
def index |
||||
render json: @announcements, each_serializer: REST::AnnouncementSerializer |
||||
end |
||||
|
||||
def dismiss |
||||
AnnouncementMute.create!(account: current_account, announcement: @announcement) |
||||
render_empty |
||||
end |
||||
|
||||
private |
||||
|
||||
def set_announcements |
||||
@announcements = begin |
||||
scope = Announcement.published |
||||
|
||||
scope.merge!(Announcement.without_muted(current_account)) unless truthy_param?(:with_dismissed) |
||||
|
||||
scope.chronological |
||||
end |
||||
end |
||||
|
||||
def set_announcement |
||||
@announcement = Announcement.published.find(params[:id]) |
||||
end |
||||
end |
@ -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 |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@ -0,0 +1,133 @@ |
||||
import api from '../api'; |
||||
import { normalizeAnnouncement } from './importer/normalizer'; |
||||
|
||||
export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST'; |
||||
export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS'; |
||||
export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL'; |
||||
export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE'; |
||||
export const ANNOUNCEMENTS_DISMISS = 'ANNOUNCEMENTS_DISMISS'; |
||||
|
||||
export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST'; |
||||
export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS'; |
||||
export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL'; |
||||
|
||||
export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST'; |
||||
export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS'; |
||||
export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL'; |
||||
|
||||
export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE'; |
||||
|
||||
const noOp = () => {}; |
||||
|
||||
export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => { |
||||
dispatch(fetchAnnouncementsRequest()); |
||||
|
||||
api(getState).get('/api/v1/announcements').then(response => { |
||||
dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x)))); |
||||
}).catch(error => { |
||||
dispatch(fetchAnnouncementsFail(error)); |
||||
}).finally(() => { |
||||
done(); |
||||
}); |
||||
}; |
||||
|
||||
export const fetchAnnouncementsRequest = () => ({ |
||||
type: ANNOUNCEMENTS_FETCH_REQUEST, |
||||
skipLoading: true, |
||||
}); |
||||
|
||||
export const fetchAnnouncementsSuccess = announcements => ({ |
||||
type: ANNOUNCEMENTS_FETCH_SUCCESS, |
||||
announcements, |
||||
skipLoading: true, |
||||
}); |
||||
|
||||
export const fetchAnnouncementsFail= error => ({ |
||||
type: ANNOUNCEMENTS_FETCH_FAIL, |
||||
error, |
||||
skipLoading: true, |
||||
skipAlert: true, |
||||
}); |
||||
|
||||
export const updateAnnouncements = announcement => ({ |
||||
type: ANNOUNCEMENTS_UPDATE, |
||||
announcement: normalizeAnnouncement(announcement), |
||||
}); |
||||
|
||||
export const dismissAnnouncement = announcementId => (dispatch, getState) => { |
||||
dispatch({ |
||||
type: ANNOUNCEMENTS_DISMISS, |
||||
id: announcementId, |
||||
}); |
||||
|
||||
api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`); |
||||
}; |
||||
|
||||
export const addReaction = (announcementId, name) => (dispatch, getState) => { |
||||
dispatch(addReactionRequest(announcementId, name)); |
||||
|
||||
api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { |
||||
dispatch(addReactionSuccess(announcementId, name)); |
||||
}).catch(err => { |
||||
dispatch(addReactionFail(announcementId, name, err)); |
||||
}); |
||||
}; |
||||
|
||||
export const addReactionRequest = (announcementId, name) => ({ |
||||
type: ANNOUNCEMENTS_REACTION_ADD_REQUEST, |
||||
id: announcementId, |
||||
name, |
||||
skipLoading: true, |
||||
}); |
||||
|
||||
export const addReactionSuccess = (announcementId, name) => ({ |
||||
type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS, |
||||
id: announcementId, |
||||
name, |
||||
skipLoading: true, |
||||
}); |
||||
|
||||
export const addReactionFail = (announcementId, name, error) => ({ |
||||
type: ANNOUNCEMENTS_REACTION_ADD_FAIL, |
||||
id: announcementId, |
||||
name, |
||||
error, |
||||
skipLoading: true, |
||||
}); |
||||
|
||||
export const removeReaction = (announcementId, name) => (dispatch, getState) => { |
||||
dispatch(removeReactionRequest(announcementId, name)); |
||||
|
||||
api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { |
||||
dispatch(removeReactionSuccess(announcementId, name)); |
||||
}).catch(err => { |
||||
dispatch(removeReactionFail(announcementId, name, err)); |
||||
}); |
||||
}; |
||||
|
||||
export const removeReactionRequest = (announcementId, name) => ({ |
||||
type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, |
||||
id: announcementId, |
||||
name, |
||||
skipLoading: true, |
||||
}); |
||||
|
||||
export const removeReactionSuccess = (announcementId, name) => ({ |
||||
type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS, |
||||
id: announcementId, |
||||
name, |
||||
skipLoading: true, |
||||
}); |
||||
|
||||
export const removeReactionFail = (announcementId, name, error) => ({ |
||||
type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL, |
||||
id: announcementId, |
||||
name, |
||||
error, |
||||
skipLoading: true, |
||||
}); |
||||
|
||||
export const updateReaction = reaction => ({ |
||||
type: ANNOUNCEMENTS_REACTION_UPDATE, |
||||
reaction, |
||||
}); |
@ -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); |
@ -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; |
||||
} |
||||
}; |