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 filterlocal
parent
81cc86bb1f
commit
f52c988e12
65 changed files with 1779 additions and 22 deletions
@ -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; |
||||||
|
} |
||||||
|
}; |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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) |
@ -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 |
@ -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 |
||||||
|
|
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -0,0 +1,6 @@ |
|||||||
|
Fabricator(:announcement) do |
||||||
|
text { Faker::Lorem.paragraph(sentence_count: 2) } |
||||||
|
published true |
||||||
|
starts_at nil |
||||||
|
ends_at nil |
||||||
|
end |
@ -0,0 +1,4 @@ |
|||||||
|
Fabricator(:announcement_mute) do |
||||||
|
account |
||||||
|
announcement |
||||||
|
end |
@ -0,0 +1,5 @@ |
|||||||
|
Fabricator(:announcement_reaction) do |
||||||
|
account |
||||||
|
announcement |
||||||
|
name '🌿' |
||||||
|
end |
@ -0,0 +1,4 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
RSpec.describe AnnouncementMute, type: :model do |
||||||
|
end |
@ -0,0 +1,4 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
RSpec.describe AnnouncementReaction, type: :model do |
||||||
|
end |
@ -0,0 +1,4 @@ |
|||||||
|
require 'rails_helper' |
||||||
|
|
||||||
|
RSpec.describe Announcement, type: :model do |
||||||
|
end |
Loading…
Reference in new issue