Merge branch 'master' into glitch-soc/merge-upstream

Conflicts:
- `app/serializers/rest/account_serializer.rb`:
  Upstream added code too close to glitch-soc-specific followers-hiding code.
  Ported upstream changes.
This commit is contained in:
Thibaut Girka
2020-01-27 15:46:50 +01:00
38 changed files with 323 additions and 129 deletions

View File

@@ -5,6 +5,7 @@ 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_DELETE = 'ANNOUNCEMENTS_DELETE';
export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
@@ -139,8 +140,11 @@ export const updateReaction = reaction => ({
reaction,
});
export function toggleShowAnnouncements() {
return dispatch => {
dispatch({ type: ANNOUNCEMENTS_TOGGLE_SHOW });
};
}
export const toggleShowAnnouncements = () => ({
type: ANNOUNCEMENTS_TOGGLE_SHOW,
});
export const deleteAnnouncement = id => ({
type: ANNOUNCEMENTS_DELETE,
id,
});

View File

@@ -8,7 +8,12 @@ import {
} from './timelines';
import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
import { fetchAnnouncements, updateAnnouncements, updateReaction as updateAnnouncementsReaction } from './announcements';
import {
fetchAnnouncements,
updateAnnouncements,
updateReaction as updateAnnouncementsReaction,
deleteAnnouncement,
} from './announcements';
import { fetchFilters } from './filters';
import { getLocale } from '../locales';
@@ -51,6 +56,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
case 'announcement.reaction':
dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
break;
case 'announcement.delete':
dispatch(deleteAnnouncement(data.payload));
break;
}
},
};

View File

