diff options
Diffstat (limited to 'app')
40 files changed, 217 insertions, 77 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 69cf65b5a..7182ed0fa 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -232,10 +232,11 @@ export function uploadCompose(files) { return function (dispatch, getState) { const uploadLimit = 4; const media = getState().getIn(['compose', 'media_attachments']); + const pending = getState().getIn(['compose', 'pending_media_attachments']); const progress = new Array(files.length).fill(0); let total = Array.from(files).reduce((a, v) => a + v.size, 0); - if (files.length + media.size > uploadLimit) { + if (files.length + media.size + pending > uploadLimit) { dispatch(showAlert(undefined, messages.uploadErrorLimit)); return; } @@ -262,7 +263,7 @@ export function uploadCompose(files) { dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); }, }).then(({ data }) => dispatch(uploadComposeSuccess(data, f))); - }).catch(error => dispatch(uploadComposeFail(error))); + }).catch(error => dispatch(uploadComposeFail(error, true))); }; }; }; @@ -293,10 +294,11 @@ export function changeUploadComposeSuccess(media) { }; }; -export function changeUploadComposeFail(error) { +export function changeUploadComposeFail(error, decrement = false) { return { type: COMPOSE_UPLOAD_CHANGE_FAIL, error: error, + decrement: decrement, skipLoading: true, }; }; diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js index fd0af9f6e..e73ef8d12 100644 --- a/app/javascript/flavours/glitch/components/modal_root.js +++ b/app/javascript/flavours/glitch/components/modal_root.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import 'wicg-inert'; import createHistory from 'history/createBrowserHistory'; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js index a7f9a97da..2d2a7cbe0 100644 --- a/app/javascript/flavours/glitch/components/poll.js +++ b/app/javascript/flavours/glitch/components/poll.js @@ -39,7 +39,8 @@ class Poll extends ImmutablePureComponent { static getDerivedStateFromProps (props, state) { const { poll, intl } = props; - const expired = poll.get('expired') || (new Date(poll.get('expires_at'))).getTime() < intl.now(); + const expires_at = poll.get('expires_at'); + const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now(); return (expired === state.expired) ? null : { expired }; } diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js index 344c4d423..637c4f23a 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.js +++ b/app/javascript/flavours/glitch/components/status_prepend.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; import Icon from 'flavours/glitch/components/icon'; +import { me } from 'flavours/glitch/util/initial_state'; export default class StatusPrepend extends React.PureComponent { @@ -64,12 +65,21 @@ export default class StatusPrepend extends React.PureComponent { /> ); case 'poll': - return ( - <FormattedMessage - id='notification.poll' - defaultMessage='A poll you have voted in has ended' - /> - ); + if (me === account.get('id')) { + return ( + <FormattedMessage + id='notification.own_poll' + defaultMessage='Your poll has ended' + /> + ); + } else { + return ( + <FormattedMessage + id='notification.poll' + defaultMessage='A poll you have voted in has ended' + /> + ); + } } return null; } diff --git a/app/javascript/flavours/glitch/features/compose/containers/options_container.js b/app/javascript/flavours/glitch/features/compose/containers/options_container.js index df842f3bf..c792aa582 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/options_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js @@ -12,11 +12,12 @@ function mapStateToProps (state) { const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']); const poll = state.getIn(['compose', 'poll']); const media = state.getIn(['compose', 'media_attachments']); + const pending_media = state.getIn(['compose', 'pending_media_attachments']); return { acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','), resetFileKey: state.getIn(['compose', 'resetFileKey']), hasPoll: !!poll, - allowMedia: !poll && (media ? media.size < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : true), + allowMedia: !poll && (media ? media.size + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4), hasMedia: media && !!media.size, allowPoll: !(media && !!media.size), showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']), diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js index f5ecf77b9..70e86905f 100644 --- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js @@ -184,6 +184,15 @@ class FocalPointModal extends ImmutablePureComponent { this.setState({ description: e.target.value, dirty: true }); } + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + this.setState({ description: e.target.value, dirty: true }); + this.handleSubmit(); + } + } + handleSubmit = () => { this.props.onSave(this.state.description, this.state.focusX, this.state.focusY); this.props.onClose(); @@ -254,6 +263,7 @@ class FocalPointModal extends ImmutablePureComponent { className='setting-text light' value={detecting ? '…' : description} onChange={this.handleChange} + onKeyDown={this.handleKeyDown} disabled={detecting} autoFocus /> diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js index 4ca853563..c01d0e5bc 100644 --- a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js +++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js @@ -19,9 +19,9 @@ const getRegex = createSelector([ return regex; }); -const makeGetStatusIds = () => createSelector([ +const makeGetStatusIds = (pending = false) => createSelector([ (state, { type }) => state.getIn(['settings', type], ImmutableMap()), - (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()), + (state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()), (state) => state.get('statuses'), getRegex, ], (columnSettings, statusIds, statuses, regex) => { @@ -56,13 +56,14 @@ const makeGetStatusIds = () => createSelector([ const makeMapStateToProps = () => { const getStatusIds = makeGetStatusIds(); + const getPendingStatusIds = makeGetStatusIds(true); const mapStateToProps = (state, { timelineId }) => ({ statusIds: getStatusIds(state, { type: timelineId }), isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), hasMore: state.getIn(['timelines', timelineId, 'hasMore']), - numPending: state.getIn(['timelines', timelineId, 'pendingItems'], ImmutableList()).size, + numPending: getPendingStatusIds(state, { type: timelineId }).size, }); return mapStateToProps; diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index e5925a484..646def8f2 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -174,7 +174,9 @@ class SwitchingColumnsArea extends React.PureComponent { } setRef = c => { - this.node = c.getWrappedInstance(); + if (c) { + this.node = c.getWrappedInstance(); + } } render () { diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js index 767fb023c..973d6ee46 100644 --- a/app/javascript/flavours/glitch/packs/public.js +++ b/app/javascript/flavours/glitch/packs/public.js @@ -1,5 +1,6 @@ import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import ready from 'flavours/glitch/util/ready'; +import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; function main() { const IntlMessageFormat = require('intl-messageformat').default; @@ -118,6 +119,9 @@ function main() { }); } -loadPolyfills().then(main).catch(error => { - console.error(error); -}); +loadPolyfills() + .then(main) + .then(loadKeyboardExtensions) + .catch(error => { + console.error(error); + }); diff --git a/app/javascript/flavours/glitch/packs/settings.js b/app/javascript/flavours/glitch/packs/settings.js index b32f38226..edf1b82e0 100644 --- a/app/javascript/flavours/glitch/packs/settings.js +++ b/app/javascript/flavours/glitch/packs/settings.js @@ -1,5 +1,6 @@ import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import ready from 'flavours/glitch/util/ready'; +import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; function main() { const { delegate } = require('rails-ujs'); @@ -15,6 +16,9 @@ function main() { }); } -loadPolyfills().then(main).catch(error => { - console.error(error); -}); +loadPolyfills() + .then(main) + .then(loadKeyboardExtensions) + .catch(error => { + console.error(error); + }); diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index 17ce5de3c..ac826de2b 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -78,6 +78,7 @@ const initialState = ImmutableMap({ is_changing_upload: false, progress: 0, media_attachments: ImmutableList(), + pending_media_attachments: 0, poll: null, suggestion_token: null, suggestions: ImmutableList(), @@ -201,6 +202,7 @@ function appendMedia(state, media, file) { map.set('is_uploading', false); map.set('resetFileKey', Math.floor((Math.random() * 0x10000))); map.set('idempotencyKey', uuid()); + map.update('pending_media_attachments', n => n - 1); if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) { map.set('sensitive', true); @@ -423,11 +425,11 @@ export default function compose(state = initialState, action) { case COMPOSE_UPLOAD_CHANGE_FAIL: return state.set('is_changing_upload', false); case COMPOSE_UPLOAD_REQUEST: - return state.set('is_uploading', true); + return state.set('is_uploading', true).update('pending_media_attachments', n => n + 1); case COMPOSE_UPLOAD_SUCCESS: return appendMedia(state, fromJS(action.media), action.file); case COMPOSE_UPLOAD_FAIL: - return state.set('is_uploading', false); + return state.set('is_uploading', false).update('pending_media_attachments', n => action.decrement ? n - 1 : n); case COMPOSE_UPLOAD_UNDO: return removeMedia(state, action.media_id); case COMPOSE_UPLOAD_PROGRESS: diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss index 92a29a933..51287f62e 100644 --- a/app/javascript/flavours/glitch/styles/components/composer.scss +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -249,6 +249,7 @@ .compose-form__autosuggest-wrapper, .autosuggest-input { position: relative; + width: 100%; label { .autosuggest-textarea__textarea { diff --git a/app/javascript/flavours/glitch/util/load_keyboard_extensions.js b/app/javascript/flavours/glitch/util/load_keyboard_extensions.js new file mode 100644 index 000000000..2dd0e45fa --- /dev/null +++ b/app/javascript/flavours/glitch/util/load_keyboard_extensions.js @@ -0,0 +1,16 @@ +// On KaiOS, we may not be able to use a mouse cursor or navigate using Tab-based focus, so we install +// special left/right focus navigation keyboard listeners, at least on public pages (i.e. so folks +// can at least log in using KaiOS devices). + +function importArrowKeyNavigation() { + return import(/* webpackChunkName: "arrow-key-navigation" */ 'arrow-key-navigation'); +} + +export default function loadKeyboardExtensions() { + if (/KAIOS/.test(navigator.userAgent)) { + return importArrowKeyNavigation().then(arrowKeyNav => { + arrowKeyNav.register(); + }); + } + return Promise.resolve(); +} diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 8e7906c73..727f02718 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -205,10 +205,11 @@ export function uploadCompose(files) { return function (dispatch, getState) { const uploadLimit = 4; const media = getState().getIn(['compose', 'media_attachments']); + const pending = getState().getIn(['compose', 'pending_media_attachments']); const progress = new Array(files.length).fill(0); let total = Array.from(files).reduce((a, v) => a + v.size, 0); - if (files.length + media.size > uploadLimit) { + if (files.length + media.size + pending > uploadLimit) { dispatch(showAlert(undefined, messages.uploadErrorLimit)); return; } @@ -235,7 +236,7 @@ export function uploadCompose(files) { dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); }, }).then(({ data }) => dispatch(uploadComposeSuccess(data, f))); - }).catch(error => dispatch(uploadComposeFail(error))); + }).catch(error => dispatch(uploadComposeFail(error, true))); }; }; }; @@ -266,10 +267,11 @@ export function changeUploadComposeSuccess(media) { }; }; -export function changeUploadComposeFail(error) { +export function changeUploadComposeFail(error, decrement = false) { return { type: COMPOSE_UPLOAD_CHANGE_FAIL, error: error, + decrement: decrement, skipLoading: true, }; }; diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index f7108fdb9..78f321da4 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -10,6 +10,12 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { return obj; }, {}); +export function searchTextFromRawStatus (status) { + const spoilerText = status.spoiler_text || ''; + const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); + return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; +} + export function normalizeAccount(account) { account = { ...account }; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 58803d1ae..3a92e0224 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -14,6 +14,7 @@ import { unescapeHTML } from '../utils/html'; import { getFiltersRegex } from '../selectors'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import compareId from 'mastodon/compare_id'; +import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; @@ -60,7 +61,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { if (notification.type === 'mention') { const dropRegex = filters[0]; const regex = filters[1]; - const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content); + const searchIndex = searchTextFromRawStatus(notification.status); if (dropRegex && dropRegex.test(searchIndex)) { return; diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js index 5d4f4bbe1..c55fa0f74 100644 --- a/app/javascript/mastodon/components/modal_root.js +++ b/app/javascript/mastodon/components/modal_root.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import 'wicg-inert'; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js index cdbcf8f70..0edd064e0 100644 --- a/app/javascript/mastodon/components/poll.js +++ b/app/javascript/mastodon/components/poll.js @@ -39,7 +39,8 @@ class Poll extends ImmutablePureComponent { static getDerivedStateFromProps (props, state) { const { poll, intl } = props; - const expired = poll.get('expired') || (new Date(poll.get('expires_at'))).getTime() < intl.now(); + const expires_at = poll.get('expires_at'); + const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now(); return (expired === state.expired) ? null : { expired }; } diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js index bdccf9ce7..923f1029f 100644 --- a/app/javascript/mastodon/features/compose/components/poll_form.js +++ b/app/javascript/mastodon/features/compose/components/poll_form.js @@ -142,9 +142,7 @@ class PollForm extends ImmutablePureComponent { </ul> <div className='poll__footer'> - {options.size < 5 && ( - <button className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button> - )} + <button disabled={options.size >= 5} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button> <select value={expiresIn} onChange={this.handleSelectDuration}> <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> diff --git a/app/javascript/mastodon/features/compose/containers/upload_button_container.js b/app/javascript/mastodon/features/compose/containers/upload_button_container.js index 1471e628b..221b98e31 100644 --- a/app/javascript/mastodon/features/compose/containers/upload_button_container.js +++ b/app/javascript/mastodon/features/compose/containers/upload_button_container.js @@ -3,7 +3,7 @@ import UploadButton from '../components/upload_button'; import { uploadCompose } from '../../../actions/compose'; const mapStateToProps = state => ({ - disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))), + disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size + state.getIn(['compose', 'pending_media_attachments']) > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))), unavailable: state.getIn(['compose', 'poll']) !== null, resetFileKey: state.getIn(['compose', 'resetFileKey']), }); diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 41e9324e6..2dea8afa7 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -1,13 +1,22 @@ import React from 'react'; -import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import StatusContainer from '../../../containers/status_container'; -import AccountContainer from '../../../containers/account_container'; -import { injectIntl, FormattedMessage } from 'react-intl'; -import Permalink from '../../../components/permalink'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; import { HotKeys } from 'react-hotkeys'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me } from 'mastodon/initial_state'; +import StatusContainer from 'mastodon/containers/status_container'; +import AccountContainer from 'mastodon/containers/account_container'; import Icon from 'mastodon/components/icon'; +import Permalink from 'mastodon/components/permalink'; + +const messages = defineMessages({ + favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' }, + follow: { id: 'notification.follow', defaultMessage: '{name} followed you' }, + ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' }, + poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }, + reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, +}); const notificationForScreenReader = (intl, message, timestamp) => { const output = [message]; @@ -107,7 +116,7 @@ class Notification extends ImmutablePureComponent { return ( <HotKeys handlers={this.getHandlers()}> - <div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow', defaultMessage: '{name} followed you' }, { name: account.get('acct') }), notification.get('created_at'))}> + <div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> <Icon id='user-plus' fixedWidth /> @@ -146,7 +155,7 @@ class Notification extends ImmutablePureComponent { return ( <HotKeys handlers={this.getHandlers()}> - <div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.favourite', defaultMessage: '{name} favourited your status' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> + <div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> <Icon id='star' className='star-icon' fixedWidth /> @@ -178,7 +187,7 @@ class Notification extends ImmutablePureComponent { return ( <HotKeys handlers={this.getHandlers()}> - <div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> + <div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> <Icon id='retweet' fixedWidth /> @@ -205,25 +214,31 @@ class Notification extends ImmutablePureComponent { ); } - renderPoll (notification) { + renderPoll (notification, account) { const { intl } = this.props; + const ownPoll = me === account.get('id'); + const message = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll); return ( <HotKeys handlers={this.getHandlers()}> - <div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }), notification.get('created_at'))}> + <div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> <Icon id='tasks' fixedWidth /> </div> <span title={notification.get('created_at')}> - <FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' /> + {ownPoll ? ( + <FormattedMessage id='notification.own_poll' defaultMessage='Your poll has ended' /> + ) : ( + <FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' /> + )} </span> </div> <StatusContainer id={notification.get('status')} - account={notification.get('account')} + account={account} muted withDismiss hidden={this.props.hidden} @@ -253,7 +268,7 @@ class Notification extends ImmutablePureComponent { case 'reblog': return this.renderReblog(notification, link); case 'poll': - return this.renderPoll(notification); + return this.renderPoll(notification, account); } return null; diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index 3694ab904..bbd463fca 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -184,6 +184,15 @@ class FocalPointModal extends ImmutablePureComponent { this.setState({ description: e.target.value, dirty: true }); } + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + this.setState({ description: e.target.value, dirty: true }); + this.handleSubmit(); + } + } + handleSubmit = () => { this.props.onSave(this.state.description, this.state.focusX, this.state.focusY); this.props.onClose(); @@ -254,6 +263,7 @@ class FocalPointModal extends ImmutablePureComponent { className='setting-text light' value={detecting ? '…' : description} onChange={this.handleChange} + onKeyDown={this.handleKeyDown} disabled={detecting} autoFocus /> diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js index 7b8eb652b..9f6cbf988 100644 --- a/app/javascript/mastodon/features/ui/containers/status_list_container.js +++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js @@ -6,9 +6,9 @@ import { createSelector } from 'reselect'; import { debounce } from 'lodash'; import { me } from '../../../initial_state'; -const makeGetStatusIds = () => createSelector([ +const makeGetStatusIds = (pending = false) => createSelector([ (state, { type }) => state.getIn(['settings', type], ImmutableMap()), - (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()), + (state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()), (state) => state.get('statuses'), ], (columnSettings, statusIds, statuses) => { return statusIds.filter(id => { @@ -31,13 +31,14 @@ const makeGetStatusIds = () => createSelector([ const makeMapStateToProps = () => { const getStatusIds = makeGetStatusIds(); + const getPendingStatusIds = makeGetStatusIds(true); const mapStateToProps = (state, { timelineId }) => ({ statusIds: getStatusIds(state, { type: timelineId }), isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), hasMore: state.getIn(['timelines', timelineId, 'hasMore']), - numPending: state.getIn(['timelines', timelineId, 'pendingItems'], ImmutableList()).size, + numPending: getPendingStatusIds(state, { type: timelineId }).size, }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 791ff9a2e..a45ba91eb 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -164,7 +164,9 @@ class SwitchingColumnsArea extends React.PureComponent { } setRef = c => { - this.node = c.getWrappedInstance(); + if (c) { + this.node = c.getWrappedInstance(); + } } render () { diff --git a/app/javascript/mastodon/load_keyboard_extensions.js b/app/javascript/mastodon/load_keyboard_extensions.js new file mode 100644 index 000000000..2dd0e45fa --- /dev/null +++ b/app/javascript/mastodon/load_keyboard_extensions.js @@ -0,0 +1,16 @@ +// On KaiOS, we may not be able to use a mouse cursor or navigate using Tab-based focus, so we install +// special left/right focus navigation keyboard listeners, at least on public pages (i.e. so folks +// can at least log in using KaiOS devices). + +function importArrowKeyNavigation() { + return import(/* webpackChunkName: "arrow-key-navigation" */ 'arrow-key-navigation'); +} + +export default function loadKeyboardExtensions() { + if (/KAIOS/.test(navigator.userAgent)) { + return importArrowKeyNavigation().then(arrowKeyNav => { + arrowKeyNav.register(); + }); + } + return Promise.resolve(); +} diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index cf9a38757..a65481998 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -2079,20 +2079,24 @@ { "descriptors": [ { - "defaultMessage": "{name} followed you", - "id": "notification.follow" - }, - { "defaultMessage": "{name} favourited your status", "id": "notification.favourite" }, { - "defaultMessage": "{name} boosted your status", - "id": "notification.reblog" + "defaultMessage": "{name} followed you", + "id": "notification.follow" + }, + { + "defaultMessage": "Your poll has ended", + "id": "notification.own_poll" }, { "defaultMessage": "A poll you have voted in has ended", "id": "notification.poll" + }, + { + "defaultMessage": "{name} boosted your status", + "id": "notification.reblog" } ], "path": "app/javascript/mastodon/features/notifications/components/notification.json" @@ -2772,6 +2776,10 @@ "id": "video.exit_fullscreen" }, { + "defaultMessage": "Download file", + "id": "video.download" + }, + { "defaultMessage": "Sensitive content", "id": "status.sensitive_warning" }, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 4005a54c3..38f190791 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -279,6 +279,7 @@ "notification.favourite": "{name} favourited your status", "notification.follow": "{name} followed you", "notification.mention": "{name} mentioned you", + "notification.own_poll": "Your poll has ended", "notification.poll": "A poll you have voted in has ended", "notification.reblog": "{name} boosted your status", "notifications.clear": "Clear notifications", @@ -417,6 +418,7 @@ "upload_modal.preview_label": "Preview ({ratio})", "upload_progress.label": "Uploading...", "video.close": "Close video", + "video.download": "Download file", "video.exit_fullscreen": "Exit full screen", "video.expand": "Expand video", "video.fullscreen": "Full screen", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index b5dc81703..4c3342ccc 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -61,6 +61,7 @@ const initialState = ImmutableMap({ is_uploading: false, progress: 0, media_attachments: ImmutableList(), + pending_media_attachments: 0, poll: null, suggestion_token: null, suggestions: ImmutableList(), @@ -114,6 +115,7 @@ function appendMedia(state, media, file) { map.set('is_uploading', false); map.set('resetFileKey', Math.floor((Math.random() * 0x10000))); map.set('idempotencyKey', uuid()); + map.update('pending_media_attachments', n => n - 1); if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) { map.set('sensitive', true); @@ -322,11 +324,11 @@ export default function compose(state = initialState, action) { case COMPOSE_UPLOAD_CHANGE_FAIL: return state.set('is_changing_upload', false); case COMPOSE_UPLOAD_REQUEST: - return state.set('is_uploading', true); + return state.set('is_uploading', true).update('pending_media_attachments', n => n + 1); case COMPOSE_UPLOAD_SUCCESS: return appendMedia(state, fromJS(action.media), action.file); case COMPOSE_UPLOAD_FAIL: - return state.set('is_uploading', false); + return state.set('is_uploading', false).update('pending_media_attachments', n => action.decrement ? n - 1 : n); case COMPOSE_UPLOAD_UNDO: return removeMedia(state, action.media_id); case COMPOSE_UPLOAD_PROGRESS: diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 1229a7505..3eae1a457 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -1,6 +1,7 @@ import loadPolyfills from '../mastodon/load_polyfills'; import ready from '../mastodon/ready'; import { start } from '../mastodon/common'; +import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; start(); @@ -122,6 +123,9 @@ function main() { }); } -loadPolyfills().then(main).catch(error => { - console.error(error); -}); +loadPolyfills() + .then(main) + .then(loadKeyboardExtensions) + .catch(error => { + console.error(error); + }); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index a56806dac..732e42a45 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -392,6 +392,7 @@ .autosuggest-input, .spoiler-input { position: relative; + width: 100%; } .spoiler-input { diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 5cd48a6ae..cdd406043 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -158,7 +158,7 @@ class ActivityPub::Activity end def follow_from_object - @follow ||= Follow.find_by(target_account: @account, uri: object_uri) unless object_uri.nil? + @follow ||= ::Follow.find_by(target_account: @account, uri: object_uri) unless object_uri.nil? end def fetch_remote_original_status diff --git a/app/models/account.rb b/app/models/account.rb index db2eb8993..648378f7b 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -431,6 +431,8 @@ class Account < ApplicationRecord SELECT target_account_id FROM follows WHERE account_id = ? + UNION ALL + SELECT ? ) SELECT accounts.*, @@ -446,7 +448,7 @@ class Account < ApplicationRecord LIMIT ? OFFSET ? SQL - records = find_by_sql([sql, account.id, account.id, account.id, limit, offset]) + records = find_by_sql([sql, account.id, account.id, account.id, account.id, limit, offset]) else sql = <<-SQL.squish SELECT diff --git a/app/models/list_account.rb b/app/models/list_account.rb index 87b498224..785923c4c 100644 --- a/app/models/list_account.rb +++ b/app/models/list_account.rb @@ -6,13 +6,13 @@ # id :bigint(8) not null, primary key # list_id :bigint(8) not null # account_id :bigint(8) not null -# follow_id :bigint(8) not null +# follow_id :bigint(8) # class ListAccount < ApplicationRecord belongs_to :list belongs_to :account - belongs_to :follow + belongs_to :follow, optional: true validates :account_id, uniqueness: { scope: :list_id } @@ -21,6 +21,6 @@ class ListAccount < ApplicationRecord private def set_follow - self.follow = Follow.find_by(account_id: list.account_id, target_account_id: account.id) + self.follow = Follow.find_by!(account_id: list.account_id, target_account_id: account.id) unless list.account_id == account.id end end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index b5e65212e..9bba89e57 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -26,6 +26,8 @@ class MediaAttachment < ApplicationRecord enum type: [:image, :gifv, :video, :unknown, :audio] + MAX_DESCRIPTION_LENGTH = 1_500 + IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp .wma).freeze @@ -139,7 +141,7 @@ class MediaAttachment < ApplicationRecord include Attachmentable validates :account, presence: true - validates :description, length: { maximum: 1_500 }, if: :local? + validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }, if: :local? scope :attached, -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) } scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) } @@ -243,7 +245,7 @@ class MediaAttachment < ApplicationRecord end def prepare_description - self.description = description.strip[0...420] unless description.nil? + self.description = description.strip[0...MAX_DESCRIPTION_LENGTH] unless description.nil? end def set_type_and_extension diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb index b05673a3d..08614b67c 100644 --- a/app/presenters/account_relationships_presenter.rb +++ b/app/presenters/account_relationships_presenter.rb @@ -6,7 +6,7 @@ class AccountRelationshipsPresenter :endorsed def initialize(account_ids, current_account_id, **options) - @account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a } + @account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a.to_i } @current_account_id = current_account_id @following = cached[:following].merge(Account.following_map(@uncached_account_ids, @current_account_id)) diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb index 40c5f8590..7e74cc893 100644 --- a/app/services/account_search_service.rb +++ b/app/services/account_search_service.rb @@ -127,7 +127,7 @@ class AccountSearchService < BaseService end def following_ids - @following_ids ||= account.active_relationships.pluck(:target_account_id) + @following_ids ||= account.active_relationships.pluck(:target_account_id) + [account.id] end def limit_for_non_exact_results diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 1941c2e2d..dc47804c0 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -8,7 +8,7 @@ class FollowService < BaseService # @param [Account] source_account From which to follow # @param [String, Account] uri User URI to follow in the form of username@domain (or account record) # @param [true, false, nil] reblogs Whether or not to show reblogs, defaults to true - def call(source_account, target_account, reblogs: nil) + def call(source_account, target_account, reblogs: nil, bypass_locked: false) reblogs = true if reblogs.nil? target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true) @@ -30,7 +30,7 @@ class FollowService < BaseService ActivityTracker.increment('activity:interactions') - if target_account.locked? || source_account.silenced? || target_account.activitypub? + if (target_account.locked? && !bypass_locked) || source_account.silenced? || target_account.activitypub? request_follow(source_account, target_account, reblogs: reblogs) elsif target_account.local? direct_follow(source_account, target_account, reblogs: reblogs) diff --git a/app/views/public_timelines/show.html.haml b/app/views/public_timelines/show.html.haml index 591620f64..063089a7f 100644 --- a/app/views/public_timelines/show.html.haml +++ b/app/views/public_timelines/show.html.haml @@ -6,7 +6,11 @@ .page-header %h1= t('about.see_whats_happening') - %p= t('about.browse_public_posts') + + - if Setting.show_known_fediverse_at_about_page + %p= t('about.browse_public_posts') + - else + %p= t('about.browse_local_posts') #mastodon-timeline{ data: { props: Oj.dump(default_props) }} #modal-container diff --git a/app/workers/move_worker.rb b/app/workers/move_worker.rb index 83f7090ee..595730226 100644 --- a/app/workers/move_worker.rb +++ b/app/workers/move_worker.rb @@ -7,7 +7,7 @@ class MoveWorker @source_account = Account.find(source_account_id) @target_account = Account.find(target_account_id) - if @target_account.local? + if @target_account.local? && @source_account.local? rewrite_follows! else queue_follow_unfollows! @@ -21,13 +21,17 @@ class MoveWorker def rewrite_follows! @source_account.passive_relationships .where(account: Account.local) + .where.not(account: @target_account.followers.local) + .where.not(account_id: @target_account.id) .in_batches .update_all(target_account_id: @target_account.id) end def queue_follow_unfollows! + bypass_locked = @target_account.local? + @source_account.followers.local.select(:id).find_in_batches do |accounts| - UnfollowFollowWorker.push_bulk(accounts.map(&:id)) { |follower_id| [follower_id, @source_account.id, @target_account.id] } + UnfollowFollowWorker.push_bulk(accounts.map(&:id)) { |follower_id| [follower_id, @source_account.id, @target_account.id, bypass_locked] } end end end diff --git a/app/workers/unfollow_follow_worker.rb b/app/workers/unfollow_follow_worker.rb index 95549e107..b6e665a41 100644 --- a/app/workers/unfollow_follow_worker.rb +++ b/app/workers/unfollow_follow_worker.rb @@ -5,12 +5,15 @@ class UnfollowFollowWorker sidekiq_options queue: 'pull' - def perform(follower_account_id, old_target_account_id, new_target_account_id) + def perform(follower_account_id, old_target_account_id, new_target_account_id, bypass_locked = false) follower_account = Account.find(follower_account_id) old_target_account = Account.find(old_target_account_id) new_target_account = Account.find(new_target_account_id) - FollowService.new.call(follower_account, new_target_account) + follow = follower_account.active_relationships.find_by(target_account: old_target_account) + reblogs = follow&.show_reblogs? + + FollowService.new.call(follower_account, new_target_account, reblogs: reblogs, bypass_locked: bypass_locked) UnfollowService.new.call(follower_account, old_target_account, skip_unmerge: true) rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError true |