From fbbec3fc0b472abcf2acc20de71f09e0fbfaa010 Mon Sep 17 00:00:00 2001 From: mayaeh Date: Thu, 9 Jul 2020 06:53:56 +0900 Subject: [PATCH 01/11] remove unused word. (#14250) ran `yarn manage:translations en` --- .../features/account/components/header.js | 1 - .../mastodon/locales/defaultMessages.json | 50 +++++++++++++------ app/javascript/mastodon/locales/en.json | 9 +++- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index b5aca574f1..9613b0b9ed 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -47,7 +47,6 @@ const messages = defineMessages({ unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, - add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' }, }); const dateFormatOptions = { diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 0aeefecab7..f62e0b0df8 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -139,6 +139,23 @@ ], "path": "app/javascript/mastodon/components/column_header.json" }, + { + "descriptors": [ + { + "defaultMessage": "{count, plural, one {{counter} Toot} other {{counter} Toots}}", + "id": "account.statuses_counter" + }, + { + "defaultMessage": "{count, plural, other {{counter} Following}}", + "id": "account.following_counter" + }, + { + "defaultMessage": "{count, plural, one {{counter} Follower} other {{counter} Followers}}", + "id": "account.followers_counter" + } + ], + "path": "app/javascript/mastodon/components/common_counter.json" + }, { "descriptors": [ { @@ -172,8 +189,8 @@ { "descriptors": [ { - "defaultMessage": "{count} {rawCount, plural, one {person} other {people}} talking", - "id": "trends.count_by_accounts" + "defaultMessage": "{count, plural, one {{counter} person} other {{counter} people}} talking", + "id": "trends.counter_by_accounts" } ], "path": "app/javascript/mastodon/components/hashtag.json" @@ -340,6 +357,23 @@ ], "path": "app/javascript/mastodon/components/relative_timestamp.json" }, + { + "descriptors": [ + { + "defaultMessage": "{count}K", + "id": "units.short.thousand" + }, + { + "defaultMessage": "{count}M", + "id": "units.short.million" + }, + { + "defaultMessage": "{count}B", + "id": "units.short.billion" + } + ], + "path": "app/javascript/mastodon/components/short_number.json" + }, { "descriptors": [ { @@ -833,18 +867,6 @@ { "defaultMessage": "Group", "id": "account.badges.group" - }, - { - "defaultMessage": "Toots", - "id": "account.posts" - }, - { - "defaultMessage": "Follows", - "id": "account.follows" - }, - { - "defaultMessage": "Followers", - "id": "account.followers" } ], "path": "app/javascript/mastodon/features/account/components/header.json" diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index b562b2afcb..19356873aa 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -15,7 +15,8 @@ "account.follow": "Follow", "account.followers": "Followers", "account.followers.empty": "No one follows this user yet.", - "account.follows": "Follows", + "account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}", + "account.following_counter": "{count, plural, other {{counter} Following}}", "account.follows.empty": "This user doesn't follow anyone yet.", "account.follows_you": "Follows you", "account.hide_reblogs": "Hide boosts from @{name}", @@ -35,6 +36,7 @@ "account.requested": "Awaiting approval. Click to cancel follow request", "account.share": "Share @{name}'s profile", "account.show_reblogs": "Show boosts from @{name}", + "account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}", "account.unblock": "Unblock @{name}", "account.unblock_domain": "Unblock domain {domain}", "account.unendorse": "Don't feature on profile", @@ -421,9 +423,12 @@ "timeline_hint.resources.followers": "Followers", "timeline_hint.resources.follows": "Follows", "timeline_hint.resources.statuses": "Older toots", - "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", + "trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} talking", "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", + "units.short.billion": "{count}B", + "units.short.million": "{count}M", + "units.short.thousand": "{count}K", "upload_area.title": "Drag & drop to upload", "upload_button.label": "Add images, a video or an audio file", "upload_error.limit": "File upload limit exceeded.", From 5b7a93b02cfa4916bd7a99d876b0e6b567e639c3 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 9 Jul 2020 12:53:16 +0200 Subject: [PATCH 02/11] Fix videos on public pages not using custom thumbnails (#14273) --- app/views/media/player.html.haml | 2 +- app/views/statuses/_detailed_status.html.haml | 2 +- app/views/statuses/_simple_status.html.haml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/media/player.html.haml b/app/views/media/player.html.haml index 1d03748970..ae47750e9a 100644 --- a/app/views/media/player.html.haml +++ b/app/views/media/player.html.haml @@ -3,7 +3,7 @@ = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' - if @media_attachment.video? - = react_component :video, src: @media_attachment.file.url(:original), preview: @media_attachment.file.url(:small), blurhash: @media_attachment.blurhash, width: 670, height: 380, editable: true, detailed: true, inline: true, alt: @media_attachment.description do + = react_component :video, src: @media_attachment.file.url(:original), preview: @media_attachment.thumbnail.present? ? @media_attachment.thumbnail.url : @media_attachment.file.url(:small), blurhash: @media_attachment.blurhash, width: 670, height: 380, editable: true, detailed: true, inline: true, alt: @media_attachment.description do %video{ controls: 'controls' } %source{ src: @media_attachment.file.url(:original) } - elsif @media_attachment.gifv? diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml index dce122607e..85b2ceea44 100644 --- a/app/views/statuses/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -29,7 +29,7 @@ - if !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first - = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: status.sensitive?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do + = react_component :video, src: video.file.url(:original), preview: video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small), blurhash: video.blurhash, sensitive: status.sensitive?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - elsif status.media_attachments.first.audio? - audio = status.media_attachments.first diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index b29e92ddc9..67c6c0fd09 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -35,7 +35,7 @@ - if !status.media_attachments.empty? - if status.media_attachments.first.video? - video = status.media_attachments.first - = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: status.sensitive?, width: 610, height: 343, inline: true, alt: video.description do + = react_component :video, src: video.file.url(:original), preview: video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small), blurhash: video.blurhash, sensitive: status.sensitive?, width: 610, height: 343, inline: true, alt: video.description do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - elsif status.media_attachments.first.audio? - audio = status.media_attachments.first From 61c07c37317f01c1ab4981826704750fe9937fe7 Mon Sep 17 00:00:00 2001 From: Sasha Sorokin Date: Thu, 9 Jul 2020 18:01:30 +0700 Subject: [PATCH 03/11] Replace repetitive blurhash code with component (#14267) This commit replaces all unnecessarily repeated code for decoding and embedding blurhash canvases with separate component - . Under the hood Blurhash component will use effect dependent on its props. This gives a few benefits: it will only be re-rendered whenever the hash or width/height/dummy props update, and will not render if canvas won't get to the final DOM, because then effect won't fire, which prevents weird bugs like #14257. --- .../mastodon/components/blurhash.js | 61 +++++++++++++++++++ .../mastodon/components/media_gallery.js | 46 ++++---------- .../account_gallery/components/media_item.js | 40 +++--------- .../features/status/components/card.js | 42 +++---------- .../mastodon/features/video/index.js | 38 +++--------- 5 files changed, 102 insertions(+), 125 deletions(-) create mode 100644 app/javascript/mastodon/components/blurhash.js diff --git a/app/javascript/mastodon/components/blurhash.js b/app/javascript/mastodon/components/blurhash.js new file mode 100644 index 0000000000..172f8c2f5e --- /dev/null +++ b/app/javascript/mastodon/components/blurhash.js @@ -0,0 +1,61 @@ +// @ts-check + +import { decode } from 'blurhash'; +import React, { useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; + +/** + * @typedef BlurhashPropsBase + * @property {string} hash Hash to render + * @property {number} width + * Width of the blurred region in pixels. Defaults to 32 + * @property {number} [height] + * Height of the blurred region in pixels. Defaults to width + * @property {boolean} [dummy] + * Whether dummy mode is enabled. If enabled, nothing is rendered + * and canvas left untouched + */ + +/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */ + +/** + * Component that is used to render blurred of blurhash string + * + * @param {BlurhashProps} param1 Props of the component + * @returns Canvas which will render blurred region element to embed + */ +function Blurhash({ + hash, + width = 32, + height = width, + dummy = false, + ...canvasProps +}) { + const canvasRef = /** @type {import('react').MutableRefObject} */ (useRef()); + + useEffect(() => { + const { current: canvas } = canvasRef; + canvas.width = canvas.width; // resets canvas + + if (dummy) return; + + const pixels = decode(hash, width, height); + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(pixels, width, height); + + ctx.putImageData(imageData, 0, 0); + }, [dummy, hash, width, height]); + + return ( + + ); +} + +Blurhash.propTypes = { + hash: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + dummy: PropTypes.bool, +}; + +export default React.memo(Blurhash); diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 0ec8661380..0a8f425850 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -7,8 +7,8 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; import classNames from 'classnames'; import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_state'; -import { decode } from 'blurhash'; import { debounce } from 'lodash'; +import Blurhash from 'mastodon/components/blurhash'; const messages = defineMessages({ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide {number, plural, one {image} other {images}}' }, @@ -74,36 +74,6 @@ class Item extends React.PureComponent { e.stopPropagation(); } - componentDidMount () { - if (this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - componentDidUpdate (prevProps) { - if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - _decode () { - if (!useBlurhash) return; - - const hash = this.props.attachment.get('blurhash'); - const pixels = decode(hash, 32, 32); - - if (pixels) { - const ctx = this.canvas.getContext('2d'); - const imageData = new ImageData(pixels, 32, 32); - - ctx.putImageData(imageData, 0, 0); - } - } - - setCanvasRef = c => { - this.canvas = c; - } - handleImageLoad = () => { this.setState({ loaded: true }); } @@ -166,7 +136,11 @@ class Item extends React.PureComponent { return ( ); @@ -232,7 +206,13 @@ class Item extends React.PureComponent { return (
- + {visible && thumbnail}
); diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js index 617a45d165..9eb4ed0d3e 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -1,7 +1,7 @@ -import { decode } from 'blurhash'; +import Blurhash from 'mastodon/components/blurhash'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; -import { autoPlayGif, displayMedia } from 'mastodon/initial_state'; +import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state'; import { isIOS } from 'mastodon/is_mobile'; import PropTypes from 'prop-types'; import React from 'react'; @@ -21,34 +21,6 @@ export default class MediaItem extends ImmutablePureComponent { loaded: false, }; - componentDidMount () { - if (this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - componentDidUpdate (prevProps) { - if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - _decode () { - const hash = this.props.attachment.get('blurhash'); - const pixels = decode(hash, 32, 32); - - if (pixels) { - const ctx = this.canvas.getContext('2d'); - const imageData = new ImageData(pixels, 32, 32); - - ctx.putImageData(imageData, 0, 0); - } - } - - setCanvasRef = c => { - this.canvas = c; - } - handleImageLoad = () => { this.setState({ loaded: true }); } @@ -152,7 +124,13 @@ export default class MediaItem extends ImmutablePureComponent { return (
- + {visible && thumbnail} {!visible && icon} diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index 971682df8b..90f9ae7ae6 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -7,7 +7,7 @@ import punycode from 'punycode'; import classnames from 'classnames'; import Icon from 'mastodon/components/icon'; import { useBlurhash } from 'mastodon/initial_state'; -import { decode } from 'blurhash'; +import Blurhash from 'mastodon/components/blurhash'; import { debounce } from 'lodash'; const IDNA_PREFIX = 'xn--'; @@ -93,38 +93,12 @@ export default class Card extends React.PureComponent { componentDidMount () { window.addEventListener('resize', this.handleResize, { passive: true }); - - if (this.props.card && this.props.card.get('blurhash') && this.canvas) { - this._decode(); - } } componentWillUnmount () { window.removeEventListener('resize', this.handleResize); } - componentDidUpdate (prevProps) { - const { card } = this.props; - - if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash')) && this.canvas) { - this._decode(); - } - } - - _decode () { - if (!useBlurhash) return; - - const hash = this.props.card.get('blurhash'); - const pixels = decode(hash, 32, 32); - - if (pixels) { - const ctx = this.canvas.getContext('2d'); - const imageData = new ImageData(pixels, 32, 32); - - ctx.putImageData(imageData, 0, 0); - } - } - _setDimensions () { const width = this.node.offsetWidth; @@ -182,10 +156,6 @@ export default class Card extends React.PureComponent { } } - setCanvasRef = c => { - this.canvas = c; - } - handleImageLoad = () => { this.setState({ previewLoaded: true }); } @@ -238,7 +208,15 @@ export default class Card extends React.PureComponent { ); let embed = ''; - let canvas = ; + let canvas = ( + + ); let thumbnail = ; let spoilerButton = (
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index a4aa270883..231c517e98 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -85,6 +85,7 @@ class StatusActionBar extends ImmutablePureComponent { onPin: PropTypes.func, onBookmark: PropTypes.func, withDismiss: PropTypes.bool, + scrollKey: PropTypes.string, intl: PropTypes.object.isRequired, }; @@ -229,7 +230,7 @@ class StatusActionBar extends ImmutablePureComponent { } render () { - const { status, relationship, intl, withDismiss } = this.props; + const { status, relationship, intl, withDismiss, scrollKey } = this.props; const mutingConversation = status.get('muted'); const anonymousAccess = !me; @@ -333,7 +334,16 @@ class StatusActionBar extends ImmutablePureComponent { {shareButton}
- +
); diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index e1b370c913..25411c1272 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -99,6 +99,7 @@ export default class StatusList extends ImmutablePureComponent { onMoveUp={this.handleMoveUp} onMoveDown={this.handleMoveDown} contextType={timelineId} + scrollKey={this.props.scrollKey} showThread /> )) diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js index ab1823194b..6ec9bbffdf 100644 --- a/app/javascript/mastodon/containers/dropdown_menu_container.js +++ b/app/javascript/mastodon/containers/dropdown_menu_container.js @@ -12,7 +12,7 @@ const mapStateToProps = state => ({ openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), }); -const mapDispatchToProps = (dispatch, { status, items }) => ({ +const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({ onOpen(id, onItemClick, dropdownPlacement, keyboard) { if (status) { dispatch(fetchRelationships([status.getIn(['account', 'id'])])); @@ -22,7 +22,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({ status, actions: items, onClick: onItemClick, - }) : openDropdownMenu(id, dropdownPlacement, keyboard)); + }) : openDropdownMenu(id, dropdownPlacement, keyboard, scrollKey)); }, onClose(id) { diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.js b/app/javascript/mastodon/features/direct_timeline/components/conversation.js index f9e45067f1..6ecc27facd 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversation.js +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.js @@ -36,6 +36,7 @@ class Conversation extends ImmutablePureComponent { accounts: ImmutablePropTypes.list.isRequired, lastStatus: ImmutablePropTypes.map, unread:PropTypes.bool.isRequired, + scrollKey: PropTypes.string, onMoveUp: PropTypes.func, onMoveDown: PropTypes.func, markRead: PropTypes.func.isRequired, @@ -127,7 +128,7 @@ class Conversation extends ImmutablePureComponent { } render () { - const { accounts, lastStatus, unread, intl } = this.props; + const { accounts, lastStatus, unread, scrollKey, intl } = this.props; if (lastStatus === null) { return null; @@ -194,7 +195,15 @@ class Conversation extends ImmutablePureComponent {
- +
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js index 8867bbd738..4ee8e52122 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js +++ b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js @@ -10,6 +10,7 @@ export default class ConversationsList extends ImmutablePureComponent { static propTypes = { conversations: ImmutablePropTypes.list.isRequired, + scrollKey: PropTypes.string.isRequired, hasMore: PropTypes.bool, isLoading: PropTypes.bool, onLoadMore: PropTypes.func, @@ -58,13 +59,14 @@ export default class ConversationsList extends ImmutablePureComponent { const { conversations, onLoadMore, ...other } = this.props; return ( - + {conversations.map(item => ( ))} diff --git a/app/javascript/mastodon/reducers/dropdown_menu.js b/app/javascript/mastodon/reducers/dropdown_menu.js index 36fd4f1321..a78a11acca 100644 --- a/app/javascript/mastodon/reducers/dropdown_menu.js +++ b/app/javascript/mastodon/reducers/dropdown_menu.js @@ -4,14 +4,14 @@ import { DROPDOWN_MENU_CLOSE, } from '../actions/dropdown_menu'; -const initialState = Immutable.Map({ openId: null, placement: null, keyboard: false }); +const initialState = Immutable.Map({ openId: null, placement: null, keyboard: false, scroll_key: null }); export default function dropdownMenu(state = initialState, action) { switch (action.type) { case DROPDOWN_MENU_OPEN: - return state.merge({ openId: action.id, placement: action.placement, keyboard: action.keyboard }); + return state.merge({ openId: action.id, placement: action.placement, keyboard: action.keyboard, scroll_key: action.scroll_key }); case DROPDOWN_MENU_CLOSE: - return state.get('openId') === action.id ? state.set('openId', null) : state; + return state.get('openId') === action.id ? state.set('openId', null).set('scroll_key', null) : state; default: return state; } From 3ef94c00444f2b72a6f68e0fd9cff1b3f783c555 Mon Sep 17 00:00:00 2001 From: Sasha Sorokin Date: Fri, 10 Jul 2020 03:32:36 +0700 Subject: [PATCH 05/11] Improve safety of Blurhash component (#14278) There was a missed empty hash check. As well as rendering is now wrapped in try/catch block, so app won't crash if any Blurhash component fails to render its contents as it's not that critical. --- app/javascript/mastodon/components/blurhash.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/javascript/mastodon/components/blurhash.js b/app/javascript/mastodon/components/blurhash.js index 172f8c2f5e..2af5cfc568 100644 --- a/app/javascript/mastodon/components/blurhash.js +++ b/app/javascript/mastodon/components/blurhash.js @@ -6,7 +6,7 @@ import PropTypes from 'prop-types'; /** * @typedef BlurhashPropsBase - * @property {string} hash Hash to render + * @property {string?} hash Hash to render * @property {number} width * Width of the blurred region in pixels. Defaults to 32 * @property {number} [height] @@ -37,13 +37,17 @@ function Blurhash({ const { current: canvas } = canvasRef; canvas.width = canvas.width; // resets canvas - if (dummy) return; + if (dummy || !hash) return; - const pixels = decode(hash, width, height); - const ctx = canvas.getContext('2d'); - const imageData = new ImageData(pixels, width, height); + try { + const pixels = decode(hash, width, height); + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(pixels, width, height); - ctx.putImageData(imageData, 0, 0); + ctx.putImageData(imageData, 0, 0); + } catch (err) { + console.error('Blurhash decoding failure', { err, hash }); + } }, [dummy, hash, width, height]); return ( From a78b87d80026feef8960d3d39df40f7a5a394f38 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 10 Jul 2020 12:25:44 +0200 Subject: [PATCH 06/11] Add attribution notice to the audio player component (#14280) The code for rendering a frequency graph around a circle has been adopted (with modifications) from a CodePen by Alex Permyakov --- app/javascript/mastodon/features/audio/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js index f0cd79873f..6ab835bd6e 100644 --- a/app/javascript/mastodon/features/audio/index.js +++ b/app/javascript/mastodon/features/audio/index.js @@ -21,6 +21,9 @@ const messages = defineMessages({ download: { id: 'video.download', defaultMessage: 'Download file' }, }); +// Some parts of the canvas rendering code in this file have been adopted from +// https://codepen.io/alexdevp/full/RNELPV by Alex Permyakov + const TICK_SIZE = 10; const PADDING = 180; From a2abe35e0f55c96e8b8525ee25089751859d14c2 Mon Sep 17 00:00:00 2001 From: ThibG Date: Fri, 10 Jul 2020 13:57:05 +0200 Subject: [PATCH 07/11] Audio player visualization improvements (#14281) * Fix audio player ticks position * Split visualizer code into own file to comply with license * Change top-left corner of visualizer always showing peaks, clean up code --- .../mastodon/features/audio/index.js | 161 ++---------------- .../mastodon/features/audio/visualizer.js | 136 +++++++++++++++ 2 files changed, 148 insertions(+), 149 deletions(-) create mode 100644 app/javascript/mastodon/features/audio/visualizer.js diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js index 6ab835bd6e..a494892576 100644 --- a/app/javascript/mastodon/features/audio/index.js +++ b/app/javascript/mastodon/features/audio/index.js @@ -7,11 +7,7 @@ import classNames from 'classnames'; import { throttle } from 'lodash'; import { getPointerPosition, fileNameFromURL } from 'mastodon/features/video'; import { debounce } from 'lodash'; - -const hex2rgba = (hex, alpha = 1) => { - const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16)); - return `rgba(${r}, ${g}, ${b}, ${alpha})`; -}; +import Visualizer from './visualizer'; const messages = defineMessages({ play: { id: 'video.play', defaultMessage: 'Play' }, @@ -21,9 +17,6 @@ const messages = defineMessages({ download: { id: 'video.download', defaultMessage: 'Download file' }, }); -// Some parts of the canvas rendering code in this file have been adopted from -// https://codepen.io/alexdevp/full/RNELPV by Alex Permyakov - const TICK_SIZE = 10; const PADDING = 180; @@ -57,6 +50,11 @@ class Audio extends React.PureComponent { dragging: false, }; + constructor (props) { + super(props); + this.visualizer = new Visualizer(TICK_SIZE); + } + setPlayerRef = c => { this.player = c; @@ -95,9 +93,7 @@ class Audio extends React.PureComponent { setCanvasRef = c => { this.canvas = c; - if (c) { - this.canvasContext = c.getContext('2d'); - } + this.visualizer.setCanvas(c); } componentDidMount () { @@ -265,17 +261,12 @@ class Audio extends React.PureComponent { _initAudioContext () { const context = new AudioContext(); - const analyser = context.createAnalyser(); const source = context.createMediaElementSource(this.audio); - analyser.smoothingTimeConstant = 0.6; - analyser.fftSize = 2048; - - source.connect(analyser); + this.visualizer.setAudioContext(context, source); source.connect(context.destination); this.audioContext = context; - this.analyser = analyser; } handleDownload = () => { @@ -308,20 +299,12 @@ class Audio extends React.PureComponent { }); } - _clear () { - this.canvasContext.clearRect(0, 0, this.state.width, this.state.height); + _clear() { + this.visualizer.clear(this.state.width, this.state.height); } - _draw () { - this.canvasContext.save(); - - const ticks = this._getTicks(360 * this._getScaleCoefficient(), TICK_SIZE); - - ticks.forEach(tick => { - this._drawTick(tick.x1, tick.y1, tick.x2, tick.y2); - }); - - this.canvasContext.restore(); + _draw() { + this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient()); } _getRadius () { @@ -332,126 +315,6 @@ class Audio extends React.PureComponent { return (this.state.height || this.props.height) / 982; } - _getTicks (count, size, animationParams = [0, 90]) { - const radius = this._getRadius(); - const ticks = this._getTickPoints(count); - const lesser = 200; - const m = []; - const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0; - const frequencyData = new Uint8Array(bufferLength); - const allScales = []; - const scaleCoefficient = this._getScaleCoefficient(); - - if (this.analyser) { - this.analyser.getByteFrequencyData(frequencyData); - } - - ticks.forEach((tick, i) => { - const coef = 1 - i / (ticks.length * 2.5); - - let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient; - - if (delta < 0) { - delta = 0; - } - - let k; - - if (animationParams[0] <= tick.angle && tick.angle <= animationParams[1]) { - k = radius / (radius - this._getSize(tick.angle, animationParams[0], animationParams[1]) - delta); - } else { - k = radius / (radius - (size + delta)); - } - - const x1 = tick.x * (radius - size); - const y1 = tick.y * (radius - size); - const x2 = x1 * k; - const y2 = y1 * k; - - m.push({ x1, y1, x2, y2 }); - - if (i < 20) { - let scale = delta / (200 * scaleCoefficient); - scale = scale < 1 ? 1 : scale; - allScales.push(scale); - } - }); - - const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length; - - return m.map(({ x1, y1, x2, y2 }) => ({ - x1: x1, - y1: y1, - x2: x2 * scale, - y2: y2 * scale, - })); - } - - _getSize (angle, l, r) { - const scaleCoefficient = this._getScaleCoefficient(); - const maxTickSize = TICK_SIZE * 9 * scaleCoefficient; - const m = (r - l) / 2; - const x = (angle - l); - - let h; - - if (x === m) { - return maxTickSize; - } - - const d = Math.abs(m - x); - const v = 40 * Math.sqrt(1 / d); - - if (v > maxTickSize) { - h = maxTickSize; - } else { - h = Math.max(TICK_SIZE, v); - } - - return h; - } - - _getTickPoints (count) { - const PI = 360; - const coords = []; - const step = PI / count; - - let rad; - - for(let deg = 0; deg < PI; deg += step) { - rad = deg * Math.PI / (PI / 2); - coords.push({ x: Math.cos(rad), y: -Math.sin(rad), angle: deg }); - } - - return coords; - } - - _drawTick (x1, y1, x2, y2) { - const cx = this._getCX(); - const cy = this._getCY(); - - const dx1 = Math.ceil(cx + x1); - const dy1 = Math.ceil(cy + y1); - const dx2 = Math.ceil(cx + x2); - const dy2 = Math.ceil(cy + y2); - - const gradient = this.canvasContext.createLinearGradient(dx1, dy1, dx2, dy2); - - const mainColor = this._getAccentColor(); - const lastColor = hex2rgba(mainColor, 0); - - gradient.addColorStop(0, mainColor); - gradient.addColorStop(0.6, mainColor); - gradient.addColorStop(1, lastColor); - - this.canvasContext.beginPath(); - this.canvasContext.strokeStyle = gradient; - this.canvasContext.lineWidth = 2; - this.canvasContext.moveTo(dx1, dy1); - this.canvasContext.lineTo(dx2, dy2); - this.canvasContext.stroke(); - } - _getCX() { return Math.floor(this.state.width / 2); } diff --git a/app/javascript/mastodon/features/audio/visualizer.js b/app/javascript/mastodon/features/audio/visualizer.js new file mode 100644 index 0000000000..77d5b5a65c --- /dev/null +++ b/app/javascript/mastodon/features/audio/visualizer.js @@ -0,0 +1,136 @@ +/* +Copyright (c) 2020 by Alex Permyakov (https://codepen.io/alexdevp/pen/RNELPV) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +const hex2rgba = (hex, alpha = 1) => { + const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16)); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; + +export default class Visualizer { + + constructor (tickSize) { + this.tickSize = tickSize; + } + + setCanvas(canvas) { + this.canvas = canvas; + if (canvas) { + this.context = canvas.getContext('2d'); + } + } + + setAudioContext(context, source) { + const analyser = context.createAnalyser(); + + analyser.smoothingTimeConstant = 0.6; + analyser.fftSize = 2048; + + source.connect(analyser); + + this.analyser = analyser; + } + + getTickPoints (count) { + const coords = []; + + for(let i = 0; i < count; i++) { + const rad = Math.PI * 2 * i / count; + coords.push({ x: Math.cos(rad), y: -Math.sin(rad) }); + } + + return coords; + } + + drawTick (cx, cy, mainColor, x1, y1, x2, y2) { + const dx1 = Math.ceil(cx + x1); + const dy1 = Math.ceil(cy + y1); + const dx2 = Math.ceil(cx + x2); + const dy2 = Math.ceil(cy + y2); + + const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2); + + const lastColor = hex2rgba(mainColor, 0); + + gradient.addColorStop(0, mainColor); + gradient.addColorStop(0.6, mainColor); + gradient.addColorStop(1, lastColor); + + this.context.beginPath(); + this.context.strokeStyle = gradient; + this.context.lineWidth = 2; + this.context.moveTo(dx1, dy1); + this.context.lineTo(dx2, dy2); + this.context.stroke(); + } + + getTicks (count, size, radius, scaleCoefficient) { + const ticks = this.getTickPoints(count); + const lesser = 200; + const m = []; + const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0; + const frequencyData = new Uint8Array(bufferLength); + const allScales = []; + + if (this.analyser) { + this.analyser.getByteFrequencyData(frequencyData); + } + + ticks.forEach((tick, i) => { + const coef = 1 - i / (ticks.length * 2.5); + + let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient; + + if (delta < 0) { + delta = 0; + } + + const k = radius / (radius - (size + delta)); + + const x1 = tick.x * (radius - size); + const y1 = tick.y * (radius - size); + const x2 = x1 * k; + const y2 = y1 * k; + + m.push({ x1, y1, x2, y2 }); + + if (i < 20) { + let scale = delta / (200 * scaleCoefficient); + scale = scale < 1 ? 1 : scale; + allScales.push(scale); + } + }); + + const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length; + + return m.map(({ x1, y1, x2, y2 }) => ({ + x1: x1, + y1: y1, + x2: x2 * scale, + y2: y2 * scale, + })); + } + + clear (width, height) { + this.context.clearRect(0, 0, width, height); + } + + draw (cx, cy, color, radius, coefficient) { + this.context.save(); + + const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient); + + ticks.forEach(tick => { + this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2); + }); + + this.context.restore(); + } + +} From 042c32ea3bcb4f5986dcb6b39a25a8cead3bf86a Mon Sep 17 00:00:00 2001 From: Sasha Sorokin Date: Thu, 9 Jul 2020 18:01:30 +0700 Subject: [PATCH 08/11] [Glitch] Replace repetitive blurhash code with component (#14267) Port 61c07c37317f01c1ab4981826704750fe9937fe7 to glitch-soc Signed-off-by: Thibaut Girka --- .../flavours/glitch/components/blurhash.js | 61 +++++++++++++++++++ .../glitch/components/media_gallery.js | 46 ++++---------- .../account_gallery/components/media_item.js | 40 +++--------- .../glitch/features/status/components/card.js | 42 +++---------- .../flavours/glitch/features/video/index.js | 41 +++---------- 5 files changed, 102 insertions(+), 128 deletions(-) create mode 100644 app/javascript/flavours/glitch/components/blurhash.js diff --git a/app/javascript/flavours/glitch/components/blurhash.js b/app/javascript/flavours/glitch/components/blurhash.js new file mode 100644 index 0000000000..172f8c2f5e --- /dev/null +++ b/app/javascript/flavours/glitch/components/blurhash.js @@ -0,0 +1,61 @@ +// @ts-check + +import { decode } from 'blurhash'; +import React, { useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; + +/** + * @typedef BlurhashPropsBase + * @property {string} hash Hash to render + * @property {number} width + * Width of the blurred region in pixels. Defaults to 32 + * @property {number} [height] + * Height of the blurred region in pixels. Defaults to width + * @property {boolean} [dummy] + * Whether dummy mode is enabled. If enabled, nothing is rendered + * and canvas left untouched + */ + +/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */ + +/** + * Component that is used to render blurred of blurhash string + * + * @param {BlurhashProps} param1 Props of the component + * @returns Canvas which will render blurred region element to embed + */ +function Blurhash({ + hash, + width = 32, + height = width, + dummy = false, + ...canvasProps +}) { + const canvasRef = /** @type {import('react').MutableRefObject} */ (useRef()); + + useEffect(() => { + const { current: canvas } = canvasRef; + canvas.width = canvas.width; // resets canvas + + if (dummy) return; + + const pixels = decode(hash, width, height); + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(pixels, width, height); + + ctx.putImageData(imageData, 0, 0); + }, [dummy, hash, width, height]); + + return ( + + ); +} + +Blurhash.propTypes = { + hash: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + dummy: PropTypes.bool, +}; + +export default React.memo(Blurhash); diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js index 71240530ce..3a48394140 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.js +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -7,8 +7,8 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from 'flavours/glitch/util/is_mobile'; import classNames from 'classnames'; import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state'; -import { decode } from 'blurhash'; import { debounce } from 'lodash'; +import Blurhash from 'flavours/glitch/components/blurhash'; const messages = defineMessages({ hidden: { @@ -94,36 +94,6 @@ class Item extends React.PureComponent { e.stopPropagation(); } - componentDidMount () { - if (this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - componentDidUpdate (prevProps) { - if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - _decode () { - if (!useBlurhash) return; - - const hash = this.props.attachment.get('blurhash'); - const pixels = decode(hash, 32, 32); - - if (pixels) { - const ctx = this.canvas.getContext('2d'); - const imageData = new ImageData(pixels, 32, 32); - - ctx.putImageData(imageData, 0, 0); - } - } - - setCanvasRef = c => { - this.canvas = c; - } - handleImageLoad = () => { this.setState({ loaded: true }); } @@ -186,7 +156,11 @@ class Item extends React.PureComponent { return ( ); @@ -253,7 +227,13 @@ class Item extends React.PureComponent { return (
- + {visible && thumbnail}
); diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js index f1cb3f9e42..b88f23aa4d 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js +++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js @@ -1,7 +1,7 @@ -import { decode } from 'blurhash'; +import Blurhash from 'flavours/glitch/components/blurhash'; import classNames from 'classnames'; import Icon from 'flavours/glitch/components/icon'; -import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state'; +import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state'; import { isIOS } from 'flavours/glitch/util/is_mobile'; import PropTypes from 'prop-types'; import React from 'react'; @@ -21,34 +21,6 @@ export default class MediaItem extends ImmutablePureComponent { loaded: false, }; - componentDidMount () { - if (this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - componentDidUpdate (prevProps) { - if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - _decode () { - const hash = this.props.attachment.get('blurhash'); - const pixels = decode(hash, 32, 32); - - if (pixels) { - const ctx = this.canvas.getContext('2d'); - const imageData = new ImageData(pixels, 32, 32); - - ctx.putImageData(imageData, 0, 0); - } - } - - setCanvasRef = c => { - this.canvas = c; - } - handleImageLoad = () => { this.setState({ loaded: true }); } @@ -149,7 +121,13 @@ export default class MediaItem extends ImmutablePureComponent { return ( diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js index ab6398e1a5..14abe9838b 100644 --- a/app/javascript/flavours/glitch/features/status/components/card.js +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -8,7 +8,7 @@ import classnames from 'classnames'; import { decode as decodeIDNA } from 'flavours/glitch/util/idna'; import Icon from 'flavours/glitch/components/icon'; import { useBlurhash } from 'flavours/glitch/util/initial_state'; -import { decode } from 'blurhash'; +import Blurhash from 'flavours/glitch/components/blurhash'; import { debounce } from 'lodash'; const getHostname = url => { @@ -85,38 +85,12 @@ export default class Card extends React.PureComponent { componentDidMount () { window.addEventListener('resize', this.handleResize, { passive: true }); - - if (this.props.card && this.props.card.get('blurhash') && this.canvas) { - this._decode(); - } } componentWillUnmount () { window.removeEventListener('resize', this.handleResize); } - componentDidUpdate (prevProps) { - const { card } = this.props; - - if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash')) && this.canvas) { - this._decode(); - } - } - - _decode () { - if (!useBlurhash) return; - - const hash = this.props.card.get('blurhash'); - const pixels = decode(hash, 32, 32); - - if (pixels) { - const ctx = this.canvas.getContext('2d'); - const imageData = new ImageData(pixels, 32, 32); - - ctx.putImageData(imageData, 0, 0); - } - } - _setDimensions () { const width = this.node.offsetWidth; @@ -174,10 +148,6 @@ export default class Card extends React.PureComponent { } } - setCanvasRef = c => { - this.canvas = c; - } - handleImageLoad = () => { this.setState({ previewLoaded: true }); } @@ -230,7 +200,15 @@ export default class Card extends React.PureComponent { ); let embed = ''; - let canvas = ; + let canvas = ( + + ); let thumbnail = ; let spoilerButton = (