Merge pull request #1271 from ThibG/glitch-soc/merge-upstream

Merge upstream changes
th-downstream
ThibG 5 years ago committed by GitHub
commit b1ccf0609d

@ -9,6 +9,7 @@ gem 'puma', '~> 4.3'
gem 'rails', '~> 5.2.4' gem 'rails', '~> 5.2.4'
gem 'sprockets', '~> 3.7.2' gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 0.20' gem 'thor', '~> 0.20'
gem 'rack', '2.0.8'
gem 'thwait', '~> 0.1.0' gem 'thwait', '~> 0.1.0'
gem 'e2mmap', '~> 0.1.0' gem 'e2mmap', '~> 0.1.0'

@ -443,7 +443,7 @@ GEM
pundit (2.1.0) pundit (2.1.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.1.6) raabro (1.1.6)
rack (2.1.1) rack (2.0.8)
rack-attack (6.2.2) rack-attack (6.2.2)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-cors (1.1.1) rack-cors (1.1.1)
@ -753,6 +753,7 @@ DEPENDENCIES
pry-rails (~> 0.3) pry-rails (~> 0.3)
puma (~> 4.3) puma (~> 4.3)
pundit (~> 2.1) pundit (~> 2.1)
rack (= 2.0.8)
rack-attack (~> 6.2) rack-attack (~> 6.2)
rack-cors (~> 1.1) rack-cors (~> 1.1)
rails (~> 5.2.4) rails (~> 5.2.4)

@ -20,8 +20,9 @@ class Admin::AnnouncementsController < Admin::BaseController
@announcement = Announcement.new(resource_params) @announcement = Announcement.new(resource_params)
if @announcement.save if @announcement.save
PublishScheduledAnnouncementWorker.perform_async(@announcement.id) if @announcement.published?
log_action :create, @announcement log_action :create, @announcement
redirect_to admin_announcements_path redirect_to admin_announcements_path, notice: @announcement.published? ? I18n.t('admin.announcements.published_msg') : I18n.t('admin.announcements.scheduled_msg')
else else
render :new render :new
end end
@ -35,18 +36,36 @@ class Admin::AnnouncementsController < Admin::BaseController
authorize :announcement, :update? authorize :announcement, :update?
if @announcement.update(resource_params) if @announcement.update(resource_params)
PublishScheduledAnnouncementWorker.perform_async(@announcement.id) if @announcement.published?
log_action :update, @announcement log_action :update, @announcement
redirect_to admin_announcements_path redirect_to admin_announcements_path, notice: I18n.t('admin.announcements.updated_msg')
else else
render :edit render :edit
end end
end end
def publish
authorize :announcement, :update?
@announcement.publish!
PublishScheduledAnnouncementWorker.perform_async(@announcement.id)
log_action :update, @announcement
redirect_to admin_announcements_path, notice: I18n.t('admin.announcements.published_msg')
end
def unpublish
authorize :announcement, :update?
@announcement.unpublish!
UnpublishAnnouncementWorker.perform_async(@announcement.id)
log_action :update, @announcement
redirect_to admin_announcements_path, notice: I18n.t('admin.announcements.unpublished_msg')
end
def destroy def destroy
authorize :announcement, :destroy? authorize :announcement, :destroy?
@announcement.destroy! @announcement.destroy!
UnpublishAnnouncementWorker.perform_async(@announcement.id) if @announcement.published?
log_action :destroy, @announcement log_action :destroy, @announcement
redirect_to admin_announcements_path redirect_to admin_announcements_path, notice: I18n.t('admin.announcements.destroyed_msg')
end end
private private

@ -36,6 +36,7 @@ module SettingsHelper
it: 'Italiano', it: 'Italiano',
ja: '日本語', ja: '日本語',
ka: 'ქართული', ka: 'ქართული',
kab: 'Taqbaylit',
kk: 'Қазақша', kk: 'Қазақша',
kn: 'ಕನ್ನಡ', kn: 'ಕನ್ನಡ',
ko: '한국어', ko: '한국어',

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

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

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

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

