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

Conflicts:
- `app/serializers/rest/account_serializer.rb`:
  Upstream added code too close to glitch-soc-specific followers-hiding code.
  Ported upstream changes.
master
Thibaut Girka 5 years ago
commit c56a504d11
  1. 1
      Gemfile
  2. 3
      Gemfile.lock
  3. 25
      app/controllers/admin/announcements_controller.rb
  4. 1
      app/helpers/settings_helper.rb
  5. 14
      app/javascript/mastodon/actions/announcements.js
  6. 10
      app/javascript/mastodon/actions/streaming.js
  7. 32
      app/javascript/mastodon/components/animated_number.js
  8. 16
      app/javascript/mastodon/components/relative_timestamp.js
  9. 66
      app/javascript/mastodon/features/getting_started/components/announcements.js
  10. 51
      app/javascript/mastodon/reducers/announcements.js
  11. 3
      app/javascript/styles/mastodon/_mixins.scss
  12. 3
      app/javascript/styles/mastodon/about.scss
  13. 1
      app/javascript/styles/mastodon/accounts.scss
  14. 10
      app/javascript/styles/mastodon/admin.scss
  15. 34
      app/javascript/styles/mastodon/components.scss
  16. 1
      app/javascript/styles/mastodon/footer.scss
  17. 2
      app/javascript/styles/mastodon/forms.scss
  18. 7
      app/javascript/styles/mastodon/widgets.scss
  19. 4
      app/lib/feed_manager.rb
  20. 29
      app/models/announcement.rb
  21. 4
      app/serializers/rest/account_serializer.rb
  22. 3
      app/serializers/rest/announcement_serializer.rb
  23. 9
      app/views/admin/announcements/_announcement.html.haml
  24. 2
      app/views/admin/announcements/edit.html.haml
  25. 2
      app/views/directories/index.html.haml
  26. 2
      app/views/relationships/_account.html.haml
  27. 2
      app/workers/publish_announcement_reaction_worker.rb
  28. 5
      app/workers/publish_scheduled_announcement_worker.rb
  29. 2
      app/workers/scheduler/scheduled_statuses_scheduler.rb
  30. 14
      app/workers/unpublish_announcement_worker.rb
  31. 1
      config/application.rb
  32. 5
      config/locales/en.yml
  33. 8
      config/routes.rb
  34. 28
      db/migrate/20170918125918_ids_to_bigints.rb
  35. 28
      db/migrate/20180528141303_fix_accounts_unique_index.rb
  36. 22
      db/migrate/20181024224956_migrate_account_conversations.rb
  37. 5
      db/migrate/20200126203551_add_published_at_to_announcements.rb
  38. 3
      db/schema.rb

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

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

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

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

@ -5,6 +5,7 @@ export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';
export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS';
export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL';
export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE';
export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE';
export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
@ -139,8 +140,11 @@ export const updateReaction = reaction => ({
reaction,
});
export function toggleShowAnnouncements() {
return dispatch => {
dispatch({ type: ANNOUNCEMENTS_TOGGLE_SHOW });
};
}
export const toggleShowAnnouncements = () => ({
type: ANNOUNCEMENTS_TOGGLE_SHOW,
});
export const deleteAnnouncement = id => ({
type: ANNOUNCEMENTS_DELETE,
id,
});

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

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

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

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