@@ -11,23 +11,41 @@ export default class AnimatedNumber extends React.PureComponent {
value: PropTypes.number.isRequired,
};
willEnter () {
return { y: -1 };
state = {
direction: 1,
};
componentWillReceiveProps (nextProps) {
if (nextProps.value > this.props.value) {
this.setState({ direction: 1 });
} else if (nextProps.value < this.props.value) {
this.setState({ direction: -1 });
}
}
willLeave () {
return { y: spring(1, { damping: 35, stiffness: 400 }) };
willEnter = () => {
const { direction } = this.state;
return { y: -1 * direction };
}
willLeave = () => {
const { direction } = this.state;
return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) };
}
render () {
const { value } = this.props;
const { direction } = this.state;
if (reduceMotion) {
return <FormattedNumber value={value} />;
}
const styles = [{
key: value,
key: `${value}`,
data: value,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
}];
@@ -35,8 +53,8 @@ export default class AnimatedNumber extends React.PureComponent {
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<span className='animated-number'>
{items.map(({ key, style }) => (
<span key={key} style={{ position: style.y > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={key} /></span>
{items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span>
))}
</span>
)}

View File

@@ -3,6 +3,7 @@ import { injectIntl, defineMessages } from 'react-intl';
import PropTypes from 'prop-types';
const messages = defineMessages({
today: { id: 'relative_time.today', defaultMessage: 'today' },
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
@@ -65,12 +66,14 @@ const getUnitDelay = units => {
}
};
export const timeAgoString = (intl, date, now, year) => {
export const timeAgoString = (intl, date, now, year, timeGiven = true) => {
const delta = now - date.getTime();
let relativeTime;
if (delta < 10 * SECOND) {
if (delta < DAY && !timeGiven) {
relativeTime = intl.formatMessage(messages.today);
} else if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(messages.just_now);
} else if (delta < 7 * DAY) {
if (delta < MINUTE) {
@@ -91,12 +94,14 @@ export const timeAgoString = (intl, date, now, year) => {
return relativeTime;
};
const timeRemainingString = (intl, date, now) => {
const timeRemainingString = (intl, date, now, timeGiven = true) => {
const delta = date.getTime() - now;
let relativeTime;
if (delta < 10 * SECOND) {
if (delta < DAY && !timeGiven) {
relativeTime = intl.formatMessage(messages.today);
} else if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(messages.moments_remaining);
} else if (delta < MINUTE) {
relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) });
@@ -173,8 +178,9 @@ class RelativeTimestamp extends React.Component {
render () {
const { timestamp, intl, year, futureDate } = this.props;
const timeGiven = timestamp.includes('T');
const date = new Date(timestamp);
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year);
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven);
return (
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>

View File

@@ -6,13 +6,15 @@ import PropTypes from 'prop-types';
import IconButton from 'mastodon/components/icon_button';
import Icon from 'mastodon/components/icon';
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
import { autoPlayGif } from 'mastodon/initial_state';
import { autoPlayGif, reduceMotion } 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';
import AnimatedNumber from 'mastodon/components/animated_number';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -194,6 +196,7 @@ class Reaction extends ImmutablePureComponent {
addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired,
style: PropTypes.object,
};
state = {
@@ -224,7 +227,7 @@ class Reaction extends ImmutablePureComponent {
}
return (
<button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`}>
<button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
<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'><AnimatedNumber value={reaction.get('count')} /></span>
</button>
@@ -248,25 +251,44 @@ class ReactionsBar extends ImmutablePureComponent {
addReaction(announcementId, data.native.replace(/:/g, ''));
}
willEnter () {
return { scale: reduceMotion ? 1 : 0 };
}
willLeave () {
return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
}
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}
/>
))}
const styles = visibleReactions.map(reaction => ({
key: reaction.get('name'),
data: reaction,
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
})).toArray();
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
</div>
return (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{items.map(({ key, data, style }) => (
<Reaction
key={key}
reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
announcementId={this.props.announcementId}
addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction}
emojiMap={this.props.emojiMap}
/>
))}
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
</div>
)}
</TransitionMotion>
);
}
@@ -367,11 +389,13 @@ class Announcements extends ImmutablePureComponent {
))}
</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>
{announcements.size > 1 && (
<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>
);

View File

@@ -9,6 +9,7 @@ import {
ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
ANNOUNCEMENTS_TOGGLE_SHOW,
ANNOUNCEMENTS_DELETE,
} from '../actions/announcements';
import { Map as ImmutableMap, List as ImmutableList, Set as ImmutableSet, fromJS } from 'immutable';
@@ -22,14 +23,10 @@ const initialState = ImmutableMap({
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);
}
const idx = reactions.findIndex(reaction => reaction.get('name') === name);
return reaction;
});
if (idx > -1) {
return reactions.update(idx, reaction => updater(reaction));
}
return reactions.push(updater(fromJS({ name, count: 0 })));
@@ -46,13 +43,33 @@ 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;
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,
// and that is information we want to preserve
return state.update('items', list => sortAnnouncements(list.update(idx, x => x.mergeDeep(announcement))));
}
return state.update('items', list => sortAnnouncements(list.unshift(announcement)));
};
export default function announcementsReducer(state = initialState, action) {
switch(action.type) {
case ANNOUNCEMENTS_TOGGLE_SHOW:
@@ -65,15 +82,17 @@ export default function announcementsReducer(state = initialState, action) {
case ANNOUNCEMENTS_FETCH_SUCCESS:
return state.withMutations(map => {
const items = fromJS(action.announcements);
map.set('unread', ImmutableSet());
addUnread(map, items);
map.set('items', items);
map.set('isLoading', false);
addUnread(map, items);
});
case ANNOUNCEMENTS_FETCH_FAIL:
return state.set('isLoading', false);
case ANNOUNCEMENTS_UPDATE:
return addUnread(state, [fromJS(action.announcement)]).update('items', list => list.unshift(fromJS(action.announcement)).sortBy(announcement => announcement.get('starts_at')));
return updateAnnouncement(state, fromJS(action.announcement));
case ANNOUNCEMENTS_REACTION_UPDATE:
return updateReactionCount(state, action.reaction);
case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
@@ -82,6 +101,16 @@ 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_DELETE:
return state.update('unread', set => set.delete(action.id)).update('items', list => {
const idx = list.findIndex(x => x.get('id') === action.id);
if (idx > -1) {
return list.delete(idx);
}
return list;
});
default:
return state;
}