@ -6,13 +6,15 @@ import PropTypes from 'prop-types';
import IconButton from 'flavours/glitch/components/icon_button'; import IconButton from 'flavours/glitch/components/icon_button';
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
import { autoPlayGif } from 'flavours/glitch/util/initial_state'; import { autoPlayGif, reduceMotion } from 'flavours/glitch/util/initial_state';
import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg'; import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
import { mascot } from 'flavours/glitch/util/initial_state'; import { mascot } from 'flavours/glitch/util/initial_state';
import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
import classNames from 'classnames'; import classNames from 'classnames';
import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker'; import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker';
import AnimatedNumber from 'flavours/glitch/components/animated_number'; import AnimatedNumber from 'flavours/glitch/components/animated_number';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
const messages = defineMessages({ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -194,6 +196,7 @@ class Reaction extends ImmutablePureComponent {
addReaction: PropTypes.func.isRequired, addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired, removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired, emojiMap: ImmutablePropTypes.map.isRequired,
style: PropTypes.object,
}; };
state = { state = {
@ -224,7 +227,7 @@ class Reaction extends ImmutablePureComponent {
} }
return ( 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__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> <span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
</button> </button>
@ -248,25 +251,44 @@ class ReactionsBar extends ImmutablePureComponent {
addReaction(announcementId, data.native.replace(/:/g, '')); addReaction(announcementId, data.native.replace(/:/g, ''));
} }
willEnter () {
return { scale: reduceMotion ? 1 : 0 };
}
willLeave () {
return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
}
render () { render () {
const { reactions } = this.props; const { reactions } = this.props;
const visibleReactions = reactions.filter(x => x.get('count') > 0); const visibleReactions = reactions.filter(x => x.get('count') > 0);
const styles = visibleReactions.map(reaction => ({
key: reaction.get('name'),
data: reaction,
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
})).toArray();
return ( return (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}> <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{visibleReactions.map(reaction => ( {items => (
<Reaction <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
key={reaction.get('name')} {items.map(({ key, data, style }) => (
reaction={reaction} <Reaction
announcementId={this.props.announcementId} key={key}
addReaction={this.props.addReaction} reaction={data}
removeReaction={this.props.removeReaction} style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
emojiMap={this.props.emojiMap} 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> ))}
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
</div>
)}
</TransitionMotion>
); );
} }
@ -367,11 +389,13 @@ class Announcements extends ImmutablePureComponent {
))} ))}
</ReactSwipeableViews> </ReactSwipeableViews>
<div className='announcements__pagination'> {announcements.size > 1 && (
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} /> <div className='announcements__pagination'>
<span>{index + 1} / {announcements.size}</span> <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} /> <span>{index + 1} / {announcements.size}</span>
</div> <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
</div>
)}
</div> </div>
</div> </div>
); );

@ -9,6 +9,7 @@ import {
ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
ANNOUNCEMENTS_REACTION_REMOVE_FAIL, ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
ANNOUNCEMENTS_TOGGLE_SHOW, ANNOUNCEMENTS_TOGGLE_SHOW,
ANNOUNCEMENTS_DELETE,
} from '../actions/announcements'; } from '../actions/announcements';
import { Map as ImmutableMap, List as ImmutableList, Set as ImmutableSet, fromJS } from 'immutable'; 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 => { const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => {
if (announcement.get('id') === id) { if (announcement.get('id') === id) {
return announcement.update('reactions', reactions => { return announcement.update('reactions', reactions => {
if (reactions.find(reaction => reaction.get('name') === name)) { const idx = reactions.findIndex(reaction => reaction.get('name') === name);
return reactions.map(reaction => {
if (reaction.get('name') === name) { if (idx > -1) {
return updater(reaction); return reactions.update(idx, reaction => updater(reaction));
}
return reaction;
});
} }
return reactions.push(updater(fromJS({ name, count: 0 }))); 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 removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).update('count', y => y - 1));
const addUnread = (state, items) => { 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 newIds = ImmutableSet(items.map(x => x.get('id')));
const oldIds = ImmutableSet(state.get('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))); 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) { export default function announcementsReducer(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ANNOUNCEMENTS_TOGGLE_SHOW: case ANNOUNCEMENTS_TOGGLE_SHOW:
@ -65,15 +82,17 @@ export default function announcementsReducer(state = initialState, action) {
case ANNOUNCEMENTS_FETCH_SUCCESS: case ANNOUNCEMENTS_FETCH_SUCCESS:
return state.withMutations(map => { return state.withMutations(map => {
const items = fromJS(action.announcements); const items = fromJS(action.announcements);
map.set('unread', ImmutableSet()); map.set('unread', ImmutableSet());
addUnread(map, items);
map.set('items', items); map.set('items', items);
map.set('isLoading', false); map.set('isLoading', false);
addUnread(map, items);
}); });
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 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: case ANNOUNCEMENTS_REACTION_UPDATE:
return updateReactionCount(state, action.reaction); return updateReactionCount(state, action.reaction);
case ANNOUNCEMENTS_REACTION_ADD_REQUEST: case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
@ -82,6 +101,16 @@ export default function announcementsReducer(state = initialState, action) {
case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST: case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST:
case ANNOUNCEMENTS_REACTION_ADD_FAIL: case ANNOUNCEMENTS_REACTION_ADD_FAIL:
return removeReaction(state, action.id, action.name); 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: default:
return state; return state;
} }

@ -17,7 +17,7 @@
} }
a { a {
color: $highlight-text-color; color: $secondary-text-color;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
@ -33,6 +33,10 @@
} }
} }
} }
&.unhandled-link {
color: lighten($ui-highlight-color, 8%);
}
} }
} }

