diff options
author | ThibG <thib@sitedethib.com> | 2020-01-27 17:26:53 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-01-27 17:26:53 +0100 |
commit | 67b8af34b3df55ba74a53af731f275d0a4c6d9f8 (patch) | |
tree | d1f2420e0ea7cea72ea8032ce005c39c36f38a43 | |
parent | 8924743349ec5ce37cd949445e071c14968ec2ec (diff) | |
parent | cf230d551f3081115402f74d4326a2682f27fedc (diff) |
Merge pull request #1271 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
46 files changed, 471 insertions, 183 deletions
diff --git a/Gemfile b/Gemfile index 042db3a55..3949c0f4d 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 8394b7baa..bfc0d5c55 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb index 02198f0b5..494fd13d0 100644 --- a/app/controllers/admin/announcements_controller.rb +++ b/app/controllers/admin/announcements_controller.rb @@ -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 diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 10aaf3aca..825aa974d 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -36,6 +36,7 @@ module SettingsHelper it: 'Italiano', ja: '日本語', ka: 'ქართული', + kab: 'Taqbaylit', kk: 'Қазақша', kn: 'ಕನ್ನಡ', ko: '한국어', diff --git a/app/javascript/flavours/glitch/actions/announcements.js b/app/javascript/flavours/glitch/actions/announcements.js index 930493b6a..a19a4c708 100644 --- a/app/javascript/flavours/glitch/actions/announcements.js +++ b/app/javascript/flavours/glitch/actions/announcements.js @@ -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, +}); diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index 8294fbf36..2f82ea805 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -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 'mastodon/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; } }, }; diff --git a/app/javascript/flavours/glitch/components/animated_number.js b/app/javascript/flavours/glitch/components/animated_number.js index 4d14d2924..e3235e368 100644 --- a/app/javascript/flavours/glitch/components/animated_number.js +++ b/app/javascript/flavours/glitch/components/animated_number.js @@ -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> )} diff --git a/app/javascript/flavours/glitch/components/relative_timestamp.js b/app/javascript/flavours/glitch/components/relative_timestamp.js index aa4b73cfe..711181dcd 100644 --- a/app/javascript/flavours/glitch/components/relative_timestamp.js +++ b/app/javascript/flavours/glitch/components/relative_timestamp.js @@ -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)}> diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js index 9a7d175c4..b56d56606 100644 --- a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js +++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js @@ -6,13 +6,15 @@ import PropTypes from 'prop-types'; import IconButton from 'flavours/glitch/components/icon_button'; import Icon from 'flavours/glitch/components/icon'; 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 { mascot } from 'flavours/glitch/util/initial_state'; import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; import classNames from 'classnames'; import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker'; 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({ 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> ); diff --git a/app/javascript/flavours/glitch/reducers/announcements.js b/app/javascript/flavours/glitch/reducers/announcements.js index 1cfb598fb..3215c1c2d 100644 --- a/app/javascript/flavours/glitch/reducers/announcements.js +++ b/app/javascript/flavours/glitch/reducers/announcements.js @@ -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; } diff --git a/app/javascript/flavours/glitch/styles/components/announcements.scss b/app/javascript/flavours/glitch/styles/components/announcements.scss index 00585cb1c..909957bf1 100644 --- a/app/javascript/flavours/glitch/styles/components/announcements.scss +++ b/app/javascript/flavours/glitch/styles/components/announcements.scss @@ -17,7 +17,7 @@ } a { - color: $highlight-text-color; + color: $secondary-text-color; text-decoration: none; &:hover { @@ -33,6 +33,10 @@ } } } + + &.unhandled-link { + color: lighten($ui-highlight-color, 8%); + } } } diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 00f947cdc..fa26c4706 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -510,6 +510,7 @@ .status-check-box__status { margin: 10px 0 10px 10px; flex: 1; + overflow: hidden; .media-gallery { max-width: 250px; diff --git a/app/javascript/mastodon/actions/announcements.js b/app/javascript/mastodon/actions/announcements.js index 53b19a6fd..f072a407f 100644 --- a/app/javascript/mastodon/actions/announcements.js +++ b/app/javascript/mastodon/actions/announcements.js @@ -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, +}); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index ac325f74c..79b08bdda 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -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; } }, }; diff --git a/app/javascript/mastodon/components/animated_number.js b/app/javascript/mastodon/components/animated_number.js index 2d359fbc6..f3127c88e 100644 --- a/app/javascript/mastodon/components/animated_number.js +++ b/app/javascript/mastodon/components/animated_number.js @@ -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> )} diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js index aa4b73cfe..711181dcd 100644 --- a/app/javascript/mastodon/components/relative_timestamp.js +++ b/app/javascript/mastodon/components/relative_timestamp.js @@ -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)}> diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js index 8ff1b0b4e..1fd28a119 100644 --- a/app/javascript/mastodon/features/getting_started/components/announcements.js +++ b/app/javascript/mastodon/features/getting_started/components/announcements.js @@ -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> ); diff --git a/app/javascript/mastodon/reducers/announcements.js b/app/javascript/mastodon/reducers/announcements.js index 1cfb598fb..3215c1c2d 100644 --- a/app/javascript/mastodon/reducers/announcements.js +++ b/app/javascript/mastodon/reducers/announcements.js @@ -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; } diff --git a/app/javascript/styles/mastodon/_mixins.scss b/app/javascript/styles/mastodon/_mixins.scss index 4e3e1434a..68cad0fde 100644 --- a/app/javascript/styles/mastodon/_mixins.scss +++ b/app/javascript/styles/mastodon/_mixins.scss @@ -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; } diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss index 201235187..cf16b54ac 100644 --- a/app/javascript/styles/mastodon/about.scss +++ b/app/javascript/styles/mastodon/about.scss @@ -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; } diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss index a1681afe6..5dc067f0e 100644 --- a/app/javascript/styles/mastodon/accounts.scss +++ b/app/javascript/styles/mastodon/accounts.scss @@ -129,6 +129,7 @@ .older, .newer { + text-transform: uppercase; color: $secondary-text-color; } diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 41654af55..9f7ffe47a 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -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 { diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index cdb932749..ee1908e3e 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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 { diff --git a/app/javascript/styles/mastodon/footer.scss b/app/javascript/styles/mastodon/footer.scss index f016248ba..00d290883 100644 --- a/app/javascript/styles/mastodon/footer.scss +++ b/app/javascript/styles/mastodon/footer.scss @@ -94,6 +94,7 @@ } h4 { + text-transform: uppercase; font-weight: 700; margin-bottom: 8px; color: $darker-text-color; diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 65cefbd7c..c9ad68f94 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -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; diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss index 90e1581bb..ca050a8d9 100644 --- a/app/javascript/styles/mastodon/widgets.scss +++ b/app/javascript/styles/mastodon/widgets.scss @@ -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; diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index cdb8383df..3ce182809 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -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 diff --git a/app/models/announcement.rb b/app/models/announcement.rb index 4da9f94d6..d99502f44 100644 --- a/app/models/announcement.rb +++ b/app/models/announcement.rb @@ -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 diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 657dd36f3..4e497cdbd 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -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 diff --git a/app/serializers/rest/announcement_serializer.rb b/app/serializers/rest/announcement_serializer.rb index 924d87b34..ae962aa1d 100644 --- a/app/serializers/rest/announcement_serializer.rb +++ b/app/serializers/rest/announcement_serializer.rb @@ -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 diff --git a/app/views/admin/announcements/_announcement.html.haml b/app/views/admin/announcements/_announcement.html.haml index 75768c7ba..f8a6c66de 100644 --- a/app/views/admin/announcements/_announcement.html.haml +++ b/app/views/admin/announcements/_announcement.html.haml @@ -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) diff --git a/app/views/admin/announcements/edit.html.haml b/app/views/admin/announcements/edit.html.haml index c5c605e93..5f56db5e7 100644 --- a/app/views/admin/announcements/edit.html.haml +++ b/app/views/admin/announcements/edit.html.haml @@ -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 diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml index dee99475a..ecf12b649 100644 --- a/app/views/directories/index.html.haml +++ b/app/views/directories/index.html.haml @@ -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') diff --git a/app/views/relationships/_account.html.haml b/app/views/relationships/_account.html.haml index 6c22deb51..af5a4aaf7 100644 --- a/app/views/relationships/_account.html.haml +++ b/app/views/relationships/_account.html.haml @@ -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') diff --git a/app/workers/publish_announcement_reaction_worker.rb b/app/workers/publish_announcement_reaction_worker.rb index e01deb64d..418dc7127 100644 --- a/app/workers/publish_announcement_reaction_worker.rb +++ b/app/workers/publish_announcement_reaction_worker.rb @@ -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 diff --git a/app/workers/publish_scheduled_announcement_worker.rb b/app/workers/publish_scheduled_announcement_worker.rb index 4b2014e34..efca39d3d 100644 --- a/app/workers/publish_scheduled_announcement_worker.rb +++ b/app/workers/publish_scheduled_announcement_worker.rb @@ -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 diff --git a/app/workers/scheduler/scheduled_statuses_scheduler.rb b/app/workers/scheduler/scheduled_statuses_scheduler.rb index 4262f1d01..9cfe949de 100644 --- a/app/workers/scheduler/scheduled_statuses_scheduler.rb +++ b/app/workers/scheduler/scheduled_statuses_scheduler.rb @@ -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 diff --git a/app/workers/unpublish_announcement_worker.rb b/app/workers/unpublish_announcement_worker.rb new file mode 100644 index 000000000..e99d70cf8 --- /dev/null +++ b/app/workers/unpublish_announcement_worker.rb @@ -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 diff --git a/config/application.rb b/config/application.rb index bf25fa0d9..1baa166ce 100644 --- a/config/application.rb +++ b/config/application.rb @@ -73,6 +73,7 @@ module Mastodon :it, :ja, :ka, + :kab, :kk, :kn, :ko, diff --git a/config/locales/en.yml b/config/locales/en.yml index 432661c09..5c402c0dc 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 322d66aec..b56f7fd87 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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] diff --git a/db/migrate/20170918125918_ids_to_bigints.rb b/db/migrate/20170918125918_ids_to_bigints.rb index 8e19468db..bcb2e9eca 100644 --- a/db/migrate/20170918125918_ids_to_bigints.rb +++ b/db/migrate/20170918125918_ids_to_bigints.rb @@ -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 diff --git a/db/migrate/20180528141303_fix_accounts_unique_index.rb b/db/migrate/20180528141303_fix_accounts_unique_index.rb index bbbf28d81..5d7b3c463 100644 --- a/db/migrate/20180528141303_fix_accounts_unique_index.rb +++ b/db/migrate/20180528141303_fix_accounts_unique_index.rb @@ -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 diff --git a/db/migrate/20181024224956_migrate_account_conversations.rb b/db/migrate/20181024224956_migrate_account_conversations.rb index 9f6c94fd1..12e0a70fa 100644 --- a/db/migrate/20181024224956_migrate_account_conversations.rb +++ b/db/migrate/20181024224956_migrate_account_conversations.rb @@ -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 diff --git a/db/migrate/20200126203551_add_published_at_to_announcements.rb b/db/migrate/20200126203551_add_published_at_to_announcements.rb new file mode 100644 index 000000000..d99f95694 --- /dev/null +++ b/db/migrate/20200126203551_add_published_at_to_announcements.rb @@ -0,0 +1,5 @@ +class AddPublishedAtToAnnouncements < ActiveRecord::Migration[5.2] + def change + add_column :announcements, :published_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 2f41dee97..73ac78b80 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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| |