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

Merge upstream changes
This commit is contained in:
ThibG 2020-01-27 17:26:53 +01:00 committed by GitHub
commit 67b8af34b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 467 additions and 179 deletions

View File

@ -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'

View File

@ -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)

View File

@ -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

View File

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

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_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,
});

View File

@ -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;
} }
}, },
}; };

View File

@ -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>
)} )}

View File

@ -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)}>

View File

@ -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);
return ( const styles = visibleReactions.map(reaction => ({
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}> key: reaction.get('name'),
{visibleReactions.map(reaction => ( data: reaction,
<Reaction style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
key={reaction.get('name')} })).toArray();
reaction={reaction}
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' />} />} return (
</div> <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> </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>
); );

View File

@ -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) {
return updater(reaction);
}
return reaction; if (idx > -1) {
}); return reactions.update(idx, reaction => updater(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;
} }

View File

@ -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%);
}
} }
} }

View File

@ -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;

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_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,
});

View File

@ -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;
} }
}, },
}; };

View File

@ -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>
)} )}

View File

@ -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)}>

View File

@ -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);
return ( const styles = visibleReactions.map(reaction => ({
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}> key: reaction.get('name'),
{visibleReactions.map(reaction => ( data: reaction,
<Reaction style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
key={reaction.get('name')} })).toArray();
reaction={reaction}
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' />} />} return (
</div> <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> </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>
); );

View File

@ -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) {
return updater(reaction);
}
return reaction; if (idx > -1) {
}); return reactions.update(idx, reaction => updater(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;
} }

View File

@ -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;
} }

View File

@ -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;
} }

View File

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

View File

@ -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 {

View File

@ -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 {

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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.published = true
self.ends_at = ends_at.change(hour: 23, min: 59, sec: 59) if all_day? && ends_at.present? self.published_at = Time.now.utc
end
def queue_publish
PublishScheduledAnnouncementWorker.perform_async(id) if scheduled_at.blank?
end end
end end

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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')

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

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

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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| 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
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

View File

@ -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| 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
migrated = 0 migrated = 0

View File

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

View File

@ -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|