@ -9,6 +9,7 @@ import {
ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
ANNOUNCEMENTS_TOGGLE_SHOW,
ANNOUNCEMENTS_DELETE,
} from '../actions/announcements';
import { Map as ImmutableMap, List as ImmutableList, Set as ImmutableSet, fromJS } from 'immutable';
@ -22,14 +23,10 @@ const initialState = ImmutableMap({
const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => {
if (announcement.get('id') === id) {
return announcement.update('reactions', reactions => {
if (reactions.find(reaction => reaction.get('name') === name)) {
return reactions.map(reaction => {
if (reaction.get('name') === name) {
return updater(reaction);
}
return reaction;
});
const idx = reactions.findIndex(reaction => reaction.get('name') === name);
if (idx > -1) {
return reactions.update(idx, reaction => updater(reaction));
}
return reactions.push(updater(fromJS({ name, count: 0 })));
@ -46,13 +43,33 @@ const addReaction = (state, id, name) => updateReaction(state, id, name, x => x.
const removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).update('count', y => y - 1));
const addUnread = (state, items) => {
if (state.get('show')) return state;
if (state.get('show')) {
return state;
}
const newIds = ImmutableSet(items.map(x => x.get('id')));
const oldIds = ImmutableSet(state.get('items').map(x => x.get('id')));
return state.update('unread', unread => unread.union(newIds.subtract(oldIds)));
};
const sortAnnouncements = list => list.sortBy(x => x.get('starts_at') || x.get('published_at'));
const updateAnnouncement = (state, announcement) => {
const idx = state.get('items').findIndex(x => x.get('id') === announcement.get('id'));
state = addUnread(state, [announcement]);
if (idx > -1) {
// Deep merge is used because announcements from the streaming API do not contain
// personalized data about which reactions have been selected by the given user,
// and that is information we want to preserve
return state.update('items', list => sortAnnouncements(list.update(idx, x => x.mergeDeep(announcement))));
}
return state.update('items', list => sortAnnouncements(list.unshift(announcement)));
};
export default function announcementsReducer(state = initialState, action) {
switch(action.type) {
case ANNOUNCEMENTS_TOGGLE_SHOW:
@ -65,15 +82,17 @@ export default function announcementsReducer(state = initialState, action) {
case ANNOUNCEMENTS_FETCH_SUCCESS:
return state.withMutations(map => {
const items = fromJS(action.announcements);
map.set('unread', ImmutableSet());
addUnread(map, items);
map.set('items', items);
map.set('isLoading', false);
addUnread(map, items);
});
case ANNOUNCEMENTS_FETCH_FAIL:
return state.set('isLoading', false);
case ANNOUNCEMENTS_UPDATE:
return addUnread(state, [fromJS(action.announcement)]).update('items', list => list.unshift(fromJS(action.announcement)).sortBy(announcement => announcement.get('starts_at')));
return updateAnnouncement(state, fromJS(action.announcement));
case ANNOUNCEMENTS_REACTION_UPDATE:
return updateReactionCount(state, action.reaction);
case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
@ -82,6 +101,16 @@ export default function announcementsReducer(state = initialState, action) {
case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST:
case ANNOUNCEMENTS_REACTION_ADD_FAIL:
return removeReaction(state, action.id, action.name);
case ANNOUNCEMENTS_DELETE:
return state.update('unread', set => set.delete(action.id)).update('items', list => {
const idx = list.findIndex(x => x.get('id') === action.id);
if (idx > -1) {
return list.delete(idx);
}
return list;
});
default:
return state;
}

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

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

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

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

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

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

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

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

@ -11,6 +11,10 @@ class FeedManager
# Must be <= MAX_ITEMS or the tracking sets will grow forever
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)
return "feed:#{type}:#{id}" unless subtype

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

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

@ -1,7 +1,8 @@
# frozen_string_literal: true
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 :tags, serializer: REST::StatusSerializer::TagSerializer

@ -10,5 +10,12 @@
- else
= l(announcement.created_at)
%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)

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

@ -47,7 +47,7 @@
%small= t('accounts.followers', count: account.followers_count).downcase
.accounts-table__count
- 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
= t('accounts.never_active')

@ -14,7 +14,7 @@
%small= t('accounts.followers', count: account.followers_count).downcase
%td.accounts-table__count
- 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
\-
%small= t('accounts.last_active')

@ -13,7 +13,7 @@ class PublishAnnouncementReactionWorker
payload = InlineRenderer.render(reaction, nil, :reaction).tap { |h| h[:announcement_id] = announcement_id.to_s }
payload = 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}")
end
rescue ActiveRecord::RecordNotFound

@ -6,12 +6,13 @@ class PublishScheduledAnnouncementWorker
def perform(announcement_id)
announcement = Announcement.find(announcement_id)
announcement.update(published: true)
announcement.publish! unless announcement.published?
payload = InlineRenderer.render(announcement, nil, :announcement)
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}")
end
end

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

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

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

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

@ -179,7 +179,13 @@ Rails.application.routes.draw do
resources :email_domain_blocks, only: [:index, :new, :create, :destroy]
resources :action_logs, only: [:index]
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]

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

@ -20,19 +20,21 @@ class FixAccountsUniqueIndex < ActiveRecord::Migration[5.2]
disable_ddl_transaction!
def up
say ''
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 'for a very long time. We will pause for 10 seconds to allow you to'
say 'interrupt this migration if you are not ready.'
say ''
say 'This migration will irreversibly delete user accounts with duplicate'
say 'usernames. You may use the `rake mastodon:maintenance:find_duplicate_usernames`'
say 'task to manually deal with such accounts before running this migration.'
10.downto(1) do |i|
say "Continuing in #{i} second#{i == 1 ? '' : 's'}...", true
sleep 1
if $stdout.isatty
say ''
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 'for a very long time. We will pause for 10 seconds to allow you to'
say 'interrupt this migration if you are not ready.'
say ''
say 'This migration will irreversibly delete user accounts with duplicate'
say 'usernames. You may use the `rake mastodon:maintenance:find_duplicate_usernames`'
say 'task to manually deal with such accounts before running this migration.'
10.downto(1) do |i|
say "Continuing in #{i} second#{i == 1 ? '' : 's'}...", true
sleep 1
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

@ -62,16 +62,18 @@ class MigrateAccountConversations < ActiveRecord::Migration[5.2]
end
def up
say ''
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 'for a very long time. We will pause for 10 seconds to allow you to'
say 'interrupt this migration if you are not ready.'
say ''
10.downto(1) do |i|
say "Continuing in #{i} second#{i == 1 ? '' : 's'}...", true
sleep 1
if $stdout.isatty
say ''
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 'for a very long time. We will pause for 10 seconds to allow you to'
say 'interrupt this migration if you are not ready.'
say ''
10.downto(1) do |i|
say "Continuing in #{i} second#{i == 1 ? '' : 's'}...", true
sleep 1
end
end
migrated = 0

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

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
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
enable_extension "plpgsql"
@ -228,6 +228,7 @@ ActiveRecord::Schema.define(version: 2020_01_19_112504) do
t.datetime "ends_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "published_at"
end
create_table "backups", force: :cascade do |t|

Loading…
Cancel
Save