--- /dev/null
+import api from 'flavours/glitch/util/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,
+});
export function normalizePoll(poll) {
const normalPoll = { ...poll };
-
const emojiMap = makeEmojiMap(normalPoll);
normalPoll.options = poll.options.map((option, index) => ({
return normalPoll;
}
+
+export function normalizeAnnouncement(announcement) {
+ const normalAnnouncement = { ...announcement };
+ const emojiMap = makeEmojiMap(normalAnnouncement);
+
+ normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
+
+ return normalAnnouncement;
+}
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
fetchRelatedRelationships(dispatch, response.data);
- done();
}).catch(error => {
dispatch(expandNotificationsFail(error, isLoadingMore));
+ }).finally(() => {
done();
});
};
type: NOTIFICATIONS_EXPAND_FAIL,
error,
skipLoading: !isLoadingMore,
+ skipAlert: !isLoadingMore,
};
};
} from './timelines';
import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
+import { fetchAnnouncements, updateAnnouncements, updateReaction as updateAnnouncementsReaction } from './announcements';
import { fetchFilters } from './filters';
import { getLocale } from 'mastodon/locales';
case 'filters_changed':
dispatch(fetchFilters());
break;
+ case 'announcement':
+ dispatch(updateAnnouncements(JSON.parse(data.payload)));
+ break;
+ case 'announcement.reaction':
+ dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
+ break;
}
},
};
}
const refreshHomeTimelineAndNotification = (dispatch, done) => {
- dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
+ dispatch(expandHomeTimeline({}, () =>
+ dispatch(expandNotifications({}, () =>
+ dispatch(fetchAnnouncements(done))))));
};
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
dispatch(importFetchedStatuses(response.data));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
- done();
}).catch(error => {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
+ }).finally(() => {
done();
});
};
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
+ button: PropTypes.node,
};
state = {
}
render () {
- const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
+ const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;
return (
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
- <img
+ {button || <img
className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='๐'
src={`${assetHost}/emoji/1f602.svg`}
- />
+ />}
</div>
<Overlay show={active} placement={placement} target={this.findTarget}>
--- /dev/null
+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 'flavours/glitch/components/icon_button';
+import Icon from 'flavours/glitch/components/icon';
+import { defineMessages, injectIntl, FormattedMessage, FormattedDate, FormattedNumber } from 'react-intl';
+import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
+import { mascot } from 'flavours/glitch/util/initial_state';
+import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
+import classNames from 'classnames';
+import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker';
+
+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>
+ );
+ }
+
+}
--- /dev/null
+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);
import { connect } from 'react-redux';
-import { fetchTrends } from '../../../actions/trends';
+import { fetchTrends } from 'mastodon/actions/trends';
import Trends from '../components/trends';
const mapStateToProps = state => ({
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { Link } from 'react-router-dom';
+import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' },
</ColumnHeader>
<StatusListContainer
+ prepend={<AnnouncementsContainer />}
+ alwaysPrepend
trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`}
onLoadMore={this.handleLoadMore}
style={swipeableViewsStyle}
containerStyle={containerStyle}
onChangeIndex={this.handleSwipe}
- onSwitching={this.handleSwitching}
index={index}
>
{content}
--- /dev/null
+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;
+ }
+};
import polls from './polls';
import identity_proofs from './identity_proofs';
import trends from './trends';
+import announcements from './announcements';
const reducers = {
+ announcements,
dropdown_menu,
timelines,
meta,
--- /dev/null
+.announcements__item__content {
+ word-wrap: break-word;
+
+ .emojione {
+ width: 20px;
+ height: 20px;
+ margin: -3px 0 0;
+ }
+
+ p {
+ margin-bottom: 10px;
+ white-space: pre-wrap;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ a {
+ color: $highlight-text-color;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &.mention {
+ &:hover {
+ text-decoration: none;
+
+ span {
+ text-decoration: underline;
+ }
+ }
+ }
+ }
+}
+
+.announcements {
+ background: lighten($ui-base-color, 4%);
+ border-top: 1px solid $ui-base-color;
+ font-size: 13px;
+ display: flex;
+ align-items: flex-end;
+
+ &__mastodon {
+ width: 124px;
+ flex: 0 0 auto;
+
+ @media screen and (max-width: 124px + 300px) {
+ display: none;
+ }
+ }
+
+ &__container {
+ width: calc(100% - 124px);
+ flex: 0 0 auto;
+ position: relative;
+
+ @media screen and (max-width: 124px + 300px) {
+ width: 100%;
+ }
+ }
+
+ &__item {
+ box-sizing: border-box;
+ width: 100%;
+ padding: 15px;
+ padding-right: 15px + 18px;
+ position: relative;
+
+ &__range {
+ display: block;
+ font-weight: 500;
+ margin-bottom: 10px;
+ }
+
+ &__dismiss-icon {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ }
+ }
+
+ &__pagination {
+ padding: 15px;
+ color: $darker-text-color;
+ position: absolute;
+ bottom: 3px;
+ right: 0;
+ }
+}
+
+.layout-multiple-columns .announcements__mastodon {
+ display: none;
+}
+
+.layout-multiple-columns .announcements__container {
+ width: 100%;
+}
+
+.reactions-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ margin-top: 15px;
+ margin-left: -2px;
+ width: calc(100% - (90px - 33px));
+
+ &__item {
+ flex-shrink: 0;
+ background: lighten($ui-base-color, 12%);
+ border: 0;
+ border-radius: 3px;
+ margin: 2px;
+ cursor: pointer;
+ user-select: none;
+ padding: 0 6px;
+ display: flex;
+ align-items: center;
+ transition: all 100ms ease-in;
+ transition-property: background-color, color;
+
+ &__emoji {
+ display: block;
+ margin: 3px 0;
+ width: 16px;
+ height: 16px;
+
+ img {
+ display: block;
+ margin: 0;
+ width: 100%;
+ height: 100%;
+ min-width: auto;
+ min-height: auto;
+ vertical-align: bottom;
+ object-fit: contain;
+ }
+ }
+
+ &__count {
+ display: block;
+ min-width: 9px;
+ font-size: 13px;
+ font-weight: 500;
+ text-align: center;
+ margin-left: 6px;
+ color: $darker-text-color;
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ background: lighten($ui-base-color, 16%);
+ transition: all 200ms ease-out;
+ transition-property: background-color, color;
+
+ &__count {
+ color: lighten($darker-text-color, 4%);
+ }
+ }
+
+ &.active {
+ transition: all 100ms ease-in;
+ transition-property: background-color, color;
+ background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 90%);
+
+ .reactions-bar__item__count {
+ color: $highlight-text-color;
+ }
+ }
+ }
+
+ .emoji-picker-dropdown {
+ margin: 2px;
+ }
+
+ &:hover .emoji-button {
+ opacity: 0.85;
+ }
+
+ .emoji-button {
+ color: $darker-text-color;
+ margin: 0;
+ font-size: 16px;
+ width: auto;
+ flex-shrink: 0;
+ padding: 0 6px;
+ height: 22px;
+ display: flex;
+ align-items: center;
+ opacity: 0.5;
+ transition: all 100ms ease-in;
+ transition-property: background-color, color;
+
+ &:hover,
+ &:active,
+ &:focus {
+ opacity: 1;
+ color: lighten($darker-text-color, 4%);
+ transition: all 200ms ease-out;
+ transition-property: background-color, color;
+ }
+ }
+
+ &--empty {
+ .emoji-button {
+ padding: 0;
+ }
+ }
+}
@import 'local_settings';
@import 'error_boundary';
@import 'single_column';
+@import 'announcements';
}
}
+ .input.datetime .label_input select {
+ display: inline-block;
+ width: auto;
+ flex: 0;
+ }
+
.required abbr {
text-decoration: none;
color: lighten($error-value-color, 12%);