Improve announcements design (#12954)
* Move announcements above scroll container; add button to temporarily hide them * Remove interface for dismissing announcements * Display number of unread announcements * Count unread announcements accurately * Fix size of announcement box not fitting the currently displayed announcement * Fix announcement box background color to match button color
This commit is contained in:
parent
ae2198bd95
commit
48c55b6392
6 changed files with 71 additions and 47 deletions
|
@ -5,7 +5,6 @@ export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';
|
||||||
export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS';
|
export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS';
|
||||||
export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL';
|
export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL';
|
||||||
export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE';
|
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_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
|
||||||
export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
|
export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
|
||||||
|
@ -17,6 +16,8 @@ export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REM
|
||||||
|
|
||||||
export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE';
|
export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE';
|
||||||
|
|
||||||
|
export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW';
|
||||||
|
|
||||||
const noOp = () => {};
|
const noOp = () => {};
|
||||||
|
|
||||||
export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => {
|
export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => {
|
||||||
|
@ -54,15 +55,6 @@ export const updateAnnouncements = announcement => ({
|
||||||
announcement: normalizeAnnouncement(announcement),
|
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) => {
|
export const addReaction = (announcementId, name) => (dispatch, getState) => {
|
||||||
dispatch(addReactionRequest(announcementId, name));
|
dispatch(addReactionRequest(announcementId, name));
|
||||||
|
|
||||||
|
@ -131,3 +123,9 @@ export const updateReaction = reaction => ({
|
||||||
type: ANNOUNCEMENTS_REACTION_UPDATE,
|
type: ANNOUNCEMENTS_REACTION_UPDATE,
|
||||||
reaction,
|
reaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function toggleShowAnnouncements() {
|
||||||
|
return dispatch => {
|
||||||
|
dispatch({ type: ANNOUNCEMENTS_TOGGLE_SHOW });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -277,19 +277,13 @@ class Announcement extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
announcement: ImmutablePropTypes.map.isRequired,
|
announcement: ImmutablePropTypes.map.isRequired,
|
||||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||||
dismissAnnouncement: PropTypes.func.isRequired,
|
|
||||||
addReaction: PropTypes.func.isRequired,
|
addReaction: PropTypes.func.isRequired,
|
||||||
removeReaction: PropTypes.func.isRequired,
|
removeReaction: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleDismissClick = () => {
|
|
||||||
const { dismissAnnouncement, announcement } = this.props;
|
|
||||||
dismissAnnouncement(announcement.get('id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { announcement, intl } = this.props;
|
const { announcement } = this.props;
|
||||||
const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
|
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 endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
@ -314,8 +308,6 @@ class Announcement extends ImmutablePureComponent {
|
||||||
removeReaction={this.props.removeReaction}
|
removeReaction={this.props.removeReaction}
|
||||||
emojiMap={this.props.emojiMap}
|
emojiMap={this.props.emojiMap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<IconButton title={intl.formatMessage(messages.close)} icon='times' className='announcements__item__dismiss-icon' onClick={this.handleDismissClick} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -328,8 +320,6 @@ class Announcements extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
announcements: ImmutablePropTypes.list,
|
announcements: ImmutablePropTypes.list,
|
||||||
emojiMap: ImmutablePropTypes.map.isRequired,
|
emojiMap: ImmutablePropTypes.map.isRequired,
|
||||||
fetchAnnouncements: PropTypes.func.isRequired,
|
|
||||||
dismissAnnouncement: PropTypes.func.isRequired,
|
|
||||||
addReaction: PropTypes.func.isRequired,
|
addReaction: PropTypes.func.isRequired,
|
||||||
removeReaction: PropTypes.func.isRequired,
|
removeReaction: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
|
@ -339,11 +329,6 @@ class Announcements extends ImmutablePureComponent {
|
||||||
index: 0,
|
index: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
const { fetchAnnouncements } = this.props;
|
|
||||||
fetchAnnouncements();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChangeIndex = index => {
|
handleChangeIndex = index => {
|
||||||
this.setState({ index: index % this.props.announcements.size });
|
this.setState({ index: index % this.props.announcements.size });
|
||||||
}
|
}
|
||||||
|
@ -369,13 +354,12 @@ class Announcements extends ImmutablePureComponent {
|
||||||
<img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
|
<img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
|
||||||
|
|
||||||
<div className='announcements__container'>
|
<div className='announcements__container'>
|
||||||
<ReactSwipeableViews index={index} onChangeIndex={this.handleChangeIndex}>
|
<ReactSwipeableViews animateHeight index={index} onChangeIndex={this.handleChangeIndex}>
|
||||||
{announcements.map(announcement => (
|
{announcements.map(announcement => (
|
||||||
<Announcement
|
<Announcement
|
||||||
key={announcement.get('id')}
|
key={announcement.get('id')}
|
||||||
announcement={announcement}
|
announcement={announcement}
|
||||||
emojiMap={this.props.emojiMap}
|
emojiMap={this.props.emojiMap}
|
||||||
dismissAnnouncement={this.props.dismissAnnouncement}
|
|
||||||
addReaction={this.props.addReaction}
|
addReaction={this.props.addReaction}
|
||||||
removeReaction={this.props.removeReaction}
|
removeReaction={this.props.removeReaction}
|
||||||
intl={intl}
|
intl={intl}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'mastodon/actions/announcements';
|
import { addReaction, removeReaction } from 'mastodon/actions/announcements';
|
||||||
import Announcements from '../components/announcements';
|
import Announcements from '../components/announcements';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
@ -12,8 +12,6 @@ const mapStateToProps = state => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
fetchAnnouncements: () => dispatch(fetchAnnouncements()),
|
|
||||||
dismissAnnouncement: id => dispatch(dismissAnnouncement(id)),
|
|
||||||
addReaction: (id, name) => dispatch(addReaction(id, name)),
|
addReaction: (id, name) => dispatch(addReaction(id, name)),
|
||||||
removeReaction: (id, name) => dispatch(removeReaction(id, name)),
|
removeReaction: (id, name) => dispatch(removeReaction(id, name)),
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,15 +9,23 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
|
||||||
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
|
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import IconWithBadge from 'mastodon/components/icon_with_badge';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||||
|
show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' },
|
||||||
|
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
|
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
|
||||||
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
|
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
|
||||||
|
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
|
||||||
|
unreadAnnouncements: state.getIn(['announcements', 'unread']).size,
|
||||||
|
showAnnouncements: state.getIn(['announcements', 'show']),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
export default @connect(mapStateToProps)
|
||||||
|
@ -32,6 +40,9 @@ class HomeTimeline extends React.PureComponent {
|
||||||
isPartial: PropTypes.bool,
|
isPartial: PropTypes.bool,
|
||||||
columnId: PropTypes.string,
|
columnId: PropTypes.string,
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
|
hasAnnouncements: PropTypes.bool,
|
||||||
|
unreadAnnouncements: PropTypes.number,
|
||||||
|
showAnnouncements: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePin = () => {
|
handlePin = () => {
|
||||||
|
@ -62,6 +73,7 @@ class HomeTimeline extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
|
this.props.dispatch(fetchAnnouncements());
|
||||||
this._checkIfReloadNeeded(false, this.props.isPartial);
|
this._checkIfReloadNeeded(false, this.props.isPartial);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,10 +106,31 @@ class HomeTimeline extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleToggleAnnouncementsClick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.props.dispatch(toggleShowAnnouncements());
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props;
|
const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
||||||
const pinned = !!columnId;
|
const pinned = !!columnId;
|
||||||
|
|
||||||
|
let announcementsButton = null;
|
||||||
|
|
||||||
|
if (hasAnnouncements) {
|
||||||
|
announcementsButton = (
|
||||||
|
<button
|
||||||
|
className={classNames('column-header__button', { 'active': showAnnouncements })}
|
||||||
|
title={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
|
||||||
|
aria-label={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
|
||||||
|
aria-pressed={showAnnouncements ? 'true' : 'false'}
|
||||||
|
onClick={this.handleToggleAnnouncementsClick}
|
||||||
|
>
|
||||||
|
<IconWithBadge id='bullhorn' count={unreadAnnouncements} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
|
@ -109,13 +142,14 @@ class HomeTimeline extends React.PureComponent {
|
||||||
onClick={this.handleHeaderClick}
|
onClick={this.handleHeaderClick}
|
||||||
pinned={pinned}
|
pinned={pinned}
|
||||||
multiColumn={multiColumn}
|
multiColumn={multiColumn}
|
||||||
|
extraButton={announcementsButton}
|
||||||
>
|
>
|
||||||
<ColumnSettingsContainer />
|
<ColumnSettingsContainer />
|
||||||
</ColumnHeader>
|
</ColumnHeader>
|
||||||
|
|
||||||
|
{hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
|
||||||
|
|
||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
prepend={<AnnouncementsContainer />}
|
|
||||||
alwaysPrepend
|
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
scrollKey={`home_timeline-${columnId}`}
|
scrollKey={`home_timeline-${columnId}`}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
|
|
|
@ -3,18 +3,20 @@ import {
|
||||||
ANNOUNCEMENTS_FETCH_SUCCESS,
|
ANNOUNCEMENTS_FETCH_SUCCESS,
|
||||||
ANNOUNCEMENTS_FETCH_FAIL,
|
ANNOUNCEMENTS_FETCH_FAIL,
|
||||||
ANNOUNCEMENTS_UPDATE,
|
ANNOUNCEMENTS_UPDATE,
|
||||||
ANNOUNCEMENTS_DISMISS,
|
|
||||||
ANNOUNCEMENTS_REACTION_UPDATE,
|
ANNOUNCEMENTS_REACTION_UPDATE,
|
||||||
ANNOUNCEMENTS_REACTION_ADD_REQUEST,
|
ANNOUNCEMENTS_REACTION_ADD_REQUEST,
|
||||||
ANNOUNCEMENTS_REACTION_ADD_FAIL,
|
ANNOUNCEMENTS_REACTION_ADD_FAIL,
|
||||||
ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
|
ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
|
||||||
ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
|
ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
|
||||||
|
ANNOUNCEMENTS_TOGGLE_SHOW,
|
||||||
} from '../actions/announcements';
|
} from '../actions/announcements';
|
||||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList, Set as ImmutableSet, fromJS } from 'immutable';
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
items: ImmutableList(),
|
items: ImmutableList(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
show: true,
|
||||||
|
unread: ImmutableSet(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => {
|
const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => {
|
||||||
|
@ -43,21 +45,35 @@ 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 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)));
|
||||||
|
};
|
||||||
|
|
||||||
export default function announcementsReducer(state = initialState, action) {
|
export default function announcementsReducer(state = initialState, action) {
|
||||||
switch(action.type) {
|
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:
|
case ANNOUNCEMENTS_FETCH_REQUEST:
|
||||||
return state.set('isLoading', true);
|
return state.set('isLoading', true);
|
||||||
case ANNOUNCEMENTS_FETCH_SUCCESS:
|
case ANNOUNCEMENTS_FETCH_SUCCESS:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('items', fromJS(action.announcements));
|
const items = fromJS(action.announcements);
|
||||||
|
map.set('unread', ImmutableSet());
|
||||||
|
addUnread(map, items);
|
||||||
|
map.set('items', items);
|
||||||
map.set('isLoading', false);
|
map.set('isLoading', false);
|
||||||
});
|
});
|
||||||
case ANNOUNCEMENTS_FETCH_FAIL:
|
case ANNOUNCEMENTS_FETCH_FAIL:
|
||||||
return state.set('isLoading', false);
|
return state.set('isLoading', false);
|
||||||
case ANNOUNCEMENTS_UPDATE:
|
case ANNOUNCEMENTS_UPDATE:
|
||||||
return state.update('items', list => list.unshift(fromJS(action.announcement)).sortBy(announcement => announcement.get('starts_at')));
|
return addUnread(state, [fromJS(action.announcement)]).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:
|
case ANNOUNCEMENTS_REACTION_UPDATE:
|
||||||
return updateReactionCount(state, action.reaction);
|
return updateReactionCount(state, action.reaction);
|
||||||
case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
|
case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
|
||||||
|
|
|
@ -6631,7 +6631,7 @@ noscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
.announcements {
|
.announcements {
|
||||||
background: lighten($ui-base-color, 4%);
|
background: lighten($ui-base-color, 8%);
|
||||||
border-top: 1px solid $ui-base-color;
|
border-top: 1px solid $ui-base-color;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -6672,12 +6672,6 @@ noscript {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__dismiss-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__pagination {
|
&__pagination {
|
||||||
|
|
Loading…
Reference in a new issue