diff options
26 files changed, 257 insertions, 224 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 052394ab4..dfe94af7d 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -82,7 +82,7 @@ class AccountsController < ApplicationController end def account_media_status_ids - @account.media_attachments.attached.reorder(nil).select(:status_id).distinct + @account.media_attachments.attached.reorder(nil).select(:status_id).group(:status_id) end def no_replies_scope diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index 650195034..d7c192f0d 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -14,7 +14,7 @@ module Admin @statuses = @account.statuses.where(visibility: [:public, :unlisted]) if params[:media] - account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct + account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).group(:status_id) @statuses.merge!(Status.where(id: account_media_status_ids)) end diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js index c0e7a93af..a086def97 100644 --- a/app/javascript/flavours/glitch/actions/markers.js +++ b/app/javascript/flavours/glitch/actions/markers.js @@ -1,7 +1,6 @@ import api from 'flavours/glitch/util/api'; import { debounce } from 'lodash'; import compareId from 'flavours/glitch/util/compare_id'; -import { showAlertForError } from './alerts'; export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; @@ -29,15 +28,19 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => { }, body: JSON.stringify(params), }); + return; } else if (navigator && navigator.sendBeacon) { // Failing that, we can use sendBeacon, but we have to encode the data as // FormData for DoorKeeper to recognize the token. const formData = new FormData(); + formData.append('bearer_token', accessToken); + for (const [id, value] of Object.entries(params)) { formData.append(`${id}[last_read_id]`, value.last_read_id); } + if (navigator.sendBeacon('/api/v1/markers', formData)) { return; } @@ -85,11 +88,9 @@ const debouncedSubmitMarkers = debounce((dispatch, getState) => { return; } - api().post('/api/v1/markers', params).then(() => { + api(getState).post('/api/v1/markers', params).then(() => { dispatch(submitMarkersSuccess(params)); - }).catch(error => { - dispatch(showAlertForError(error)); - }); + }).catch(() => {}); }, 300000, { leading: true, trailing: true }); export function submitMarkersSuccess({ home, notifications }) { @@ -102,9 +103,11 @@ export function submitMarkersSuccess({ home, notifications }) { export function submitMarkers(params = {}) { const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); + if (params.immediate === true) { debouncedSubmitMarkers.flush(); } + return result; }; diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.js b/app/javascript/flavours/glitch/features/list_timeline/index.js index 70e530bae..d826c8ccd 100644 --- a/app/javascript/flavours/glitch/features/list_timeline/index.js +++ b/app/javascript/flavours/glitch/features/list_timeline/index.js @@ -19,9 +19,9 @@ import RadioButton from 'flavours/glitch/components/radio_button'; const messages = defineMessages({ deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' }, - all_replies: { id: 'lists.replies_policy.all_replies', defaultMessage: 'Any followed user' }, - no_replies: { id: 'lists.replies_policy.no_replies', defaultMessage: 'No one' }, - list_replies: { id: 'lists.replies_policy.list_replies', defaultMessage: 'Members of the list' }, + followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' }, + none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' }, + list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' }, }); const mapStateToProps = (state, props) => ({ @@ -193,7 +193,7 @@ class ListTimeline extends React.PureComponent { <FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /> </span> <div className='column-settings__row'> - { ['no_replies', 'list_replies', 'all_replies'].map(policy => ( + { ['none', 'list', 'followed'].map(policy => ( <RadioButton name='order' value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} /> ))} </div> diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js index 729ade212..640be19ab 100644 --- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js @@ -75,7 +75,9 @@ class ColumnsArea extends ImmutablePureComponent { } componentWillReceiveProps() { - this.setState({ shouldAnimate: false }); + if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getIndex(this.context.router.history.location.pathname)) { + this.setState({ shouldAnimate: false }); + } } componentDidMount() { @@ -99,8 +101,13 @@ class ColumnsArea extends ImmutablePureComponent { if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); } - this.lastIndex = getIndex(this.context.router.history.location.pathname); - this.setState({ shouldAnimate: true }); + + const newIndex = getIndex(this.context.router.history.location.pathname); + + if (this.lastIndex !== newIndex) { + this.lastIndex = newIndex; + this.setState({ shouldAnimate: true }); + } } componentWillUnmount () { diff --git a/app/javascript/mastodon/actions/markers.js b/app/javascript/mastodon/actions/markers.js index c4b61effd..16a3df8f6 100644 --- a/app/javascript/mastodon/actions/markers.js +++ b/app/javascript/mastodon/actions/markers.js @@ -1,7 +1,6 @@ import api from '../api'; import { debounce } from 'lodash'; import compareId from '../compare_id'; -import { showAlertForError } from './alerts'; export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; @@ -29,15 +28,19 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => { }, body: JSON.stringify(params), }); + return; } else if (navigator && navigator.sendBeacon) { // Failing that, we can use sendBeacon, but we have to encode the data as // FormData for DoorKeeper to recognize the token. const formData = new FormData(); + formData.append('bearer_token', accessToken); + for (const [id, value] of Object.entries(params)) { formData.append(`${id}[last_read_id]`, value.last_read_id); } + if (navigator.sendBeacon('/api/v1/markers', formData)) { return; } @@ -85,11 +88,9 @@ const debouncedSubmitMarkers = debounce((dispatch, getState) => { return; } - api().post('/api/v1/markers', params).then(() => { + api(getState).post('/api/v1/markers', params).then(() => { dispatch(submitMarkersSuccess(params)); - }).catch(error => { - dispatch(showAlertForError(error)); - }); + }).catch(() => {}); }, 300000, { leading: true, trailing: true }); export function submitMarkersSuccess({ home, notifications }) { @@ -102,9 +103,11 @@ export function submitMarkersSuccess({ home, notifications }) { export function submitMarkers(params = {}) { const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); + if (params.immediate === true) { debouncedSubmitMarkers.flush(); } + return result; }; diff --git a/app/javascript/mastodon/blurhash.js b/app/javascript/mastodon/blurhash.js new file mode 100644 index 000000000..5adcc3e77 --- /dev/null +++ b/app/javascript/mastodon/blurhash.js @@ -0,0 +1,112 @@ +const DIGIT_CHARACTERS = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + '#', + '$', + '%', + '*', + '+', + ',', + '-', + '.', + ':', + ';', + '=', + '?', + '@', + '[', + ']', + '^', + '_', + '{', + '|', + '}', + '~', +]; + +export const decode83 = (str) => { + let value = 0; + let c, digit; + + for (let i = 0; i < str.length; i++) { + c = str[i]; + digit = DIGIT_CHARACTERS.indexOf(c); + value = value * 83 + digit; + } + + return value; +}; + +export const intToRGB = int => ({ + r: Math.max(0, (int >> 16)), + g: Math.max(0, (int >> 8) & 255), + b: Math.max(0, (int & 255)), +}); + +export const getAverageFromBlurhash = blurhash => { + if (!blurhash) { + return null; + } + + return intToRGB(decode83(blurhash.slice(2, 6))); +}; diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js index 9bfc0e49f..26344528e 100644 --- a/app/javascript/mastodon/components/modal_root.js +++ b/app/javascript/mastodon/components/modal_root.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import 'wicg-inert'; -import { normal } from 'color-blend'; +import { multiply } from 'color-blend'; export default class ModalRoot extends React.PureComponent { @@ -98,7 +98,7 @@ export default class ModalRoot extends React.PureComponent { let backgroundColor = null; if (this.props.backgroundColor) { - backgroundColor = normal({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.3 }); + backgroundColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 }); } return ( diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 27d96e588..295e83f58 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -97,7 +97,7 @@ class Status extends ImmutablePureComponent { cachedMediaWidth: PropTypes.number, scrollKey: PropTypes.string, deployPictureInPicture: PropTypes.func, - pictureInPicture: PropTypes.shape({ + pictureInPicture: ImmutablePropTypes.contains({ inUse: PropTypes.bool, available: PropTypes.bool, }), @@ -192,8 +192,9 @@ class Status extends ImmutablePureComponent { return <div className='audio-player' style={{ height: '110px' }} />; } - handleOpenVideo = (media, options) => { - this.props.onOpenVideo(this._properStatus().get('id'), media, options); + handleOpenVideo = (options) => { + const status = this._properStatus(); + this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options); } handleOpenMedia = (media, index) => { @@ -202,15 +203,15 @@ class Status extends ImmutablePureComponent { handleHotkeyOpenMedia = e => { const { onOpenMedia, onOpenVideo } = this.props; - const statusId = this._properStatus().get('id'); + const status = this._properStatus(); e.preventDefault(); if (status.get('media_attachments').size > 0) { if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - onOpenVideo(statusId, status.getIn(['media_attachments', 0]), { startTime: 0 }); + onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), { startTime: 0 }); } else { - onOpenMedia(statusId, status.get('media_attachments'), 0); + onOpenMedia(status.get('id'), status.get('media_attachments'), 0); } } } @@ -353,7 +354,7 @@ class Status extends ImmutablePureComponent { status = status.get('reblog'); } - if (pictureInPicture.inUse) { + if (pictureInPicture.get('inUse')) { media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />; } else if (status.get('media_attachments').size > 0) { if (this.props.muted) { @@ -380,7 +381,7 @@ class Status extends ImmutablePureComponent { width={this.props.cachedMediaWidth} height={110} cacheWidth={this.props.cacheMediaWidth} - deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined} + deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined} /> )} </Bundle> @@ -403,7 +404,7 @@ class Status extends ImmutablePureComponent { sensitive={status.get('sensitive')} onOpenVideo={this.handleOpenVideo} cacheWidth={this.props.cacheMediaWidth} - deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined} + deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined} visible={this.state.showMedia} onToggleVisibility={this.handleToggleMediaVisibility} /> @@ -431,7 +432,7 @@ class Status extends ImmutablePureComponent { } else if (status.get('spoiler_text').length === 0 && status.get('card')) { media = ( <Card - onOpenMedia={this.props.onOpenMedia} + onOpenMedia={this.handleOpenMedia} card={status.get('card')} compact cacheWidth={this.props.cacheMediaWidth} diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index ef520b96a..d6bcb8973 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import Status from '../components/status'; -import { makeGetStatus } from '../selectors'; +import { makeGetStatus, makeGetPictureInPicture } from '../selectors'; import { replyCompose, mentionCompose, @@ -54,14 +54,11 @@ const messages = defineMessages({ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); + const getPictureInPicture = makeGetPictureInPicture(); const mapStateToProps = (state, props) => ({ status: getStatus(state, props), - - pictureInPicture: { - inUse: state.getIn(['meta', 'layout']) !== 'mobile' && state.get('picture_in_picture').statusId === props.id, - available: state.getIn(['meta', 'layout']) !== 'mobile', - }, + pictureInPicture: getPictureInPicture(state, props), }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js index a3be8fbea..02b018247 100644 --- a/app/javascript/mastodon/features/list_timeline/index.js +++ b/app/javascript/mastodon/features/list_timeline/index.js @@ -20,9 +20,9 @@ import RadioButton from 'mastodon/components/radio_button'; const messages = defineMessages({ deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' }, - all_replies: { id: 'lists.replies_policy.all_replies', defaultMessage: 'Any followed user' }, - no_replies: { id: 'lists.replies_policy.no_replies', defaultMessage: 'No one' }, - list_replies: { id: 'lists.replies_policy.list_replies', defaultMessage: 'Members of the list' }, + followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' }, + none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' }, + list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' }, }); const mapStateToProps = (state, props) => ({ @@ -193,7 +193,7 @@ class ListTimeline extends React.PureComponent { <FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /> </span> <div className='column-settings__row'> - { ['no_replies', 'list_replies', 'all_replies'].map(policy => ( + { ['none', 'list', 'followed'].map(policy => ( <RadioButton name='order' value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} /> ))} </div> diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index cd29b5489..043a749ed 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -41,7 +41,10 @@ class DetailedStatus extends ImmutablePureComponent { domain: PropTypes.string.isRequired, compact: PropTypes.bool, showMedia: PropTypes.bool, - usingPiP: PropTypes.bool, + pictureInPicture: ImmutablePropTypes.contains({ + inUse: PropTypes.bool, + available: PropTypes.bool, + }), onToggleMediaVisibility: PropTypes.func, }; @@ -58,8 +61,8 @@ class DetailedStatus extends ImmutablePureComponent { e.stopPropagation(); } - handleOpenVideo = (media, options) => { - this.props.onOpenVideo(media, options); + handleOpenVideo = (options) => { + this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), options); } handleExpandedToggle = () => { @@ -102,7 +105,7 @@ class DetailedStatus extends ImmutablePureComponent { render () { const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; const outerStyle = { boxSizing: 'border-box' }; - const { intl, compact, usingPiP } = this.props; + const { intl, compact, pictureInPicture } = this.props; if (!status) { return null; @@ -118,7 +121,7 @@ class DetailedStatus extends ImmutablePureComponent { outerStyle.height = `${this.state.height}px`; } - if (usingPiP) { + if (pictureInPicture.get('inUse')) { media = <PictureInPicturePlaceholder />; } else if (status.get('media_attachments').size > 0) { if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js index 6d5c33240..0ac4519c8 100644 --- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js +++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import DetailedStatus from '../components/detailed_status'; -import { makeGetStatus } from '../../../selectors'; +import { makeGetStatus, makeGetPictureInPicture } from '../../../selectors'; import { replyCompose, mentionCompose, @@ -40,10 +40,12 @@ const messages = defineMessages({ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); + const getPictureInPicture = makeGetPictureInPicture(); const mapStateToProps = (state, props) => ({ status: getStatus(state, props), domain: state.getIn(['meta', 'domain']), + pictureInPicture: getPictureInPicture(state, props), }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index c5e7ba776..09822f372 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -43,7 +43,7 @@ import { import { initMuteModal } from '../../actions/mutes'; import { initBlockModal } from '../../actions/blocks'; import { initReport } from '../../actions/reports'; -import { makeGetStatus } from '../../selectors'; +import { makeGetStatus, makeGetPictureInPicture } from '../../selectors'; import { ScrollContainer } from 'react-router-scroll-4'; import ColumnBackButton from '../../components/column_back_button'; import ColumnHeader from '../../components/column_header'; @@ -72,6 +72,7 @@ const messages = defineMessages({ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); + const getPictureInPicture = makeGetPictureInPicture(); const getAncestorsIds = createSelector([ (_, { id }) => id, @@ -129,11 +130,12 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => { const status = getStatus(state, { id: props.params.statusId }); - let ancestorsIds = Immutable.List(); + + let ancestorsIds = Immutable.List(); let descendantsIds = Immutable.List(); if (status) { - ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') }); + ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') }); descendantsIds = getDescendantsIds(state, { id: status.get('id') }); } @@ -143,7 +145,7 @@ const makeMapStateToProps = () => { descendantsIds, askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, domain: state.getIn(['meta', 'domain']), - usingPiP: state.get('picture_in_picture').statusId === props.params.statusId, + pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }), }; }; @@ -168,7 +170,10 @@ class Status extends ImmutablePureComponent { askReplyConfirmation: PropTypes.bool, multiColumn: PropTypes.bool, domain: PropTypes.string.isRequired, - usingPiP: PropTypes.bool, + pictureInPicture: ImmutablePropTypes.contains({ + inUse: PropTypes.bool, + available: PropTypes.bool, + }), }; state = { @@ -492,7 +497,7 @@ class Status extends ImmutablePureComponent { render () { let ancestors, descendants; - const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props; + const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props; const { fullscreen } = this.state; if (status === null) { @@ -550,7 +555,7 @@ class Status extends ImmutablePureComponent { domain={domain} showMedia={this.state.showMedia} onToggleMediaVisibility={this.handleToggleMediaVisibility} - usingPiP={usingPiP} + pictureInPicture={pictureInPicture} /> <ActionBar diff --git a/app/javascript/mastodon/features/ui/components/audio_modal.js b/app/javascript/mastodon/features/ui/components/audio_modal.js index a80776b22..0676bd9cf 100644 --- a/app/javascript/mastodon/features/ui/components/audio_modal.js +++ b/app/javascript/mastodon/features/ui/components/audio_modal.js @@ -4,13 +4,11 @@ import PropTypes from 'prop-types'; import Audio from 'mastodon/features/audio'; import { connect } from 'react-redux'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; import { previewState } from './video_modal'; -import classNames from 'classnames'; -import Icon from 'mastodon/components/icon'; +import Footer from 'mastodon/features/picture_in_picture/components/footer'; -const mapStateToProps = (state, { status }) => ({ - account: state.getIn(['accounts', status.get('account')]), +const mapStateToProps = (state, { statusId }) => ({ + accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']), }); export default @connect(mapStateToProps) @@ -18,12 +16,13 @@ class AudioModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, - status: ImmutablePropTypes.map, + statusId: PropTypes.string.isRequired, + accountStaticAvatar: PropTypes.string.isRequired, options: PropTypes.shape({ autoPlay: PropTypes.bool, }), - account: ImmutablePropTypes.map, onClose: PropTypes.func.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, }; static contextTypes = { @@ -52,15 +51,8 @@ class AudioModal extends ImmutablePureComponent { } } - handleStatusClick = e => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); - } - } - render () { - const { media, status, account } = this.props; + const { media, accountStaticAvatar, statusId, onClose } = this.props; const options = this.props.options || {}; return ( @@ -71,7 +63,7 @@ class AudioModal extends ImmutablePureComponent { alt={media.get('description')} duration={media.getIn(['meta', 'original', 'duration'], 0)} height={150} - poster={media.get('preview_url') || account.get('avatar_static')} + poster={media.get('preview_url') || accountStaticAvatar} backgroundColor={media.getIn(['meta', 'colors', 'background'])} foregroundColor={media.getIn(['meta', 'colors', 'foreground'])} accentColor={media.getIn(['meta', 'colors', 'accent'])} @@ -79,11 +71,9 @@ class AudioModal extends ImmutablePureComponent { /> </div> - {status && ( - <div className={classNames('media-modal__meta')}> - <a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a> - </div> - )} + <div className='media-modal__overlay'> + {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + </div> </div> ); } diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 36a84fcbf..6837450eb 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -75,7 +75,9 @@ class ColumnsArea extends ImmutablePureComponent { } componentWillReceiveProps() { - this.setState({ shouldAnimate: false }); + if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getIndex(this.context.router.history.location.pathname)) { + this.setState({ shouldAnimate: false }); + } } componentDidMount() { @@ -99,8 +101,13 @@ class ColumnsArea extends ImmutablePureComponent { if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); } - this.lastIndex = getIndex(this.context.router.history.location.pathname); - this.setState({ shouldAnimate: true }); + + const newIndex = getIndex(this.context.router.history.location.pathname); + + if (this.lastIndex !== newIndex) { + this.lastIndex = newIndex; + this.setState({ shouldAnimate: true }); + } } componentWillUnmount () { diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index 58cef1e9d..7fe7ed094 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -12,6 +12,7 @@ import Icon from 'mastodon/components/icon'; import GIFV from 'mastodon/components/gifv'; import { disableSwiping } from 'mastodon/initial_state'; import Footer from 'mastodon/features/picture_in_picture/components/footer'; +import { getAverageFromBlurhash } from 'mastodon/blurhash'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -21,111 +22,6 @@ const messages = defineMessages({ export const previewState = 'previewMediaModal'; -const digitCharacters = [ - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - 'A', - 'B', - 'C', - 'D', - 'E', - 'F', - 'G', - 'H', - 'I', - 'J', - 'K', - 'L', - 'M', - 'N', - 'O', - 'P', - 'Q', - 'R', - 'S', - 'T', - 'U', - 'V', - 'W', - 'X', - 'Y', - 'Z', - 'a', - 'b', - 'c', - 'd', - 'e', - 'f', - 'g', - 'h', - 'i', - 'j', - 'k', - 'l', - 'm', - 'n', - 'o', - 'p', - 'q', - 'r', - 's', - 't', - 'u', - 'v', - 'w', - 'x', - 'y', - 'z', - '#', - '$', - '%', - '*', - '+', - ',', - '-', - '.', - ':', - ';', - '=', - '?', - '@', - '[', - ']', - '^', - '_', - '{', - '|', - '}', - '~', -]; - -const decode83 = (str) => { - let value = 0; - let c, digit; - - for (let i = 0; i < str.length; i++) { - c = str[i]; - digit = digitCharacters.indexOf(c); - value = value * 83 + digit; - } - - return value; -}; - -const decodeRGB = int => ({ - r: Math.max(0, (int >> 16)), - g: Math.max(0, (int >> 8) & 255), - b: Math.max(0, (int & 255)), -}); - export default @injectIntl class MediaModal extends ImmutablePureComponent { @@ -224,7 +120,7 @@ class MediaModal extends ImmutablePureComponent { const blurhash = media.getIn([index, 'blurhash']); if (blurhash) { - const backgroundColor = decodeRGB(decode83(blurhash.slice(2, 6))); + const backgroundColor = getAverageFromBlurhash(blurhash); onChangeBackgroundColor(backgroundColor); } } diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js index 2c3c026c8..2f13a175a 100644 --- a/app/javascript/mastodon/features/ui/components/video_modal.js +++ b/app/javascript/mastodon/features/ui/components/video_modal.js @@ -3,6 +3,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import Video from 'mastodon/features/video'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import Footer from 'mastodon/features/picture_in_picture/components/footer'; +import { getAverageFromBlurhash } from 'mastodon/blurhash'; export const previewState = 'previewVideoModal'; @@ -17,6 +19,7 @@ export default class VideoModal extends ImmutablePureComponent { defaultVolume: PropTypes.number, }), onClose: PropTypes.func.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, }; static contextTypes = { @@ -24,29 +27,35 @@ export default class VideoModal extends ImmutablePureComponent { }; componentDidMount () { - if (this.context.router) { - const history = this.context.router.history; + const { router } = this.context; + const { media, onChangeBackgroundColor, onClose } = this.props; - history.push(history.location.pathname, previewState); + if (router) { + router.history.push(router.history.location.pathname, previewState); + this.unlistenHistory = router.history.listen(() => onClose()); + } + + const backgroundColor = getAverageFromBlurhash(media.get('blurhash')); - this.unlistenHistory = history.listen(() => { - this.props.onClose(); - }); + if (backgroundColor) { + onChangeBackgroundColor(backgroundColor); } } componentWillUnmount () { - if (this.context.router) { + const { router } = this.context; + + if (router) { this.unlistenHistory(); - if (this.context.router.history.location.state === previewState) { - this.context.router.history.goBack(); + if (router.history.location.state === previewState) { + router.history.goBack(); } } } render () { - const { media, onClose } = this.props; + const { media, statusId, onClose } = this.props; const options = this.props.options || {}; return ( @@ -65,6 +74,10 @@ export default class VideoModal extends ImmutablePureComponent { alt={media.get('description')} /> </div> + + <div className='media-modal__overlay'> + {statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} + </div> </div> ); } diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index 46eaebd9b..a2dccdfc0 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { fromJS, is } from 'immutable'; +import { is } from 'immutable'; import { throttle, debounce } from 'lodash'; import classNames from 'classnames'; import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen'; @@ -495,25 +495,13 @@ class Video extends React.PureComponent { } handleOpenVideo = () => { - const { src, preview, width, height, alt } = this.props; - - const media = fromJS({ - type: 'video', - url: src, - preview_url: preview, - description: alt, - width, - height, - }); + this.video.pause(); - const options = { + this.props.onOpenVideo({ startTime: this.video.currentTime, autoPlay: !this.state.paused, defaultVolume: this.state.volume, - }; - - this.video.pause(); - this.props.onOpenVideo(media, options); + }); } handleCloseVideo = () => { diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index fd3b72f96..1e19db65d 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -1,5 +1,5 @@ import { createSelector } from 'reselect'; -import { List as ImmutableList, is } from 'immutable'; +import { List as ImmutableList, Map as ImmutableMap, is } from 'immutable'; import { me } from '../initial_state'; const getAccountBase = (state, id) => state.getIn(['accounts', id], null); @@ -121,6 +121,16 @@ export const makeGetStatus = () => { ); }; +export const makeGetPictureInPicture = () => { + return createSelector([ + (state, { id }) => state.get('picture_in_picture').statusId === id, + (state) => state.getIn(['meta', 'layout']) !== 'mobile', + ], (inUse, available) => ImmutableMap({ + inUse: inUse && available, + available, + })); +}; + const getAlertsBase = state => state.get('alerts'); export const getAlerts = createSelector([getAlertsBase], (base) => { diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 3c1f8d6e2..ebd25b398 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -403,8 +403,8 @@ class FeedManager def filter_from_list?(status, list) if status.reply? && status.in_reply_to_account_id != status.account_id should_filter = status.in_reply_to_account_id != list.account_id - should_filter &&= !list.show_all_replies? - should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?) + should_filter &&= !list.show_followed? + should_filter &&= !(list.show_list? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?) return !!should_filter end diff --git a/app/models/account.rb b/app/models/account.rb index 87b89df51..b70978d2b 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -445,7 +445,7 @@ class Account < ApplicationRecord end def inboxes - urls = reorder(nil).where(protocol: :activitypub).pluck(Arel.sql("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)")) + urls = reorder(nil).where(protocol: :activitypub).group(:preferred_inbox_url).pluck(Arel.sql("coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url) AS preferred_inbox_url")) DeliveryFailureTracker.without_unavailable(urls) end diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb index 882770d7c..26d6d3abf 100644 --- a/app/models/form/account_batch.rb +++ b/app/models/form/account_batch.rb @@ -51,7 +51,7 @@ class Form::AccountBatch end def account_domains - accounts.pluck(Arel.sql('distinct domain')).compact + accounts.group(:domain).pluck(:domain).compact end def accounts diff --git a/app/models/list.rb b/app/models/list.rb index 8493046e5..655d55ff6 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -8,7 +8,7 @@ # title :string default(""), not null # created_at :datetime not null # updated_at :datetime not null -# replies_policy :integer default("list_replies"), not null +# replies_policy :integer default("list"), not null # class List < ApplicationRecord @@ -16,7 +16,7 @@ class List < ApplicationRecord PER_ACCOUNT_LIMIT = 50 - enum replies_policy: [:list_replies, :all_replies, :no_replies], _prefix: :show + enum replies_policy: [:list, :followed, :none], _prefix: :show belongs_to :account, optional: true diff --git a/app/serializers/rest/account_featured_tag_serializer.rb b/app/serializers/rest/account_featured_tag_serializer.rb index d8d5fd68c..84bef2e62 100644 --- a/app/serializers/rest/account_featured_tag_serializer.rb +++ b/app/serializers/rest/account_featured_tag_serializer.rb @@ -9,10 +9,6 @@ class REST::AccountFeaturedTagSerializer < ActiveModel::Serializer object.tag.id.to_s end - def name - "##{object.name}" - end - def url short_account_tag_url(object.account, object.tag) end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 78563ee94..2217ad272 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -342,7 +342,7 @@ RSpec.describe FeedManager do context 'when replies policy is set to no replies' do before do - list.replies_policy = :no_replies + list.replies_policy = :none end it 'pushes statuses that are not replies' do @@ -365,7 +365,7 @@ RSpec.describe FeedManager do context 'when replies policy is set to list-only replies' do before do - list.replies_policy = :list_replies + list.replies_policy = :list end it 'pushes statuses that are not replies' do @@ -394,7 +394,7 @@ RSpec.describe FeedManager do context 'when replies policy is set to any reply' do before do - list.replies_policy = :all_replies + list.replies_policy = :followed end it 'pushes statuses that are not replies' do |