@ -510,6 +510,7 @@
.status-check-box__status { .status-check-box__status {
margin: 10px 0 10px 10px; margin: 10px 0 10px 10px;
flex: 1; flex: 1;
overflow: hidden;
.media-gallery { .media-gallery {
max-width: 250px; max-width: 250px;

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

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

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

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

@ -6,13 +6,15 @@ import PropTypes from 'prop-types';
import IconButton from 'mastodon/components/icon_button'; import IconButton from 'mastodon/components/icon_button';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; 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 elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
import { mascot } from 'mastodon/initial_state'; import { mascot } from 'mastodon/initial_state';
import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light'; import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
import classNames from 'classnames'; import classNames from 'classnames';
import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_picker_dropdown_container'; import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_picker_dropdown_container';
import AnimatedNumber from 'mastodon/components/animated_number'; import AnimatedNumber from 'mastodon/components/animated_number';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
const messages = defineMessages({ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -194,6 +196,7 @@ class Reaction extends ImmutablePureComponent {
addReaction: PropTypes.func.isRequired, addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired, removeReaction: PropTypes.func.isRequired,
emojiMap: ImmutablePropTypes.map.isRequired, emojiMap: ImmutablePropTypes.map.isRequired,
style: PropTypes.object,
}; };
state = { state = {
@ -224,7 +227,7 @@ class Reaction extends ImmutablePureComponent {
} }
return ( 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__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> <span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
</button> </button>
@ -248,25 +251,44 @@ class ReactionsBar extends ImmutablePureComponent {
addReaction(announcementId, data.native.replace(/:/g, '')); addReaction(announcementId, data.native.replace(/:/g, ''));
} }
willEnter () {
return { scale: reduceMotion ? 1 : 0 };
}
willLeave () {
return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
}
render () { render () {
const { reactions } = this.props; const { reactions } = this.props;
const visibleReactions = reactions.filter(x => x.get('count') > 0); const visibleReactions = reactions.filter(x => x.get('count') > 0);
const styles = visibleReactions.map(reaction => ({
key: reaction.get('name'),
data: reaction,
style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
})).toArray();
return ( return (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}> <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{visibleReactions.map(reaction => ( {items => (
<Reaction <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
key={reaction.get('name')} {items.map(({ key, data, style }) => (
reaction={reaction} <Reaction
announcementId={this.props.announcementId} key={key}
addReaction={this.props.addReaction} reaction={data}
removeReaction={this.props.removeReaction} style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
emojiMap={this.props.emojiMap} 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> ))}
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
</div>
)}
</TransitionMotion>
); );
} }
@ -367,11 +389,13 @@ class Announcements extends ImmutablePureComponent {
))} ))}
</ReactSwipeableViews> </ReactSwipeableViews>
<div className='announcements__pagination'> {announcements.size > 1 && (
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} /> <div className='announcements__pagination'>
<span>{index + 1} / {announcements.size}</span> <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} /> <span>{index + 1} / {announcements.size}</span>
</div> <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
</div>
)}
</div> </div>
</div> </div>
); );

