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,16 +251,33 @@ 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 (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}> <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{visibleReactions.map(reaction => ( {items.map(({ key, data, style }) => (
<Reaction <Reaction
key={reaction.get('name')} key={key}
reaction={reaction} reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
announcementId={this.props.announcementId} announcementId={this.props.announcementId}
addReaction={this.props.addReaction} addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction} removeReaction={this.props.removeReaction}
@ -267,6 +287,8 @@ class ReactionsBar extends ImmutablePureComponent {
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />} {visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
</div> </div>
)}
</TransitionMotion>
); );
} }
@ -367,11 +389,13 @@ class Announcements extends ImmutablePureComponent {
))} ))}
</ReactSwipeableViews> </ReactSwipeableViews>
{announcements.size > 1 && (
<div className='announcements__pagination'> <div className='announcements__pagination'>
<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.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
<span>{index + 1} / {announcements.size}</span> <span>{index + 1} / {announcements.size}</span>
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} /> <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
</div> </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,16 +251,33 @@ 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 (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}> <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
{visibleReactions.map(reaction => ( {items.map(({ key, data, style }) => (
<Reaction <Reaction
key={reaction.get('name')} key={key}
reaction={reaction} reaction={data}
style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
announcementId={this.props.announcementId} announcementId={this.props.announcementId}
addReaction={this.props.addReaction} addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction} removeReaction={this.props.removeReaction}
@ -267,6 +287,8 @@ class ReactionsBar extends ImmutablePureComponent {
{visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />} {visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
</div> </div>
)}
</TransitionMotion>
); );
} }
@ -367,11 +389,13 @@ class Announcements extends ImmutablePureComponent {
))} ))}
</ReactSwipeableViews> </ReactSwipeableViews>
{announcements.size > 1 && (
<div className='announcements__pagination'> <div className='announcements__pagination'>
<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.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
<span>{index + 1} / {announcements.size}</span> <span>{index + 1} / {announcements.size}</span>
<IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} /> <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
</div> </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,6 +70,7 @@ 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.
if $stdout.isatty
say '' say ''
say 'WARNING: This migration may take a *long* time for large instances' say 'WARNING: This migration may take a *long* time for large instances'
say 'It will *not* lock tables for any significant time, but it may run' say 'It will *not* lock tables for any significant time, but it may run'
@ -85,6 +86,7 @@ class IdsToBigints < ActiveRecord::Migration[5.1]
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
table_sizes = {} table_sizes = {}

View file

@ -20,6 +20,7 @@ class FixAccountsUniqueIndex < ActiveRecord::Migration[5.2]
disable_ddl_transaction! disable_ddl_transaction!
def up def up
if $stdout.isatty
say '' say ''
say 'WARNING: This migration may take a *long* time for large instances' say 'WARNING: This migration may take a *long* time for large instances'
say 'It will *not* lock tables for any significant time, but it may run' say 'It will *not* lock tables for any significant time, but it may run'
@ -34,6 +35,7 @@ class FixAccountsUniqueIndex < ActiveRecord::Migration[5.2]
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,6 +62,7 @@ class MigrateAccountConversations < ActiveRecord::Migration[5.2]
end end
def up def up
if $stdout.isatty
say '' say ''
say 'WARNING: This migration may take a *long* time for large instances' say 'WARNING: This migration may take a *long* time for large instances'
say 'It will *not* lock tables for any significant time, but it may run' say 'It will *not* lock tables for any significant time, but it may run'
@ -73,6 +74,7 @@ class MigrateAccountConversations < ActiveRecord::Migration[5.2]
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
last_time = Time.zone.now last_time = Time.zone.now

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|