From 04a079e7230bc4e73b897e3c4462011cddf36474 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 15 Nov 2020 14:24:54 +0100 Subject: Add hotkeys for audio/video control (#15158) Fix #14515 --- app/javascript/mastodon/features/audio/index.js | 51 +++++++++++++++- app/javascript/mastodon/features/video/index.js | 78 +++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) (limited to 'app') diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js index 434148e8e..c47f55dd1 100644 --- a/app/javascript/mastodon/features/audio/index.js +++ b/app/javascript/mastodon/features/audio/index.js @@ -386,13 +386,59 @@ class Audio extends React.PureComponent { return this.props.foregroundColor || '#ffffff'; } + seekBy (time) { + const currentTime = this.audio.currentTime + time; + + if (!isNaN(currentTime)) { + this.setState({ currentTime }, () => { + this.audio.currentTime = currentTime; + }); + } + } + + handleAudioKeyDown = e => { + // On the audio element or the seek bar, we can safely use the space bar + // for playback control because there are no buttons to press + + if (e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + } + } + + handleKeyDown = e => { + switch(e.key) { + case 'k': + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + break; + case 'm': + e.preventDefault(); + e.stopPropagation(); + this.toggleMute(); + break; + case 'j': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-10); + break; + case 'l': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(10); + break; + } + } + render () { const { src, intl, alt, editable, autoPlay } = this.props; const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; const progress = Math.min((currentTime / duration) * 100, 100); return ( -
+
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index 229a92140..e6c6f4b67 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -266,6 +266,81 @@ class Video extends React.PureComponent { } }, 15); + seekBy (time) { + const currentTime = this.video.currentTime + time; + + if (!isNaN(currentTime)) { + this.setState({ currentTime }, () => { + this.video.currentTime = currentTime; + }); + } + } + + handleVideoKeyDown = e => { + // On the video element or the seek bar, we can safely use the space bar + // for playback control because there are no buttons to press + + if (e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + } + } + + handleKeyDown = e => { + const frameTime = 1 / 25; + + switch(e.key) { + case 'k': + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + break; + case 'm': + e.preventDefault(); + e.stopPropagation(); + this.toggleMute(); + break; + case 'f': + e.preventDefault(); + e.stopPropagation(); + this.toggleFullscreen(); + break; + case 'j': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-10); + break; + case 'l': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(10); + break; + case ',': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-frameTime); + break; + case '.': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(frameTime); + break; + } + + // If we are in fullscreen mode, we don't want any hotkeys + // interacting with the UI that's not visible + + if (this.state.fullscreen) { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === 'Escape') { + exitFullscreen(); + } + } + } + togglePlay = () => { if (this.state.paused) { this.setState({ paused: false }, () => this.video.play()); @@ -484,6 +559,7 @@ class Video extends React.PureComponent { onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClickRoot} + onKeyDown={this.handleKeyDown} tabIndex={0} >
-- cgit From 18ca4e0e9a3f74a6f21d329882b429f8f5227b0f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 16 Nov 2020 05:16:39 +0100 Subject: Fix pop-out player appearing on mobile screens in web UI (#15157) Fix #15092 --- app/javascript/mastodon/actions/app.js | 7 ++ app/javascript/mastodon/components/status.js | 15 ++-- .../mastodon/containers/status_container.js | 6 +- app/javascript/mastodon/features/ui/index.js | 91 +++++++++------------- app/javascript/mastodon/is_mobile.js | 25 +++--- app/javascript/mastodon/reducers/meta.js | 7 +- 6 files changed, 79 insertions(+), 72 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/actions/app.js b/app/javascript/mastodon/actions/app.js index 414968f7d..c817c8708 100644 --- a/app/javascript/mastodon/actions/app.js +++ b/app/javascript/mastodon/actions/app.js @@ -8,3 +8,10 @@ export const focusApp = () => ({ export const unfocusApp = () => ({ type: APP_UNFOCUS, }); + +export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE'; + +export const changeLayout = layout => ({ + type: APP_LAYOUT_CHANGE, + layout, +}); diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index be4f0bcca..f4ed25f1e 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -97,7 +97,10 @@ class Status extends ImmutablePureComponent { cachedMediaWidth: PropTypes.number, scrollKey: PropTypes.string, deployPictureInPicture: PropTypes.func, - usingPiP: PropTypes.bool, + pictureInPicture: PropTypes.shape({ + inUse: PropTypes.bool, + available: PropTypes.bool, + }), }; // Avoid checking props that are functions (and whose equality will always @@ -108,7 +111,7 @@ class Status extends ImmutablePureComponent { 'muted', 'hidden', 'unread', - 'usingPiP', + 'pictureInPicture', ]; state = { @@ -277,7 +280,7 @@ class Status extends ImmutablePureComponent { let media = null; let statusAvatar, prepend, rebloggedByText; - const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, usingPiP } = this.props; + const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture } = this.props; let { status, account, ...other } = this.props; @@ -348,7 +351,7 @@ class Status extends ImmutablePureComponent { status = status.get('reblog'); } - if (usingPiP) { + if (pictureInPicture.inUse) { media = ; } else if (status.get('media_attachments').size > 0) { if (this.props.muted) { @@ -375,7 +378,7 @@ class Status extends ImmutablePureComponent { width={this.props.cachedMediaWidth} height={110} cacheWidth={this.props.cacheMediaWidth} - deployPictureInPicture={this.handleDeployPictureInPicture} + deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined} /> )} @@ -397,7 +400,7 @@ class Status extends ImmutablePureComponent { sensitive={status.get('sensitive')} onOpenVideo={this.handleOpenVideo} cacheWidth={this.props.cacheMediaWidth} - deployPictureInPicture={this.handleDeployPictureInPicture} + deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined} visible={this.state.showMedia} onToggleVisibility={this.handleToggleMediaVisibility} /> diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 7bfd66d3e..fe81981a7 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -57,7 +57,11 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ status: getStatus(state, props), - usingPiP: state.get('picture_in_picture').statusId === props.id, + + pictureInPicture: { + inUse: state.getIn(['meta', 'layout']) !== 'mobile' && state.get('picture_in_picture').statusId === props.id, + available: state.getIn(['meta', 'layout']) !== 'mobile', + }, }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index c6df49a5f..507ac1df1 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -8,14 +8,14 @@ import PropTypes from 'prop-types'; import NotificationsContainer from './containers/notifications_container'; import LoadingBarContainer from './containers/loading_bar_container'; import ModalContainer from './containers/modal_container'; -import { isMobile } from '../../is_mobile'; +import { layoutFromWindow } from 'mastodon/is_mobile'; import { debounce } from 'lodash'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { expandHomeTimeline } from '../../actions/timelines'; import { expandNotifications } from '../../actions/notifications'; import { fetchFilters } from '../../actions/filters'; import { clearHeight } from '../../actions/height_cache'; -import { focusApp, unfocusApp } from 'mastodon/actions/app'; +import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import UploadArea from './components/upload_area'; @@ -52,7 +52,7 @@ import { Search, Directory, } from './util/async-components'; -import { me, forceSingleColumn } from '../../initial_state'; +import { me } from '../../initial_state'; import { previewState as previewMediaState } from './components/media_modal'; import { previewState as previewVideoState } from './components/video_modal'; @@ -65,6 +65,7 @@ const messages = defineMessages({ }); const mapStateToProps = state => ({ + layout: state.getIn(['meta', 'layout']), isComposing: state.getIn(['compose', 'is_composing']), hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0, hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0, @@ -110,17 +111,11 @@ class SwitchingColumnsArea extends React.PureComponent { static propTypes = { children: PropTypes.node, location: PropTypes.object, - onLayoutChange: PropTypes.func.isRequired, - }; - - state = { - mobile: isMobile(window.innerWidth), + mobile: PropTypes.bool, }; componentWillMount () { - window.addEventListener('resize', this.handleResize, { passive: true }); - - if (this.state.mobile || forceSingleColumn) { + if (this.props.mobile) { document.body.classList.toggle('layout-single-column', true); document.body.classList.toggle('layout-multiple-columns', false); } else { @@ -129,44 +124,21 @@ class SwitchingColumnsArea extends React.PureComponent { } } - componentDidUpdate (prevProps, prevState) { + componentDidUpdate (prevProps) { if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { this.node.handleChildrenContentChange(); } - if (prevState.mobile !== this.state.mobile && !forceSingleColumn) { - document.body.classList.toggle('layout-single-column', this.state.mobile); - document.body.classList.toggle('layout-multiple-columns', !this.state.mobile); + if (prevProps.mobile !== this.props.mobile) { + document.body.classList.toggle('layout-single-column', this.props.mobile); + document.body.classList.toggle('layout-multiple-columns', !this.props.mobile); } } - componentWillUnmount () { - window.removeEventListener('resize', this.handleResize); - } - shouldUpdateScroll (_, { location }) { return location.state !== previewMediaState && location.state !== previewVideoState; } - handleLayoutChange = debounce(() => { - // The cached heights are no longer accurate, invalidate - this.props.onLayoutChange(); - }, 500, { - trailing: true, - }) - - handleResize = () => { - const mobile = isMobile(window.innerWidth); - - if (mobile !== this.state.mobile) { - this.handleLayoutChange.cancel(); - this.props.onLayoutChange(); - this.setState({ mobile }); - } else { - this.handleLayoutChange(); - } - } - setRef = c => { if (c) { this.node = c.getWrappedInstance(); @@ -174,13 +146,11 @@ class SwitchingColumnsArea extends React.PureComponent { } render () { - const { children } = this.props; - const { mobile } = this.state; - const singleColumn = forceSingleColumn || mobile; - const redirect = singleColumn ? : ; + const { children, mobile } = this.props; + const redirect = mobile ? : ; return ( - + {redirect} @@ -244,6 +214,7 @@ class UI extends React.PureComponent { location: PropTypes.object, intl: PropTypes.object.isRequired, dropdownMenuIsOpen: PropTypes.bool, + layout: PropTypes.string.isRequired, }; state = { @@ -273,11 +244,6 @@ class UI extends React.PureComponent { this.props.dispatch(unfocusApp()); } - handleLayoutChange = () => { - // The cached heights are no longer accurate, invalidate - this.props.dispatch(clearHeight()); - } - handleDragEnter = (e) => { e.preventDefault(); @@ -351,10 +317,28 @@ class UI extends React.PureComponent { } } - componentWillMount () { + handleLayoutChange = debounce(() => { + this.props.dispatch(clearHeight()); // The cached heights are no longer accurate, invalidate + }, 500, { + trailing: true, + }); + + handleResize = () => { + const layout = layoutFromWindow(); + + if (layout !== this.props.layout) { + this.handleLayoutChange.cancel(); + this.props.dispatch(changeLayout(layout)); + } else { + this.handleLayoutChange(); + } + } + + componentDidMount () { window.addEventListener('focus', this.handleWindowFocus, false); window.addEventListener('blur', this.handleWindowBlur, false); window.addEventListener('beforeunload', this.handleBeforeUnload, false); + window.addEventListener('resize', this.handleResize, { passive: true }); document.addEventListener('dragenter', this.handleDragEnter, false); document.addEventListener('dragover', this.handleDragOver, false); @@ -371,9 +355,7 @@ class UI extends React.PureComponent { this.props.dispatch(expandNotifications()); setTimeout(() => this.props.dispatch(fetchFilters()), 500); - } - componentDidMount () { this.hotkeys.__mousetrap__.stopCallback = (e, element) => { return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); }; @@ -383,6 +365,7 @@ class UI extends React.PureComponent { window.removeEventListener('focus', this.handleWindowFocus); window.removeEventListener('blur', this.handleWindowBlur); window.removeEventListener('beforeunload', this.handleBeforeUnload); + window.removeEventListener('resize', this.handleResize); document.removeEventListener('dragenter', this.handleDragEnter); document.removeEventListener('dragover', this.handleDragOver); @@ -513,7 +496,7 @@ class UI extends React.PureComponent { render () { const { draggingOver } = this.state; - const { children, isComposing, location, dropdownMenuIsOpen } = this.props; + const { children, isComposing, location, dropdownMenuIsOpen, layout } = this.props; const handlers = { help: this.handleHotkeyToggleHelp, @@ -540,11 +523,11 @@ class UI extends React.PureComponent { return (
- + {children} - + {layout !== 'mobile' && } diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js index 5a8c3db08..2926eb4b1 100644 --- a/app/javascript/mastodon/is_mobile.js +++ b/app/javascript/mastodon/is_mobile.js @@ -1,9 +1,18 @@ import { supportsPassiveEvents } from 'detect-passive-events'; +import { forceSingleColumn } from 'mastodon/initial_state'; const LAYOUT_BREAKPOINT = 630; -export function isMobile(width) { - return width <= LAYOUT_BREAKPOINT; +export const isMobile = width => width <= LAYOUT_BREAKPOINT; + +export const layoutFromWindow = () => { + if (isMobile(window.innerWidth)) { + return 'mobile'; + } else if (forceSingleColumn) { + return 'single-column'; + } else { + return 'multi-column'; + } }; const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; @@ -11,17 +20,13 @@ const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; let userTouching = false; let listenerOptions = supportsPassiveEvents ? { passive: true } : false; -function touchListener() { +const touchListener = () => { userTouching = true; window.removeEventListener('touchstart', touchListener, listenerOptions); -} +}; window.addEventListener('touchstart', touchListener, listenerOptions); -export function isUserTouching() { - return userTouching; -} +export const isUserTouching = () => userTouching; -export function isIOS() { - return iOS; -}; +export const isIOS = () => iOS; diff --git a/app/javascript/mastodon/reducers/meta.js b/app/javascript/mastodon/reducers/meta.js index 36a5a1c35..65becc44f 100644 --- a/app/javascript/mastodon/reducers/meta.js +++ b/app/javascript/mastodon/reducers/meta.js @@ -1,15 +1,20 @@ -import { STORE_HYDRATE } from '../actions/store'; +import { STORE_HYDRATE } from 'mastodon/actions/store'; +import { APP_LAYOUT_CHANGE } from 'mastodon/actions/app'; import { Map as ImmutableMap } from 'immutable'; +import { layoutFromWindow } from 'mastodon/is_mobile'; const initialState = ImmutableMap({ streaming_api_base_url: null, access_token: null, + layout: layoutFromWindow(), }); export default function meta(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: return state.merge(action.state.get('meta')); + case APP_LAYOUT_CHANGE: + return state.set('layout', action.layout); default: return state; } -- cgit From d8d43a427a549cf063de3f6b3c22a08a06f53ffa Mon Sep 17 00:00:00 2001 From: ThibG Date: Wed, 18 Nov 2020 18:01:58 +0100 Subject: Fix image uploads being random data when canvas read access is blocked (#15180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #15178 PR #11499 introduced a way to deal with all-white canvas, but newer Firefox versions set random data instead. This PR detects whether canvas operations are reliable by comparing the results on a hardcoded 2×2 pixels image, and memoizing the result. This should be both more reliable and faster than the previous check. --- app/javascript/mastodon/utils/resize_image.js | 50 ++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 9 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/utils/resize_image.js b/app/javascript/mastodon/utils/resize_image.js index 6c1cb61a2..8f1485379 100644 --- a/app/javascript/mastodon/utils/resize_image.js +++ b/app/javascript/mastodon/utils/resize_image.js @@ -41,6 +41,45 @@ const dropOrientationIfNeeded = (orientation) => new Promise(resolve => { } }); +// Some browsers don't allow reading from a canvas and instead return all-white +// or randomized data. Use a pre-defined image to check if reading the canvas +// works. +const checkCanvasReliability = () => new Promise((resolve, reject) => { + switch(_browser_quirks['canvas-read-unreliable']) { + case true: + reject('Canvas reading unreliable'); + break; + case false: + resolve(); + break; + default: + // 2×2 GIF with white, red, green and blue pixels + const testImageURL = + 'data:image/gif;base64,R0lGODdhAgACAKEDAAAA//8AAAD/AP///ywAAAAAAgACAAACA1wEBQA7'; + const refData = + [255, 255, 255, 255, 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]; + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + context.drawImage(img, 0, 0, 2, 2); + const imageData = context.getImageData(0, 0, 2, 2); + if (imageData.data.every((x, i) => refData[i] === x)) { + _browser_quirks['canvas-read-unreliable'] = false; + resolve(); + } else { + _browser_quirks['canvas-read-unreliable'] = true; + reject('Canvas reading unreliable'); + } + }; + img.onerror = () => { + _browser_quirks['canvas-read-unreliable'] = true; + reject('Failed to load test image'); + }; + img.src = testImageURL; + } +}); + const getImageUrl = inputFile => new Promise((resolve, reject) => { if (window.URL && URL.createObjectURL) { try { @@ -110,14 +149,6 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) = context.drawImage(img, 0, 0, width, height); - // The Tor Browser and maybe other browsers may prevent reading from canvas - // and return an all-white image instead. Assume reading failed if the resized - // image is perfectly white. - const imageData = context.getImageData(0, 0, width, height); - if (imageData.data.every(value => value === 255)) { - throw 'Failed to read from canvas'; - } - canvas.toBlob(resolve, type); }); @@ -127,7 +158,8 @@ const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) = const newWidth = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (width / height))); const newHeight = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (height / width))); - getOrientation(img, type) + checkCanvasReliability() + .then(getOrientation(img, type)) .then(orientation => processImage(img, { width: newWidth, height: newHeight, -- cgit From 966593393ede79e66d443a6b299c3aa35611e3ef Mon Sep 17 00:00:00 2001 From: ThibG Date: Thu, 19 Nov 2020 00:23:46 +0100 Subject: Fix DMs not appearing into timelines (#15182) Fixes #15179 --- app/services/fan_out_on_write_service.rb | 1 + 1 file changed, 1 insertion(+) (limited to 'app') diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 4f6c64de1..b72bb82d3 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -9,6 +9,7 @@ class FanOutOnWriteService < BaseService deliver_to_self(status) if status.account.local? if status.direct_visibility? + deliver_to_mentioned_followers(status) deliver_to_own_conversation(status) elsif status.limited_visibility? deliver_to_mentioned_followers(status) -- cgit From df1653174be233f2737d8ec281325dee54011947 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 19 Nov 2020 17:38:06 +0100 Subject: Add cache buster feature for media files (#15155) Nginx can be configured to bypass proxy cache when a special header is in the request. If the response is cacheable, it will replace the cache for that request. Proxy caching of media files is desirable when using object storage as a way of minimizing bandwidth costs, but has the drawback of leaving deleted media files for a configured amount of cache time. A cache buster can make those media files immediately unavailable. This especially makes sense when suspending and unsuspending an account. --- app/lib/cache_buster.rb | 28 ++++++++++++++++++++++++++++ app/services/suspend_account_service.rb | 2 ++ app/services/unsuspend_account_service.rb | 2 ++ app/workers/cache_buster_worker.rb | 18 ++++++++++++++++++ config/initializers/cache_buster.rb | 10 ++++++++++ config/initializers/paperclip.rb | 1 - 6 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 app/lib/cache_buster.rb create mode 100644 app/workers/cache_buster_worker.rb create mode 100644 config/initializers/cache_buster.rb (limited to 'app') diff --git a/app/lib/cache_buster.rb b/app/lib/cache_buster.rb new file mode 100644 index 000000000..035611518 --- /dev/null +++ b/app/lib/cache_buster.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class CacheBuster + def initialize(options = {}) + @secret_header = options[:secret_header] || 'Secret-Header' + @secret = options[:secret] || 'True' + end + + def bust(url) + site = Addressable::URI.parse(url).normalized_site + + request_pool.with(site) do |http_client| + build_request(url, http_client).perform + end + end + + private + + def request_pool + RequestPool.current + end + + def build_request(url, http_client) + Request.new(:get, url, http_client: http_client).tap do |request| + request.add_headers(@secret_header => @secret) + end + end +end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index 7c70a6021..19d65280d 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -78,6 +78,8 @@ class SuspendAccountService < BaseService Rails.logger.warn "Tried to change permission on non-existent file #{attachment.path(style)}" end end + + CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled end end end diff --git a/app/services/unsuspend_account_service.rb b/app/services/unsuspend_account_service.rb index a81d1ac4f..f07a3f053 100644 --- a/app/services/unsuspend_account_service.rb +++ b/app/services/unsuspend_account_service.rb @@ -69,6 +69,8 @@ class UnsuspendAccountService < BaseService Rails.logger.warn "Tried to change permission on non-existent file #{attachment.path(style)}" end end + + CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled end end end diff --git a/app/workers/cache_buster_worker.rb b/app/workers/cache_buster_worker.rb new file mode 100644 index 000000000..5ad0a44cb --- /dev/null +++ b/app/workers/cache_buster_worker.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CacheBusterWorker + include Sidekiq::Worker + include RoutingHelper + + sidekiq_options queue: 'pull' + + def perform(path) + cache_buster.bust(full_asset_url(path)) + end + + private + + def cache_buster + CacheBuster.new(Rails.configuration.x.cache_buster) + end +end diff --git a/config/initializers/cache_buster.rb b/config/initializers/cache_buster.rb new file mode 100644 index 000000000..227e450f3 --- /dev/null +++ b/config/initializers/cache_buster.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.x.cache_buster_enabled = ENV['CACHE_BUSTER_ENABLED'] == 'true' + + config.x.cache_buster = { + secret_header: ENV['CACHE_BUSTER_SECRET_HEADER'], + secret: ENV['CACHE_BUSTER_SECRET'], + } +end diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb index b841d5220..25adcd8d6 100644 --- a/config/initializers/paperclip.rb +++ b/config/initializers/paperclip.rb @@ -107,7 +107,6 @@ elsif ENV['SWIFT_ENABLED'] == 'true' else Paperclip::Attachment.default_options.merge!( storage: :filesystem, - use_timestamp: true, path: File.join(ENV.fetch('PAPERCLIP_ROOT_PATH', File.join(':rails_root', 'public', 'system')), ':prefix_path:class', ':attachment', ':id_partition', ':style', ':filename'), url: ENV.fetch('PAPERCLIP_ROOT_URL', '/system') + '/:prefix_url:class/:attachment/:id_partition/:style/:filename', ) -- cgit From 2f6831f3187af0dfd9cacd7d4cba5664be7a9cbd Mon Sep 17 00:00:00 2001 From: ThibG Date: Thu, 19 Nov 2020 17:39:47 +0100 Subject: Fix sending spurious Rejects when processing remote account deletion (#15104) * Fix sending spurious Rejects when processing remote account deletion * Make skip_side_effects imply skip_activitypub --- app/lib/activitypub/activity/delete.rb | 2 +- app/services/delete_account_service.rb | 5 ++++- app/services/resolve_account_service.rb | 2 +- app/workers/account_deletion_worker.rb | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) (limited to 'app') diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 09b9e5e0e..2e5293b83 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -13,7 +13,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity def delete_person lock_or_return("delete_in_progress:#{@account.id}") do - DeleteAccountService.new.call(@account, reserve_username: false) + DeleteAccountService.new.call(@account, reserve_username: false, skip_activitypub: true) end end diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb index de6488c78..778d064de 100644 --- a/app/services/delete_account_service.rb +++ b/app/services/delete_account_service.rb @@ -41,6 +41,7 @@ class DeleteAccountService < BaseService # @option [Boolean] :reserve_email Keep user record. Only applicable for local accounts # @option [Boolean] :reserve_username Keep account record # @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads + # @option [Boolean] :skip_activitypub Skip sending ActivityPub payloads. Implied by :skip_side_effects # @option [Time] :suspended_at Only applicable when :reserve_username is true def call(account, **options) @account = account @@ -52,6 +53,8 @@ class DeleteAccountService < BaseService @options[:skip_side_effects] = true end + @options[:skip_activitypub] = true if @options[:skip_side_effects] + reject_follows! purge_user! purge_profile! @@ -62,7 +65,7 @@ class DeleteAccountService < BaseService private def reject_follows! - return if @account.local? || !@account.activitypub? + return if @account.local? || !@account.activitypub? || @options[:skip_activitypub] # When deleting a remote account, the account obviously doesn't # actually become deleted on its origin server, i.e. unlike a diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index 4783e6d33..c5291fa67 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -145,7 +145,7 @@ class ResolveAccountService < BaseService end def queue_deletion! - AccountDeletionWorker.perform_async(@account.id, reserve_username: false) + AccountDeletionWorker.perform_async(@account.id, reserve_username: false, skip_activitypub: true) end def lock_options diff --git a/app/workers/account_deletion_worker.rb b/app/workers/account_deletion_worker.rb index 81c3b91ad..98b67419d 100644 --- a/app/workers/account_deletion_worker.rb +++ b/app/workers/account_deletion_worker.rb @@ -7,7 +7,8 @@ class AccountDeletionWorker def perform(account_id, options = {}) reserve_username = options.with_indifferent_access.fetch(:reserve_username, true) - DeleteAccountService.new.call(Account.find(account_id), reserve_username: reserve_username, reserve_email: false) + skip_activitypub = options.with_indifferent_access.fetch(:skip_activitypub, false) + DeleteAccountService.new.call(Account.find(account_id), reserve_username: reserve_username, skip_activitypub: skip_activitypub, reserve_email: false) rescue ActiveRecord::RecordNotFound true end -- cgit From 8e2530ea16332f389edaeba7e87d7447c077bb00 Mon Sep 17 00:00:00 2001 From: Darius Kazemi Date: Thu, 19 Nov 2020 08:40:18 -0800 Subject: Mark correctly as read reverse-order announcements (#15070) This fixes a bug in #15065 where the "read" indicator was not getting correctly set. The ID of a dismissed announcement is now correct. --- .../mastodon/features/getting_started/components/announcements.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js index d53bd8055..5bc3abac6 100644 --- a/app/javascript/mastodon/features/getting_started/components/announcements.js +++ b/app/javascript/mastodon/features/getting_started/components/announcements.js @@ -396,7 +396,7 @@ class Announcements extends ImmutablePureComponent { _markAnnouncementAsRead () { const { dismissAnnouncement, announcements } = this.props; const { index } = this.state; - const announcement = announcements.get(index); + const announcement = announcements.get(announcements.size - 1 - index); if (!announcement.get('read')) dismissAnnouncement(announcement.get('id')); } -- cgit From 96c1e7132971877fba308c51cd42306f0b1bf166 Mon Sep 17 00:00:00 2001 From: ThibG Date: Thu, 19 Nov 2020 17:48:13 +0100 Subject: Add import/export feature for bookmarks (#14956) * Add ability to export bookmarks * Add support for importing bookmarks * Add bookmark import tests * Add bookmarks export test --- .../settings/exports/bookmarks_controller.rb | 19 +++++++++ app/models/export.rb | 12 ++++++ app/models/import.rb | 2 +- app/services/import_service.rb | 45 ++++++++++++++++++++++ app/views/settings/exports/show.html.haml | 4 ++ config/locales/en.yml | 2 + config/routes.rb | 1 + .../settings/exports/bookmarks_controller_specs.rb | 17 ++++++++ spec/fixtures/files/bookmark-imports.txt | 4 ++ spec/services/import_service_spec.rb | 42 ++++++++++++++++++++ 10 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 app/controllers/settings/exports/bookmarks_controller.rb create mode 100644 spec/controllers/settings/exports/bookmarks_controller_specs.rb create mode 100644 spec/fixtures/files/bookmark-imports.txt (limited to 'app') diff --git a/app/controllers/settings/exports/bookmarks_controller.rb b/app/controllers/settings/exports/bookmarks_controller.rb new file mode 100644 index 000000000..c12e2f147 --- /dev/null +++ b/app/controllers/settings/exports/bookmarks_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Settings + module Exports + class BookmarksController < BaseController + include ExportControllerConcern + + def index + send_export_file + end + + private + + def export_data + @export.to_bookmarks_csv + end + end + end +end diff --git a/app/models/export.rb b/app/models/export.rb index cab01f11a..5216eed5e 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -9,6 +9,14 @@ class Export @account = account end + def to_bookmarks_csv + CSV.generate do |csv| + account.bookmarks.includes(:status).reorder(id: :desc).each do |bookmark| + csv << [ActivityPub::TagManager.instance.uri_for(bookmark.status)] + end + end + end + def to_blocked_accounts_csv to_csv account.blocking.select(:username, :domain) end @@ -55,6 +63,10 @@ class Export account.statuses_count end + def total_bookmarks + account.bookmarks.count + end + def total_follows account.following_count end diff --git a/app/models/import.rb b/app/models/import.rb index c78a04d07..702453289 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -24,7 +24,7 @@ class Import < ApplicationRecord belongs_to :account - enum type: [:following, :blocking, :muting, :domain_blocking] + enum type: [:following, :blocking, :muting, :domain_blocking, :bookmarks] validates :type, presence: true diff --git a/app/services/import_service.rb b/app/services/import_service.rb index 7e55452de..288e47f1e 100644 --- a/app/services/import_service.rb +++ b/app/services/import_service.rb @@ -18,6 +18,8 @@ class ImportService < BaseService import_mutes! when 'domain_blocking' import_domain_blocks! + when 'bookmarks' + import_bookmarks! end end @@ -88,6 +90,39 @@ class ImportService < BaseService end end + def import_bookmarks! + parse_import_data!(['#uri']) + items = @data.take(ROWS_PROCESSING_LIMIT).map { |row| row['#uri'].strip } + + if @import.overwrite? + presence_hash = items.each_with_object({}) { |id, mapping| mapping[id] = true } + + @account.bookmarks.find_each do |bookmark| + if presence_hash[bookmark.status.uri] + items.delete(bookmark.status.uri) + else + bookmark.destroy! + end + end + end + + statuses = items.map do |uri| + status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status) + next if status.nil? && ActivityPub::TagManager.instance.local_uri?(uri) + + status || ActivityPub::FetchRemoteStatusService.new.call(uri) + end.compact + + account_ids = statuses.map(&:account_id) + preloaded_relations = relations_map_for_account(@account, account_ids) + + statuses.keep_if { |status| StatusPolicy.new(@account, status, preloaded_relations).show? } + + statuses.each do |status| + @account.bookmarks.find_or_create_by!(account: @account, status: status) + end + end + def parse_import_data!(default_headers) data = CSV.parse(import_data, headers: true) data = CSV.parse(import_data, headers: default_headers) unless data.headers&.first&.strip&.include?(' ') @@ -101,4 +136,14 @@ class ImportService < BaseService def follow_limit FollowLimitValidator.limit_for_account(@account) end + + def relations_map_for_account(account, account_ids) + { + blocking: {}, + blocked_by: Account.blocked_by_map(account_ids, account.id), + muting: {}, + following: Account.following_map(account_ids, account.id), + domain_blocking_by_domain: {}, + } + end end diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml index 0bb80e937..18b52c0c2 100644 --- a/app/views/settings/exports/show.html.haml +++ b/app/views/settings/exports/show.html.haml @@ -36,6 +36,10 @@ %th= t('exports.domain_blocks') %td= number_with_delimiter @export.total_domain_blocks %td= table_link_to 'download', t('exports.csv'), settings_exports_domain_blocks_path(format: :csv) + %tr + %th= t('exports.bookmarks') + %td= number_with_delimiter @export.total_bookmarks + %td= table_link_to 'download', t('bookmarks.csv'), settings_exports_bookmarks_path(format: :csv) %hr.spacer/ diff --git a/config/locales/en.yml b/config/locales/en.yml index bec099082..263ffcdc7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -842,6 +842,7 @@ en: request: Request your archive size: Size blocks: You block + bookmarks: Bookmarks csv: CSV domain_blocks: Domain blocks lists: Lists @@ -918,6 +919,7 @@ en: success: Your data was successfully uploaded and will now be processed in due time types: blocking: Blocking list + bookmarks: Bookmarks domain_blocking: Domain blocking list following: Following list muting: Muting list diff --git a/config/routes.rb b/config/routes.rb index 54c76799c..a534b433e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -125,6 +125,7 @@ Rails.application.routes.draw do resources :mutes, only: :index, controller: :muted_accounts resources :lists, only: :index, controller: :lists resources :domain_blocks, only: :index, controller: :blocked_domains + resources :bookmarks, only: :index, controller: :bookmarks end resources :two_factor_authentication_methods, only: [:index] do diff --git a/spec/controllers/settings/exports/bookmarks_controller_specs.rb b/spec/controllers/settings/exports/bookmarks_controller_specs.rb new file mode 100644 index 000000000..85761577b --- /dev/null +++ b/spec/controllers/settings/exports/bookmarks_controller_specs.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +describe Settings::Exports::BookmarksController do + render_views + + describe 'GET #index' do + it 'returns a csv of the bookmarked toots' do + user = Fabricate(:user) + user.account.bookmarks.create!(status: Fabricate(:status, uri: 'https://foo.bar/statuses/1312')) + + sign_in user, scope: :user + get :index, format: :csv + + expect(response.body).to eq "https://foo.bar/statuses/1312\n" + end + end +end diff --git a/spec/fixtures/files/bookmark-imports.txt b/spec/fixtures/files/bookmark-imports.txt new file mode 100644 index 000000000..7cc8901a0 --- /dev/null +++ b/spec/fixtures/files/bookmark-imports.txt @@ -0,0 +1,4 @@ +https://example.com/statuses/1312 +https://local.com/users/foo/statuses/42 +https://unknown-remote.com/users/bar/statuses/1 +https://example.com/statuses/direct diff --git a/spec/services/import_service_spec.rb b/spec/services/import_service_spec.rb index b1909d4fd..764225aa7 100644 --- a/spec/services/import_service_spec.rb +++ b/spec/services/import_service_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe ImportService, type: :service do + include RoutingHelper + let!(:account) { Fabricate(:account, locked: false) } let!(:bob) { Fabricate(:account, username: 'bob', locked: false) } let!(:eve) { Fabricate(:account, username: 'eve', domain: 'example.com', locked: false, protocol: :activitypub, inbox_url: 'https://example.com/inbox') } @@ -169,4 +171,44 @@ RSpec.describe ImportService, type: :service do end end end + + context 'import bookmarks' do + subject { ImportService.new } + + let(:csv) { attachment_fixture('bookmark-imports.txt') } + + around(:each) do |example| + local_before = Rails.configuration.x.local_domain + web_before = Rails.configuration.x.web_domain + Rails.configuration.x.local_domain = 'local.com' + Rails.configuration.x.web_domain = 'local.com' + example.run + Rails.configuration.x.web_domain = web_before + Rails.configuration.x.local_domain = local_before + end + + let(:local_account) { Fabricate(:account, username: 'foo', domain: '') } + let!(:remote_status) { Fabricate(:status, uri: 'https://example.com/statuses/1312') } + let!(:direct_status) { Fabricate(:status, uri: 'https://example.com/statuses/direct', visibility: :direct) } + + before do + service = double + allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service) + allow(service).to receive(:call).with('https://unknown-remote.com/users/bar/statuses/1') do + Fabricate(:status, uri: 'https://unknown-remote.com/users/bar/statuses/1') + end + end + + describe 'when no bookmarks are set' do + let(:import) { Import.create(account: account, type: 'bookmarks', data: csv) } + it 'adds the toots the user has access to to bookmarks' do + local_status = Fabricate(:status, account: local_account, uri: 'https://local.com/users/foo/statuses/42', id: 42, local: true) + subject.call(import) + expect(account.bookmarks.map(&:status).map(&:id)).to include(local_status.id) + expect(account.bookmarks.map(&:status).map(&:id)).to include(remote_status.id) + expect(account.bookmarks.map(&:status).map(&:id)).not_to include(direct_status.id) + expect(account.bookmarks.count).to eq 3 + end + end + end end -- cgit From 8b8004a9626442ae31e4dffd79e874e9cde050c6 Mon Sep 17 00:00:00 2001 From: ThibG Date: Thu, 19 Nov 2020 19:52:06 +0100 Subject: Fix webfinger redirect handling in ResolveAccountService (#15187) * Fix webfinger redirect handling in ResolveAccountService ResolveAccountService#process_webfinger! handled a one-step webfinger redirection, but only accepting the result if it matched the exact URI passed as input, defeating the point of a redirection check. Instead, use the same logic as in `ActivityPub::FetchRemoteAccountService`, updating the resulting `acct:` URI with the result of the first webfinger query. * Add tests --- app/services/resolve_account_service.rb | 23 +++++++----- spec/services/resolve_account_service_spec.rb | 52 +++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 15 deletions(-) (limited to 'app') diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index c5291fa67..74b0b82d0 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -29,6 +29,7 @@ class ResolveAccountService < BaseService # At this point we are in need of a Webfinger query, which may # yield us a different username/domain through a redirect process_webfinger!(@uri) + @domain = nil if TagManager.instance.local_domain?(@domain) # Because the username/domain pair may be different than what # we already checked, we need to check if we've already got @@ -78,25 +79,31 @@ class ResolveAccountService < BaseService @uri = [@username, @domain].compact.join('@') end - def process_webfinger!(uri, redirected = false) + def process_webfinger!(uri) @webfinger = webfinger!("acct:#{uri}") - confirmed_username, confirmed_domain = @webfinger.subject.gsub(/\Aacct:/, '').split('@') + confirmed_username, confirmed_domain = split_acct(@webfinger.subject) if confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero? @username = confirmed_username @domain = confirmed_domain - @uri = uri - elsif !redirected - return process_webfinger!("#{confirmed_username}@#{confirmed_domain}", true) - else - raise Webfinger::RedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}" + return end - @domain = nil if TagManager.instance.local_domain?(@domain) + # Account doesn't match, so it may have been redirected + @webfinger = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}") + @username, @domain = split_acct(@webfinger.subject) + + unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero? + raise Webfinger::RedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}" + end rescue Webfinger::GoneError @gone = true end + def split_acct(acct) + acct.gsub(/\Aacct:/, '').split('@') + end + def process_account! return unless activitypub_ready? diff --git a/spec/services/resolve_account_service_spec.rb b/spec/services/resolve_account_service_spec.rb index 76cb9ed8d..5bd0ec264 100644 --- a/spec/services/resolve_account_service_spec.rb +++ b/spec/services/resolve_account_service_spec.rb @@ -4,11 +4,8 @@ RSpec.describe ResolveAccountService, type: :service do subject { described_class.new } before do - stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt')) - stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:catsrgr8@example.com").to_return(status: 404) stub_request(:get, "https://example.com/.well-known/host-meta").to_return(status: 404) stub_request(:get, "https://quitter.no/avatar/7477-300-20160211190340.png").to_return(request_fixture('avatar.txt')) - stub_request(:get, "https://quitter.no/.well-known/webfinger?resource=acct:catsrgr8@quitter.no").to_return(status: 404) stub_request(:get, "https://ap.example.com/.well-known/webfinger?resource=acct:foo@ap.example.com").to_return(request_fixture('activitypub-webfinger.txt')) stub_request(:get, "https://ap.example.com/users/foo").to_return(request_fixture('activitypub-actor.txt')) stub_request(:get, "https://ap.example.com/users/foo.atom").to_return(request_fixture('activitypub-feed.txt')) @@ -16,12 +13,25 @@ RSpec.describe ResolveAccountService, type: :service do stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:hoge@example.com').to_return(status: 410) end - it 'returns nil if no such user can be resolved via webfinger' do - expect(subject.call('catsrgr8@quitter.no')).to be_nil + context 'when there is an LRDD endpoint but no resolvable account' do + before do + stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt')) + stub_request(:get, "https://quitter.no/.well-known/webfinger?resource=acct:catsrgr8@quitter.no").to_return(status: 404) + end + + it 'returns nil' do + expect(subject.call('catsrgr8@quitter.no')).to be_nil + end end - it 'returns nil if the domain does not have webfinger' do - expect(subject.call('catsrgr8@example.com')).to be_nil + context 'when there is no LRDD endpoint nor resolvable account' do + before do + stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:catsrgr8@example.com").to_return(status: 404) + end + + it 'returns nil' do + expect(subject.call('catsrgr8@example.com')).to be_nil + end end context 'when webfinger returns http gone' do @@ -48,6 +58,34 @@ RSpec.describe ResolveAccountService, type: :service do end end + context 'with a legitimate webfinger redirection' do + before do + webfinger = { subject: 'acct:foo@ap.example.com', links: [{ rel: 'self', href: 'https://ap.example.com/users/foo' }] } + stub_request(:get, 'https://redirected.example.com/.well-known/webfinger?resource=acct:Foo@redirected.example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'returns new remote account' do + account = subject.call('Foo@redirected.example.com') + + expect(account.activitypub?).to eq true + expect(account.acct).to eq 'foo@ap.example.com' + expect(account.inbox_url).to eq 'https://ap.example.com/users/foo/inbox' + end + end + + context 'with too many webfinger redirections' do + before do + webfinger = { subject: 'acct:foo@evil.example.com', links: [{ rel: 'self', href: 'https://ap.example.com/users/foo' }] } + stub_request(:get, 'https://redirected.example.com/.well-known/webfinger?resource=acct:Foo@redirected.example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + webfinger2 = { subject: 'acct:foo@ap.example.com', links: [{ rel: 'self', href: 'https://ap.example.com/users/foo' }] } + stub_request(:get, 'https://evil.example.com/.well-known/webfinger?resource=acct:foo@evil.example.com').to_return(body: Oj.dump(webfinger2), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'returns new remote account' do + expect { subject.call('Foo@redirected.example.com') }.to raise_error Webfinger::RedirectError + end + end + context 'with an ActivityPub account' do it 'returns new remote account' do account = subject.call('foo@ap.example.com') -- cgit From b82aa33deab18e499fb32d37c1915d986c3ce88e Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 15 Nov 2020 14:24:54 +0100 Subject: [Glitch] Add hotkeys for audio/video control Port 04a079e7230bc4e73b897e3c4462011cddf36474 to glitch-soc Signed-off-by: Thibaut Girka --- .../flavours/glitch/features/audio/index.js | 51 +++++++++++++- .../flavours/glitch/features/video/index.js | 78 ++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) (limited to 'app') diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js index c050a63a9..ac0468f70 100644 --- a/app/javascript/flavours/glitch/features/audio/index.js +++ b/app/javascript/flavours/glitch/features/audio/index.js @@ -392,13 +392,59 @@ class Audio extends React.PureComponent { return this.props.foregroundColor || '#ffffff'; } + seekBy (time) { + const currentTime = this.audio.currentTime + time; + + if (!isNaN(currentTime)) { + this.setState({ currentTime }, () => { + this.audio.currentTime = currentTime; + }); + } + } + + handleAudioKeyDown = e => { + // On the audio element or the seek bar, we can safely use the space bar + // for playback control because there are no buttons to press + + if (e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + } + } + + handleKeyDown = e => { + switch(e.key) { + case 'k': + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + break; + case 'm': + e.preventDefault(); + e.stopPropagation(); + this.toggleMute(); + break; + case 'j': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-10); + break; + case 'l': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(10); + break; + } + } + render () { const { src, intl, alt, editable, autoPlay } = this.props; const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; const progress = Math.min((currentTime / duration) * 100, 100); return ( -
+
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js index 870812856..21a2594e7 100644 --- a/app/javascript/flavours/glitch/features/video/index.js +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -279,6 +279,81 @@ class Video extends React.PureComponent { } }, 15); + seekBy (time) { + const currentTime = this.video.currentTime + time; + + if (!isNaN(currentTime)) { + this.setState({ currentTime }, () => { + this.video.currentTime = currentTime; + }); + } + } + + handleVideoKeyDown = e => { + // On the video element or the seek bar, we can safely use the space bar + // for playback control because there are no buttons to press + + if (e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + } + } + + handleKeyDown = e => { + const frameTime = 1 / 25; + + switch(e.key) { + case 'k': + e.preventDefault(); + e.stopPropagation(); + this.togglePlay(); + break; + case 'm': + e.preventDefault(); + e.stopPropagation(); + this.toggleMute(); + break; + case 'f': + e.preventDefault(); + e.stopPropagation(); + this.toggleFullscreen(); + break; + case 'j': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-10); + break; + case 'l': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(10); + break; + case ',': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(-frameTime); + break; + case '.': + e.preventDefault(); + e.stopPropagation(); + this.seekBy(frameTime); + break; + } + + // If we are in fullscreen mode, we don't want any hotkeys + // interacting with the UI that's not visible + + if (this.state.fullscreen) { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === 'Escape') { + exitFullscreen(); + } + } + } + togglePlay = () => { if (this.state.paused) { this.setState({ paused: false }, () => this.video.play()); @@ -504,6 +579,7 @@ class Video extends React.PureComponent { onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onMouseDown={this.handleMouseDownRoot} + onKeyDown={this.handleKeyDown} tabIndex={0} >
-- cgit From 15be0bde8a7d45ed032dd8b5a634216ccb9a056b Mon Sep 17 00:00:00 2001 From: ThibG Date: Wed, 18 Nov 2020 18:01:58 +0100 Subject: [Glitch] Fix image uploads being random data when canvas read access is blocked Port d8d43a427a549cf063de3f6b3c22a08a06f53ffa to glitch-soc Signed-off-by: Thibaut Girka --- .../flavours/glitch/util/resize_image.js | 50 ++++++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) (limited to 'app') diff --git a/app/javascript/flavours/glitch/util/resize_image.js b/app/javascript/flavours/glitch/util/resize_image.js index 8c89b2841..57fc6a6ec 100644 --- a/app/javascript/flavours/glitch/util/resize_image.js +++ b/app/javascript/flavours/glitch/util/resize_image.js @@ -41,6 +41,45 @@ const dropOrientationIfNeeded = (orientation) => new Promise(resolve => { } }); +// Some browsers don't allow reading from a canvas and instead return all-white +// or randomized data. Use a pre-defined image to check if reading the canvas +// works. +const checkCanvasReliability = () => new Promise((resolve, reject) => { + switch(_browser_quirks['canvas-read-unreliable']) { + case true: + reject('Canvas reading unreliable'); + break; + case false: + resolve(); + break; + default: + // 2×2 GIF with white, red, green and blue pixels + const testImageURL = + 'data:image/gif;base64,R0lGODdhAgACAKEDAAAA//8AAAD/AP///ywAAAAAAgACAAACA1wEBQA7'; + const refData = + [255, 255, 255, 255, 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]; + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + context.drawImage(img, 0, 0, 2, 2); + const imageData = context.getImageData(0, 0, 2, 2); + if (imageData.data.every((x, i) => refData[i] === x)) { + _browser_quirks['canvas-read-unreliable'] = false; + resolve(); + } else { + _browser_quirks['canvas-read-unreliable'] = true; + reject('Canvas reading unreliable'); + } + }; + img.onerror = () => { + _browser_quirks['canvas-read-unreliable'] = true; + reject('Failed to load test image'); + }; + img.src = testImageURL; + } +}); + const getImageUrl = inputFile => new Promise((resolve, reject) => { if (window.URL && URL.createObjectURL) { try { @@ -110,14 +149,6 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) = context.drawImage(img, 0, 0, width, height); - // The Tor Browser and maybe other browsers may prevent reading from canvas - // and return an all-white image instead. Assume reading failed if the resized - // image is perfectly white. - const imageData = context.getImageData(0, 0, width, height); - if (imageData.data.every(value => value === 255)) { - throw 'Failed to read from canvas'; - } - canvas.toBlob(resolve, type); }); @@ -127,7 +158,8 @@ const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) = const newWidth = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (width / height))); const newHeight = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (height / width))); - getOrientation(img, type) + checkCanvasReliability() + .then(getOrientation(img, type)) .then(orientation => processImage(img, { width: newWidth, height: newHeight, -- cgit From 3281c9b4c0935a9296e0ea5f359243d732605832 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Fri, 20 Nov 2020 14:20:17 +0100 Subject: Fix video not taking focus on interaction Revert part of #737 to restore hotkey functionality --- app/javascript/flavours/glitch/features/video/index.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) (limited to 'app') diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js index 21a2594e7..ea40b6073 100644 --- a/app/javascript/flavours/glitch/features/video/index.js +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -182,10 +182,7 @@ class Video extends React.PureComponent { this.volume = c; } - handleMouseDownRoot = e => { - e.preventDefault(); - e.stopPropagation(); - } + handleClickRoot = e => e.stopPropagation(); handlePlay = () => { this.setState({ paused: false }); @@ -578,7 +575,7 @@ class Video extends React.PureComponent { ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} - onMouseDown={this.handleMouseDownRoot} + onClick={this.handleClickRoot} onKeyDown={this.handleKeyDown} tabIndex={0} > -- cgit