@ -9,6 +9,7 @@ import {
ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
ANNOUNCEMENTS_REACTION_REMOVE_FAIL, ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
ANNOUNCEMENTS_TOGGLE_SHOW, ANNOUNCEMENTS_TOGGLE_SHOW,
ANNOUNCEMENTS_DELETE,
} from '../actions/announcements'; } from '../actions/announcements';
import { Map as ImmutableMap, List as ImmutableList, Set as ImmutableSet, fromJS } from 'immutable'; 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 => { const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => {
if (announcement.get('id') === id) { if (announcement.get('id') === id) {
return announcement.update('reactions', reactions => { return announcement.update('reactions', reactions => {
if (reactions.find(reaction => reaction.get('name') === name)) { const idx = reactions.findIndex(reaction => reaction.get('name') === name);
return reactions.map(reaction => {
if (reaction.get('name') === name) { if (idx > -1) {
return updater(reaction); return reactions.update(idx, reaction => updater(reaction));
}
return reaction;
});
} }
return reactions.push(updater(fromJS({ name, count: 0 }))); 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 removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).update('count', y => y - 1));
const addUnread = (state, items) => { 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 newIds = ImmutableSet(items.map(x => x.get('id')));
const oldIds = ImmutableSet(state.get('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))); 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) { export default function announcementsReducer(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ANNOUNCEMENTS_TOGGLE_SHOW: case ANNOUNCEMENTS_TOGGLE_SHOW:
@ -65,15 +82,17 @@ export default function announcementsReducer(state = initialState, action) {
case ANNOUNCEMENTS_FETCH_SUCCESS: case ANNOUNCEMENTS_FETCH_SUCCESS:
return state.withMutations(map => { return state.withMutations(map => {
const items = fromJS(action.announcements); const items = fromJS(action.announcements);
map.set('unread', ImmutableSet()); map.set('unread', ImmutableSet());
addUnread(map, items);
map.set('items', items); map.set('items', items);
map.set('isLoading', false); map.set('isLoading', false);
addUnread(map, items);
}); });
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 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: case ANNOUNCEMENTS_REACTION_UPDATE:
return updateReactionCount(state, action.reaction); return updateReactionCount(state, action.reaction);
case ANNOUNCEMENTS_REACTION_ADD_REQUEST: case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
@ -82,6 +101,16 @@ export default function announcementsReducer(state = initialState, action) {
case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST: case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST:
case ANNOUNCEMENTS_REACTION_ADD_FAIL: case ANNOUNCEMENTS_REACTION_ADD_FAIL:
return removeReaction(state, action.id, action.name); 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: default:
return state; return state;
} }

@ -34,8 +34,9 @@
box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
h4 { h4 {
text-transform: uppercase;
color: $light-text-color; color: $light-text-color;
font-size: 14px; font-size: 13px;
font-weight: 500; font-weight: 500;
margin-bottom: 10px; margin-bottom: 10px;
} }

@ -719,8 +719,9 @@ $small-breakpoint: 960px;
h4 { h4 {
padding: 10px; padding: 10px;
text-transform: uppercase;
font-weight: 700; font-weight: 700;
font-size: 14px; font-size: 13px;
color: $darker-text-color; color: $darker-text-color;
} }

@ -129,6 +129,7 @@
.older, .older,
.newer { .newer {
text-transform: uppercase;
color: $secondary-text-color; color: $secondary-text-color;
} }

@ -232,7 +232,8 @@ $content-width: 840px;
} }
h4 { h4 {
font-size: 14px; text-transform: uppercase;
font-size: 13px;
font-weight: 700; font-weight: 700;
color: $darker-text-color; color: $darker-text-color;
padding-bottom: 8px; padding-bottom: 8px;
@ -407,7 +408,8 @@ body,
strong { strong {
font-weight: 500; font-weight: 500;
font-size: 13px; text-transform: uppercase;
font-size: 12px;
@each $lang in $cjk-langs { @each $lang in $cjk-langs {
&:lang(#{$lang}) { &:lang(#{$lang}) {
@ -420,7 +422,8 @@ body,
display: inline-block; display: inline-block;
color: $darker-text-color; color: $darker-text-color;
text-decoration: none; text-decoration: none;
font-size: 13px; text-transform: uppercase;
font-size: 12px;
font-weight: 500; font-weight: 500;
border-bottom: 2px solid $ui-base-color; border-bottom: 2px solid $ui-base-color;
@ -786,6 +789,7 @@ a.name-tag,
flex: 0 0 auto; flex: 0 0 auto;
font-weight: 500; font-weight: 500;
color: $darker-text-color; color: $darker-text-color;
text-transform: uppercase;
text-align: right; text-align: right;
a { a {

@ -41,7 +41,7 @@
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
font-family: inherit; font-family: inherit;
font-size: 15px; font-size: 14px;
font-weight: 500; font-weight: 500;
height: 36px; height: 36px;
letter-spacing: 0; letter-spacing: 0;
@ -50,6 +50,7 @@
padding: 0 16px; padding: 0 16px;
position: relative; position: relative;
text-align: center; text-align: center;
text-transform: uppercase;
text-decoration: none; text-decoration: none;
text-overflow: ellipsis; text-overflow: ellipsis;
transition: all 100ms ease-in; transition: all 100ms ease-in;
@ -886,7 +887,7 @@
} }
a { a {
color: $highlight-text-color; color: $secondary-text-color;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
@ -902,6 +903,10 @@
} }
} }
} }
&.unhandled-link {
color: lighten($ui-highlight-color, 8%);
}
} }
} }
@ -932,8 +937,9 @@
border: 0; border: 0;
color: $inverted-text-color; color: $inverted-text-color;
font-weight: 700; font-weight: 700;
font-size: 12px; font-size: 11px;
padding: 0 6px; padding: 0 6px;
text-transform: uppercase;
line-height: 20px; line-height: 20px;
cursor: pointer; cursor: pointer;
vertical-align: middle; vertical-align: middle;
@ -1086,6 +1092,7 @@
.status-check-box__status { .status-check-box__status {
margin: 10px 0 10px 10px; margin: 10px 0 10px 10px;
flex: 1; flex: 1;
overflow: hidden;
.media-gallery { .media-gallery {
max-width: 250px; max-width: 250px;
@ -1455,7 +1462,8 @@ a .account__avatar {
& > span { & > span {
display: block; display: block;
font-size: 12px; text-transform: uppercase;
font-size: 11px;
color: $darker-text-color; color: $darker-text-color;
} }
@ -2803,8 +2811,9 @@ a.account__display-name {
background: $ui-base-color; background: $ui-base-color;
color: $dark-text-color; color: $dark-text-color;
padding: 8px 20px; padding: 8px 20px;
font-size: 13px; font-size: 12px;
font-weight: 500; font-weight: 500;
text-transform: uppercase;
cursor: default; cursor: default;
} }
@ -2869,7 +2878,8 @@ a.account__display-name {
margin-top: 10px; margin-top: 10px;
h4 { h4 {
font-size: 13px; font-size: 12px;
text-transform: uppercase;
color: $darker-text-color; color: $darker-text-color;
padding: 10px; padding: 10px;
font-weight: 500; font-weight: 500;
@ -3399,8 +3409,9 @@ a.status-card.compact:hover {
.loading-indicator { .loading-indicator {
color: $dark-text-color; color: $dark-text-color;
font-size: 13px; font-size: 12px;
font-weight: 400; font-weight: 400;
text-transform: uppercase;
overflow: visible; overflow: visible;
position: absolute; position: absolute;
top: 50%; top: 50%;
@ -3764,7 +3775,8 @@ a.status-card.compact:hover {
display: block; display: block;
vertical-align: top; vertical-align: top;
background-color: $base-overlay-background; background-color: $base-overlay-background;
font-size: 12px; text-transform: uppercase;
font-size: 11px;
font-weight: 500; font-weight: 500;
padding: 4px; padding: 4px;
border-radius: 4px; border-radius: 4px;
@ -4016,7 +4028,8 @@ a.status-card.compact:hover {
} }
span { span {
font-size: 13px; font-size: 12px;
text-transform: uppercase;
font-weight: 500; font-weight: 500;
display: block; display: block;
} }
@ -4615,7 +4628,8 @@ a.status-card.compact:hover {
font-weight: 500; font-weight: 500;
color: $inverted-text-color; color: $inverted-text-color;
margin-bottom: 5px; margin-bottom: 5px;
font-size: 13px; text-transform: uppercase;
font-size: 12px;
} }
&__case { &__case {

@ -94,6 +94,7 @@
} }
h4 { h4 {
text-transform: uppercase;
font-weight: 700; font-weight: 700;
margin-bottom: 8px; margin-bottom: 8px;
color: $darker-text-color; color: $darker-text-color;

@ -420,6 +420,7 @@ code {
line-height: inherit; line-height: inherit;
height: auto; height: auto;
padding: 10px; padding: 10px;
text-transform: uppercase;
text-decoration: none; text-decoration: none;
text-align: center; text-align: center;
box-sizing: border-box; box-sizing: border-box;
@ -662,6 +663,7 @@ code {
a { a {
color: $highlight-text-color; color: $highlight-text-color;
text-transform: uppercase;
text-decoration: none; text-decoration: none;
font-weight: 700; font-weight: 700;

@ -76,8 +76,9 @@
h4 { h4 {
padding: 10px; padding: 10px;
text-transform: uppercase;
font-weight: 700; font-weight: 700;
font-size: 14px; font-size: 13px;
color: $darker-text-color; color: $darker-text-color;
} }
@ -138,8 +139,9 @@
h4 { h4 {
padding: 10px; padding: 10px;
text-transform: uppercase;
font-weight: 700; font-weight: 700;
font-size: 14px; font-size: 13px;
color: $darker-text-color; color: $darker-text-color;
} }
@ -406,6 +408,7 @@
thead th { thead th {
text-align: center; text-align: center;
text-transform: uppercase;
color: $darker-text-color; color: $darker-text-color;
font-weight: 700; font-weight: 700;
padding: 10px; padding: 10px;

@ -11,6 +11,10 @@ class FeedManager
# Must be <= MAX_ITEMS or the tracking sets will grow forever # Must be <= MAX_ITEMS or the tracking sets will grow forever
REBLOG_FALLOFF = 40 REBLOG_FALLOFF = 40
def with_active_accounts(&block)
Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each(&block)
end
def key(type, id, subtype = nil) def key(type, id, subtype = nil)
return "feed:#{type}:#{id}" unless subtype return "feed:#{type}:#{id}" unless subtype

@ -13,15 +13,14 @@
# ends_at :datetime # ends_at :datetime
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# published_at :datetime
# #
class Announcement < ApplicationRecord class Announcement < ApplicationRecord
after_commit :queue_publish, on: :create
scope :unpublished, -> { where(published: false) } scope :unpublished, -> { where(published: false) }
scope :published, -> { where(published: true) } 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 :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')) } scope :chronological, -> { order(Arel.sql('COALESCE(announcements.starts_at, announcements.scheduled_at, announcements.published_at, announcements.created_at) ASC')) }
has_many :announcement_mutes, dependent: :destroy has_many :announcement_mutes, dependent: :destroy
has_many :announcement_reactions, dependent: :destroy has_many :announcement_reactions, dependent: :destroy
@ -31,8 +30,15 @@ class Announcement < ApplicationRecord
validates :ends_at, presence: true, if: -> { starts_at.present? } validates :ends_at, presence: true, if: -> { starts_at.present? }
before_validation :set_all_day before_validation :set_all_day
before_validation :set_starts_at, on: :create before_validation :set_published, on: :create
before_validation :set_ends_at, on: :create
def publish!
update!(published: true, published_at: Time.now.utc, scheduled_at: nil)
end
def unpublish!
update!(published: false, scheduled_at: nil)
end
def time_range? def time_range?
starts_at.present? && ends_at.present? starts_at.present? && ends_at.present?
@ -71,15 +77,10 @@ class Announcement < ApplicationRecord
self.all_day = false if starts_at.blank? || ends_at.blank? self.all_day = false if starts_at.blank? || ends_at.blank?
end end
def set_starts_at def set_published
self.starts_at = starts_at.change(hour: 0, min: 0, sec: 0) if all_day? && starts_at.present? return unless scheduled_at.blank? || scheduled_at.past?
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 self.published = true
PublishScheduledAnnouncementWorker.perform_async(id) if scheduled_at.blank? self.published_at = Time.now.utc
end end
end end

@ -56,6 +56,10 @@ class REST::AccountSerializer < ActiveModel::Serializer
object.moved? && object.moved_to_account.moved_to_account_id.nil? object.moved? && object.moved_to_account.moved_to_account_id.nil?
end end
def last_status_at
object.last_status_at&.to_date&.iso8601
end
def followers_count def followers_count
(Setting.hide_followers_count || object.user&.setting_hide_followers_count) ? -1 : object.followers_count (Setting.hide_followers_count || object.user&.setting_hide_followers_count) ? -1 : object.followers_count
end end

@ -1,7 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::AnnouncementSerializer < ActiveModel::Serializer class REST::AnnouncementSerializer < ActiveModel::Serializer
attributes :id, :content, :starts_at, :ends_at, :all_day attributes :id, :content, :starts_at, :ends_at, :all_day,
:published_at, :updated_at
has_many :mentions has_many :mentions
has_many :tags, serializer: REST::StatusSerializer::TagSerializer has_many :tags, serializer: REST::StatusSerializer::TagSerializer

@ -10,5 +10,12 @@
- else - else
= l(announcement.created_at) = l(announcement.created_at)
%td %td
= table_link_to 'pencil', t('generic.edit'), edit_admin_announcement_path(announcement) if can?(:update, announcement) - if can?(:update, announcement)
- if announcement.published?
= table_link_to 'pause', t('admin.announcements.unpublish'), unpublish_admin_announcement_path(announcement), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }
- else
= table_link_to 'play', t('admin.announcements.publish'), publish_admin_announcement_path(announcement), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }
= table_link_to 'pencil', t('generic.edit'), edit_admin_announcement_path(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) = 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)

@ -14,7 +14,7 @@
.fields-group .fields-group
= f.input :text, wrapper: :with_block_label = f.input :text, wrapper: :with_block_label
- if @announcement.scheduled_at.present? && !@announcement.published? - unless @announcement.published?
.fields-group .fields-group
= f.input :scheduled_at, include_blank: true, wrapper: :with_block_label = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label

@ -47,7 +47,7 @@
%small= t('accounts.followers', count: account.followers_count).downcase %small= t('accounts.followers', count: account.followers_count).downcase
.accounts-table__count .accounts-table__count
- if account.last_status_at.present? - if account.last_status_at.present?
%time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at %time.time-ago{ datetime: account.last_status_at.to_date.iso8601, title: l(account.last_status_at.to_date) }= l account.last_status_at.to_date
- else - else
= t('accounts.never_active') = t('accounts.never_active')

@ -14,7 +14,7 @@
%small= t('accounts.followers', count: account.followers_count).downcase %small= t('accounts.followers', count: account.followers_count).downcase
%td.accounts-table__count %td.accounts-table__count
- if account.last_status_at.present? - if account.last_status_at.present?
%time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at %time.time-ago{ datetime: account.last_status_at.to_date.iso8601, title: l(account.last_status_at.to_date) }= l account.last_status_at
- else - else
\- \-
%small= t('accounts.last_active') %small= t('accounts.last_active')

@ -13,7 +13,7 @@ class PublishAnnouncementReactionWorker
payload = InlineRenderer.render(reaction, nil, :reaction).tap { |h| h[:announcement_id] = announcement_id.to_s } payload = InlineRenderer.render(reaction, nil, :reaction).tap { |h| h[:announcement_id] = announcement_id.to_s }
payload = Oj.dump(event: :'announcement.reaction', payload: payload) 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| FeedManager.instance.with_active_accounts do |account|
redis.publish("timeline:#{account.id}", payload) if redis.exists("subscribed:timeline:#{account.id}") redis.publish("timeline:#{account.id}", payload) if redis.exists("subscribed:timeline:#{account.id}")
end end
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound

@ -6,12 +6,13 @@ class PublishScheduledAnnouncementWorker
def perform(announcement_id) def perform(announcement_id)
announcement = Announcement.find(announcement_id) announcement = Announcement.find(announcement_id)
announcement.update(published: true)
announcement.publish! unless announcement.published?
payload = InlineRenderer.render(announcement, nil, :announcement) payload = InlineRenderer.render(announcement, nil, :announcement)
payload = Oj.dump(event: :announcement, payload: payload) payload = Oj.dump(event: :announcement, payload: payload)
Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each do |account| FeedManager.instance.with_active_accounts do |account|
redis.publish("timeline:#{account.id}", payload) if redis.exists("subscribed:timeline:#{account.id}") redis.publish("timeline:#{account.id}", payload) if redis.exists("subscribed:timeline:#{account.id}")
end end
end end

@ -34,7 +34,7 @@ class Scheduler::ScheduledStatusesScheduler
end end
def unpublish_expired_announcements! def unpublish_expired_announcements!
expired_announcements.in_batches.update_all(published: false) expired_announcements.in_batches.update_all(published: false, scheduled_at: nil)
end end
def expired_announcements def expired_announcements

@ -0,0 +1,14 @@
# frozen_string_literal: true
class UnpublishAnnouncementWorker
include Sidekiq::Worker
include Redisable
def perform(announcement_id)
payload = Oj.dump(event: :'announcement.delete', payload: announcement_id.to_s)
FeedManager.instance.with_active_accounts do |account|
redis.publish("timeline:#{account.id}", payload) if redis.exists("subscribed:timeline:#{account.id}")
end
end
end

@ -73,6 +73,7 @@ module Mastodon
:it, :it,
:ja, :ja,
:ka, :ka,
:kab,
:kk, :kk,
:kn, :kn,
:ko, :ko,

@ -232,6 +232,7 @@ en:
deleted_status: "(deleted status)" deleted_status: "(deleted status)"
title: Audit log title: Audit log
announcements: announcements:
destroyed_msg: Announcement successfully deleted!
edit: edit:
title: Edit announcement title: Edit announcement
empty: No announcements found. empty: No announcements found.
@ -240,8 +241,12 @@ en:
create: Create announcement create: Create announcement
title: New announcement title: New announcement
published: Published published: Published
published_msg: Announcement successfully published!
scheduled_msg: Announcement scheduled for publication!
time_range: Time range time_range: Time range
title: Announcements title: Announcements
unpublished_msg: Announcement successfully unpublished!
updated_msg: Announcement successfully updated!
custom_emojis: custom_emojis:
assign_category: Assign category assign_category: Assign category
by_domain: Domain by_domain: Domain

@ -179,7 +179,13 @@ Rails.application.routes.draw do
resources :email_domain_blocks, only: [:index, :new, :create, :destroy] resources :email_domain_blocks, only: [:index, :new, :create, :destroy]
resources :action_logs, only: [:index] resources :action_logs, only: [:index]
resources :warning_presets, except: [:new] resources :warning_presets, except: [:new]
resources :announcements, except: [:show]
resources :announcements, except: [:show] do
member do
post :publish
post :unpublish
end
end
resource :settings, only: [:edit, :update] resource :settings, only: [:edit, :update]

@ -70,20 +70,22 @@ class IdsToBigints < ActiveRecord::Migration[5.1]
included_columns << [:deprecated_preview_cards, :id] if table_exists?(:deprecated_preview_cards) included_columns << [:deprecated_preview_cards, :id] if table_exists?(:deprecated_preview_cards)
# Print out a warning that this will probably take a while. # Print out a warning that this will probably take a while.
say '' if $stdout.isatty
say 'WARNING: This migration may take a *long* time for large instances' say ''
say 'It will *not* lock tables for any significant time, but it may run' say 'WARNING: This migration may take a *long* time for large instances'
say 'for a very long time. We will pause for 10 seconds to allow you to' say 'It will *not* lock tables for any significant time, but it may run'
say 'interrupt this migration if you are not ready.' say 'for a very long time. We will pause for 10 seconds to allow you to'
say '' say 'interrupt this migration if you are not ready.'
say 'This migration has some sections that can be safely interrupted' say ''
say 'and restarted later, and will tell you when those are occurring.' say 'This migration has some sections that can be safely interrupted'
say '' say 'and restarted later, and will tell you when those are occurring.'
say 'For more information, see https://github.com/tootsuite/mastodon/pull/5088' say ''
say 'For more information, see https://github.com/tootsuite/mastodon/pull/5088'
10.downto(1) do |i| 10.downto(1) do |i|
say "Continuing in #{i} second#{i == 1 ? '' : 's'}...", true say "Continuing in #{i} second#{i == 1 ? '' : 's'}...", true
sleep 1 sleep 1
end
end end
tables = included_columns.map(&:first).uniq tables = included_columns.map(&:first).uniq

@ -20,19 +20,21 @@ class FixAccountsUniqueIndex < ActiveRecord::Migration[5.2]
disable_ddl_transaction! disable_ddl_transaction!
def up def up
say '' if $stdout.isatty
say 'WARNING: This migration may take a *long* time for large instances' say ''
say 'It will *not* lock tables for any significant time, but it may run' say 'WARNING: This migration may take a *long* time for large instances'
say 'for a very long time. We will pause for 10 seconds to allow you to' say 'It will *not* lock tables for any significant time, but it may run'
say 'interrupt this migration if you are not ready.' say 'for a very long time. We will pause for 10 seconds to allow you to'
say '' say 'interrupt this migration if you are not ready.'
say 'This migration will irreversibly delete user accounts with duplicate' say ''
say 'usernames. You may use the `rake mastodon:maintenance:find_duplicate_usernames`' say 'This migration will irreversibly delete user accounts with duplicate'
say 'task to manually deal with such accounts before running this migration.' say 'usernames. You may use the `rake mastodon:maintenance:find_duplicate_usernames`'
say 'task to manually deal with such accounts before running this migration.'
10.downto(1) do |i|
say "Continuing in #{i} second#{i == 1 ? '' : 's'}...", true 10.downto(1) do |i|
sleep 1 say "Continuing in #{i} second#{i == 1 ? '' : 's'}...", true
sleep 1
end
end end
duplicates = Account.connection.select_all('SELECT string_agg(id::text, \',\') AS ids FROM accounts GROUP BY lower(username), lower(domain) HAVING count(*) > 1').to_hash duplicates = Account.connection.select_all('SELECT string_agg(id::text, \',\') AS ids FROM accounts GROUP BY lower(username), lower(domain) HAVING count(*) > 1').to_hash

@ -62,16 +62,18 @@ class MigrateAccountConversations < ActiveRecord::Migration[5.2]
end end
def up def up
say '' if $stdout.isatty
say 'WARNING: This migration may take a *long* time for large instances' say ''
say 'It will *not* lock tables for any significant time, but it may run' say 'WARNING: This migration may take a *long* time for large instances'
say 'for a very long time. We will pause for 10 seconds to allow you to' say 'It will *not* lock tables for any significant time, but it may run'
say 'interrupt this migration if you are not ready.' say 'for a very long time. We will pause for 10 seconds to allow you to'
say '' say 'interrupt this migration if you are not ready.'
say ''
10.downto(1) do |i|
say "Continuing in #{i} second#{i == 1 ? '' : 's'}...", true 10.downto(1) do |i|
sleep 1 say "Continuing in #{i} second#{i == 1 ? '' : 's'}...", true
sleep 1
end
end end
migrated = 0 migrated = 0

@ -0,0 +1,5 @@
class AddPublishedAtToAnnouncements < ActiveRecord::Migration[5.2]
def change
add_column :announcements, :published_at, :datetime
end
end

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2020_01_19_112504) do ActiveRecord::Schema.define(version: 2020_01_26_203551) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -228,6 +228,7 @@ ActiveRecord::Schema.define(version: 2020_01_19_112504) do
t.datetime "ends_at" t.datetime "ends_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "published_at"
end end
create_table "backups", force: :cascade do |t| create_table "backups", force: :cascade do |t|

Loading…
Cancel
Save