* Change meaning of /api/v1/announcements/:id/dismiss to mark an announcement as read * Change how unread announcements are counted in UI * Add unread marker to announcements and mark announcements as unread as they are displayed * Fixupsmaster^2
@@ -19,11 +19,7 @@ class Api::V1::AnnouncementsController < Api::BaseController | |||
def set_announcements | |||
@announcements = begin | |||
scope = Announcement.published | |||
scope.merge!(Announcement.without_muted(current_account)) unless truthy_param?(:with_dismissed) | |||
scope.chronological | |||
Announcement.published.chronological | |||
end | |||
end | |||
@@ -7,6 +7,10 @@ export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL'; | |||
export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE'; | |||
export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE'; | |||
export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST'; | |||
export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS'; | |||
export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL'; | |||
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'; | |||
@@ -56,6 +60,32 @@ export const updateAnnouncements = announcement => ({ | |||
announcement: normalizeAnnouncement(announcement), | |||
}); | |||
export const dismissAnnouncement = announcementId => (dispatch, getState) => { | |||
dispatch(dismissAnnouncementRequest(announcementId)); | |||
api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => { | |||
dispatch(dismissAnnouncementSuccess(announcementId)); | |||
}).catch(error => { | |||
dispatch(dismissAnnouncementFail(announcementId, error)); | |||
}); | |||
}; | |||
export const dismissAnnouncementRequest = announcementId => ({ | |||
type: ANNOUNCEMENTS_DISMISS_REQUEST, | |||
id: announcementId, | |||
}); | |||
export const dismissAnnouncementSuccess = announcementId => ({ | |||
type: ANNOUNCEMENTS_DISMISS_SUCCESS, | |||
id: announcementId, | |||
}); | |||
export const dismissAnnouncementFail = (announcementId, error) => ({ | |||
type: ANNOUNCEMENTS_DISMISS_FAIL, | |||
id: announcementId, | |||
error, | |||
}); | |||
export const addReaction = (announcementId, name) => (dispatch, getState) => { | |||
const announcement = getState().getIn(['announcements', 'items']).find(x => x.get('id') === announcementId); | |||
@@ -302,10 +302,23 @@ class Announcement extends ImmutablePureComponent { | |||
addReaction: PropTypes.func.isRequired, | |||
removeReaction: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
selected: PropTypes.bool, | |||
}; | |||
state = { | |||
unread: !this.props.announcement.get('read'), | |||
}; | |||
componentDidUpdate () { | |||
const { selected, announcement } = this.props; | |||
if (!selected && this.state.unread !== !announcement.get('read')) { | |||
this.setState({ unread: !announcement.get('read') }); | |||
} | |||
} | |||
render () { | |||
const { announcement } = this.props; | |||
const { unread } = this.state; | |||
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(); | |||
@@ -330,6 +343,8 @@ class Announcement extends ImmutablePureComponent { | |||
removeReaction={this.props.removeReaction} | |||
emojiMap={this.props.emojiMap} | |||
/> | |||
{unread && <span className='announcements__item__unread' />} | |||
</div> | |||
); | |||
} | |||
@@ -342,6 +357,7 @@ class Announcements extends ImmutablePureComponent { | |||
static propTypes = { | |||
announcements: ImmutablePropTypes.list, | |||
emojiMap: ImmutablePropTypes.map.isRequired, | |||
dismissAnnouncement: PropTypes.func.isRequired, | |||
addReaction: PropTypes.func.isRequired, | |||
removeReaction: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
@@ -351,6 +367,21 @@ class Announcements extends ImmutablePureComponent { | |||
index: 0, | |||
}; | |||
componentDidMount () { | |||
this._markAnnouncementAsRead(); | |||
} | |||
componentDidUpdate () { | |||
this._markAnnouncementAsRead(); | |||
} | |||
_markAnnouncementAsRead () { | |||
const { dismissAnnouncement, announcements } = this.props; | |||
const { index } = this.state; | |||
const announcement = announcements.get(index); | |||
if (!announcement.get('read')) dismissAnnouncement(announcement.get('id')); | |||
} | |||
handleChangeIndex = index => { | |||
this.setState({ index: index % this.props.announcements.size }); | |||
} | |||
@@ -377,7 +408,7 @@ class Announcements extends ImmutablePureComponent { | |||
<div className='announcements__container'> | |||
<ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}> | |||
{announcements.map(announcement => ( | |||
{announcements.map((announcement, idx) => ( | |||
<Announcement | |||
key={announcement.get('id')} | |||
announcement={announcement} | |||
@@ -385,6 +416,7 @@ class Announcements extends ImmutablePureComponent { | |||
addReaction={this.props.addReaction} | |||
removeReaction={this.props.removeReaction} | |||
intl={intl} | |||
selected={index === idx} | |||
/> | |||
))} | |||
</ReactSwipeableViews> | |||
@@ -1,5 +1,5 @@ | |||
import { connect } from 'react-redux'; | |||
import { addReaction, removeReaction } from 'mastodon/actions/announcements'; | |||
import { addReaction, removeReaction, dismissAnnouncement } from 'mastodon/actions/announcements'; | |||
import Announcements from '../components/announcements'; | |||
import { createSelector } from 'reselect'; | |||
import { Map as ImmutableMap } from 'immutable'; | |||
@@ -12,6 +12,7 @@ const mapStateToProps = state => ({ | |||
}); | |||
const mapDispatchToProps = dispatch => ({ | |||
dismissAnnouncement: id => dispatch(dismissAnnouncement(id)), | |||
addReaction: (id, name) => dispatch(addReaction(id, name)), | |||
removeReaction: (id, name) => dispatch(removeReaction(id, name)), | |||
}); | |||
@@ -24,7 +24,7 @@ const mapStateToProps = state => ({ | |||
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, | |||
isPartial: state.getIn(['timelines', 'home', 'isPartial']), | |||
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(), | |||
unreadAnnouncements: state.getIn(['announcements', 'unread']).size, | |||
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')), | |||
showAnnouncements: state.getIn(['announcements', 'show']), | |||
}); | |||
@@ -10,14 +10,14 @@ import { | |||
ANNOUNCEMENTS_REACTION_REMOVE_FAIL, | |||
ANNOUNCEMENTS_TOGGLE_SHOW, | |||
ANNOUNCEMENTS_DELETE, | |||
ANNOUNCEMENTS_DISMISS_SUCCESS, | |||
} from '../actions/announcements'; | |||
import { Map as ImmutableMap, List as ImmutableList, Set as ImmutableSet, fromJS } from 'immutable'; | |||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; | |||
const initialState = ImmutableMap({ | |||
items: ImmutableList(), | |||
isLoading: false, | |||
show: false, | |||
unread: ImmutableSet(), | |||
}); | |||
const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => { | |||
@@ -42,24 +42,11 @@ const addReaction = (state, id, name) => updateReaction(state, id, name, x => x. | |||
const removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).update('count', y => y - 1)); | |||
const addUnread = (state, items) => { | |||
if (state.get('show')) { | |||
return state; | |||
} | |||
const newIds = ImmutableSet(items.map(x => x.get('id'))); | |||
const oldIds = ImmutableSet(state.get('items').map(x => x.get('id'))); | |||
return state.update('unread', unread => unread.union(newIds.subtract(oldIds))); | |||
}; | |||
const sortAnnouncements = list => list.sortBy(x => x.get('starts_at') || x.get('published_at')); | |||
const updateAnnouncement = (state, announcement) => { | |||
const idx = state.get('items').findIndex(x => x.get('id') === announcement.get('id')); | |||
state = addUnread(state, [announcement]); | |||
if (idx > -1) { | |||
// Deep merge is used because announcements from the streaming API do not contain | |||
// personalized data about which reactions have been selected by the given user, | |||
@@ -74,7 +61,6 @@ export default function announcementsReducer(state = initialState, action) { | |||
switch(action.type) { | |||
case ANNOUNCEMENTS_TOGGLE_SHOW: | |||
return state.withMutations(map => { | |||
if (!map.get('show')) map.set('unread', ImmutableSet()); | |||
map.set('show', !map.get('show')); | |||
}); | |||
case ANNOUNCEMENTS_FETCH_REQUEST: | |||
@@ -83,10 +69,6 @@ export default function announcementsReducer(state = initialState, action) { | |||
return state.withMutations(map => { | |||
const items = fromJS(action.announcements); | |||
map.set('unread', ImmutableSet()); | |||
addUnread(map, items); | |||
map.set('items', items); | |||
map.set('isLoading', false); | |||
}); | |||
@@ -102,8 +84,10 @@ export default function announcementsReducer(state = initialState, action) { | |||
case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST: | |||
case ANNOUNCEMENTS_REACTION_ADD_FAIL: | |||
return removeReaction(state, action.id, action.name); | |||
case ANNOUNCEMENTS_DISMISS_SUCCESS: | |||
return updateAnnouncement(state, fromJS({ 'id': action.id, 'read': true })); | |||
case ANNOUNCEMENTS_DELETE: | |||
return state.update('unread', set => set.delete(action.id)).update('items', list => { | |||
return state.update('items', list => { | |||
const idx = list.findIndex(x => x.get('id') === action.id); | |||
if (idx > -1) { | |||
@@ -6694,6 +6694,18 @@ noscript { | |||
font-weight: 500; | |||
margin-bottom: 10px; | |||
} | |||
&__unread { | |||
position: absolute; | |||
top: 15px; | |||
right: 15px; | |||
display: inline-block; | |||
background: $highlight-text-color; | |||
border-radius: 50%; | |||
width: 0.625rem; | |||
height: 0.625rem; | |||
margin: 0 .15em; | |||
} | |||
} | |||
&__pagination { | |||
@@ -4,15 +4,25 @@ class REST::AnnouncementSerializer < ActiveModel::Serializer | |||
attributes :id, :content, :starts_at, :ends_at, :all_day, | |||
:published_at, :updated_at | |||
attribute :read, if: :current_user? | |||
has_many :mentions | |||
has_many :tags, serializer: REST::StatusSerializer::TagSerializer | |||
has_many :emojis, serializer: REST::CustomEmojiSerializer | |||
has_many :reactions, serializer: REST::ReactionSerializer | |||
def current_user? | |||
!current_user.nil? | |||
end | |||
def id | |||
object.id.to_s | |||
end | |||
def read | |||
object.announcement_mutes.where(account: current_user.account).exists? | |||
end | |||
def content | |||
Formatter.instance.linkify(object.text) | |||
end | |||