diff --git a/assets/ui/components/ArticleBodyHtml.jsx b/assets/ui/components/ArticleBodyHtml.jsx index 00518b76..da25329c 100644 --- a/assets/ui/components/ArticleBodyHtml.jsx +++ b/assets/ui/components/ArticleBodyHtml.jsx @@ -4,62 +4,103 @@ import {get, memoize} from 'lodash'; import {formatHTML} from 'utils'; import {connect} from 'react-redux'; import {selectCopy} from '../../wire/actions'; +import DOMPurify from 'dompurify'; -/** - * using component to fix iframely loading - * https://iframely.com/docs/reactjs - */ +const fallbackDefault = 'https://scontent.fsyd3-1.fna.fbcdn.net/v/t39.30808-6/409650761_846997544097330_4773850429743120820_n.jpg?_nc_cat=106&ccb=1-7&_nc_sid=127cfc&_nc_ohc=j6x9FL3TtcoQ7kNvgF9emTy&_nc_ht=scontent.fsyd3-1.fna&_nc_gid=ALgZM2NojeFY-L80j-LAA9M&oh=00_AYC6Y4pRTB22E1bRF1fqHDMfDpkcfNmBtIrAkRxTX08xEA&oe=66D338BF'; class ArticleBodyHtml extends React.PureComponent { constructor(props) { super(props); + this.state = { + sanitizedHtml: '', + memoryUsage: null + }; this.copyClicked = this.copyClicked.bind(this); this.clickClicked = this.clickClicked.bind(this); - - // use memoize so this function is only called when `body_html` changes + this.preventContextMenu = this.preventContextMenu.bind(this); this.getBodyHTML = memoize(this._getBodyHTML.bind(this)); - this.bodyRef = React.createRef(); + this.players = new Map(); + this.memoryInterval = null; } componentDidMount() { + this.updateSanitizedHtml(); this.loadIframely(); - this.executeScripts(); + this.setupPlyrPlayers(); document.addEventListener('copy', this.copyClicked); document.addEventListener('click', this.clickClicked); + this.addContextMenuEventListeners(); + this.startMemoryUsageTracking(); } - clickClicked(event) { - if (event != null) { - const target = event.target; + componentDidUpdate(prevProps) { + if (prevProps.item !== this.props.item) { + this.updateSanitizedHtml(); + } + this.loadIframely(); + this.setupPlyrPlayers(); + this.addContextMenuEventListeners(); + } - if (target && target.tagName === 'A' && this.isLinkExternal(target.href)) { - event.preventDefault(); - event.stopPropagation(); + componentWillUnmount() { + document.removeEventListener('copy', this.copyClicked); + document.removeEventListener('click', this.clickClicked); + this.removeContextMenuEventListeners(); - // security https://mathiasbynens.github.io/rel-noopener/ - var nextWindow = window.open(); + this.players.forEach(player => player.destroy()); + this.players.clear(); - nextWindow.opener = null; - nextWindow.location.href = target.href; - } + if (this.memoryInterval) { + clearInterval(this.memoryInterval); } } - isLinkExternal(href) { - try { - const url = new URL(href); - - // Check if the hosts are different and protocol is http or https - return url.host !== window.location.host && ['http:', 'https:'].includes(url.protocol); - } catch (e) { - // will throw if string is not a valid link - return false; + startMemoryUsageTracking() { + if (window.performance && window.performance.memory) { + this.memoryInterval = setInterval(() => { + const memoryInfo = window.performance.memory; + this.setState({ + memoryUsage: { + usedJSHeapSize: memoryInfo.usedJSHeapSize / (1024 * 1024), + totalJSHeapSize: memoryInfo.totalJSHeapSize / (1024 * 1024), + jsHeapSizeLimit: memoryInfo.jsHeapSizeLimit / (1024 * 1024) + } + }); + }, 2000); } } - componentDidUpdate() { - this.loadIframely(); - this.executeScripts(); + updateSanitizedHtml() { + const item = this.props.item; + const html = this.getBodyHTML( + get(item, 'es_highlight.body_html.length', 0) > 0 ? + item.es_highlight.body_html[0] : + item.body_html + ); + this.sanitizeHtml(html); + } + + sanitizeHtml(html) { + if (!html) { + this.setState({sanitizedHtml: ''}); + return; + } + const sanitizedHtml = DOMPurify.sanitize(html, { + ADD_TAGS: ['iframe', 'video', 'audio', 'figure', 'figcaption', 'script', 'twitter-widget', 'fb:like', + 'blockquote', 'div'], + ADD_ATTR: [ + 'allow', 'allowfullscreen', 'frameborder', 'scrolling', 'src', 'width', 'height', + 'data-plyr-config', 'data-plyr', 'aria-label', 'aria-hidden', 'focusable', + 'class', 'role', 'tabindex', 'controls', 'download', 'target', + 'async', 'defer', 'data-tweet-id', 'data-href', + 'data-instgrm-captioned', 'data-instgrm-permalink', + 'data-flourish-embed', 'data-src' + ], + ALLOW_DATA_ATTR: true, + ALLOW_UNKNOWN_PROTOCOLS: true, + KEEP_CONTENT: true + }); + this.setState({sanitizedHtml}); } loadIframely() { @@ -69,71 +110,114 @@ class ArticleBodyHtml extends React.PureComponent { window.iframely.load(); } } - - executeScripts() { + setupPlyrPlayers() { const tree = this.bodyRef.current; - const loaded = []; - - if (tree == null) { + if (tree == null || window.Plyr == null) { return; } - if (window.Plyr != null) { - window.Plyr.setup('.js-player'); - } - - tree.querySelectorAll('script').forEach((s) => { - if (s.hasAttribute('src') && !loaded.includes(s.getAttribute('src'))) { - let url = s.getAttribute('src'); + tree.querySelectorAll('.js-player:not(.plyr--setup)').forEach(element => { + if (!this.players.has(element)) { + const player = new window.Plyr(element, { + seekTime: 1, + keyboard: {focused: true, global: true}, + tooltips: {controls: true, seek: true}, + captions: {active: true, language: 'auto', update: true} + }); + this.players.set(element, player); + this.checkVideoLoading(player, element.getAttribute('src')); + this.setupMovePlayback(player); + } + }); + } - loaded.push(url); + setupMovePlayback(player) { + const container = player.elements.container; + let isScrubbing = false; + let wasPaused = false; + + container.addEventListener('mousedown', startScrubbing); + document.addEventListener('mousemove', scrub); + document.addEventListener('mouseup', stopScrubbing); + + function startScrubbing(event) { + if (event.target.closest('.plyr__progress')) { + isScrubbing = true; + wasPaused = player.paused; + player.pause(); + scrub(event); + } + } - if (url.includes('twitter.com/') && window.twttr != null) { - window.twttr.widgets.load(); - return; - } + function scrub(event) { + if (!isScrubbing) return; - if (url.includes('instagram.com/') && window.instgrm != null) { - window.instgrm.Embeds.process(); - return; - } + const progress = player.elements.progress; + const rect = progress.getBoundingClientRect(); + const percent = Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1); + player.currentTime = percent * player.duration; + } - // Force Flourish to always load - if (url.includes('flourish.studio/')) { - delete window.FlourishLoaded; + function stopScrubbing() { + if (isScrubbing) { + console.log(`Playback - Current Time: ${player.currentTime.toFixed(2)}s, Duration: ${player.duration.toFixed(2)}s, Percentage: ${((player.currentTime / player.duration) * 100).toFixed(2)}%`); + isScrubbing = false; + if (!wasPaused) { + player.play(); } + } + } - if (url.startsWith('http')) { - // change https?:// to // so it uses schema of the client - url = url.substring(url.indexOf(':') + 1); - } + } + checkVideoLoading(player, videoSrc) { + if (!videoSrc || !videoSrc.startsWith('/assets/')) { + return; + } - const script = document.createElement('script'); + const startTime = Date.now(); - script.src = url; - script.async = true; + const loadHandler = () => { + const loadTime = Date.now() - startTime; - script.onload = () => { - document.body.removeChild(script); - }; + if (loadTime < 100) { + console.log(`Media ${videoSrc} from cache. Load time: ${loadTime}ms`); + } else { + console.log(`Media ${videoSrc} from network server. Load time: ${loadTime}ms`); + } - script.onerrror = (error) => { - throw new URIError('The script ' + error.target.src + 'didn\'t load.'); - }; + if (player.duration > 0) { + console.log(`Media ${videoSrc} is fully loaded. Duration: ${player.duration}s`); + } - document.body.appendChild(script); + if (player.media.videoWidth === 0 && player.media.videoHeight === 0) { + console.log("Video dimensions are not available"); + if (player.poster) { + console.log("Poster image is already set:", player.poster); + } else { + player.poster = fallbackDefault; + console.log("Poster image set to:", player.poster); + } + } else { + console.log("Video dimensions are available"); + const isFirstFrameBlack = player.media.videoWidth === 1920 && player.media.videoHeight === 1080; + if (isFirstFrameBlack) { + console.log("First frame is meaningful, Setting no fallback poster image."); + } else if (player.poster) { + console.log("Poster image is set:", player.poster); + } else { + console.log("No poster image is set. Setting fallback poster image."); + player.poster = fallbackDefault; + } } + player.off('loadeddata', loadHandler); + }; + + player.on('loadeddata', loadHandler); + player.on('error', (error) => { + console.error(`Error loading media ${videoSrc}:`, error); }); } - copyClicked() { - this.props.reportCopy(this.props.item); - } - - componentWillUnmount() { - document.removeEventListener('copy', this.copyClicked); - document.removeEventListener('click', this.clickClicked); - } _getBodyHTML(bodyHtml) { return !bodyHtml ? @@ -141,17 +225,9 @@ class ArticleBodyHtml extends React.PureComponent { this._updateImageEmbedSources(formatHTML(bodyHtml)); } - /** - * Update Image Embeds to use the Web APIs Assets endpoint - * - * @param html - The `body_html` value (could also be the ES Highlight version) - * @returns {string} - * @private - */ _updateImageEmbedSources(html) { const item = this.props.item; - // Get the list of Original Rendition IDs for all Image Associations const imageEmbedOriginalIds = Object .keys(item.associations || {}) .filter((key) => key.startsWith('editor_')) @@ -159,13 +235,9 @@ class ArticleBodyHtml extends React.PureComponent { .filter((value) => value); if (!imageEmbedOriginalIds.length) { - // This item has no Image Embeds - // return the supplied html as-is return html; } - // Create a DOM node tree from the supplied html - // We can then efficiently find and update the image sources const container = document.createElement('div'); let imageSourcesUpdated = false; @@ -173,21 +245,17 @@ class ArticleBodyHtml extends React.PureComponent { container .querySelectorAll('img,video,audio') .forEach((imageTag) => { - // Using the tag's `src` attribute, find the Original Rendition's ID const originalMediaId = imageEmbedOriginalIds.find((mediaId) => ( !imageTag.src.startsWith('/assets/') && imageTag.src.includes(mediaId)) ); if (originalMediaId) { - // We now have the Original Rendition's ID - // Use that to update the `src` attribute to use Newshub's Web API imageSourcesUpdated = true; imageTag.src = `/assets/${originalMediaId}`; } }); - // Find all Audio and Video tags and mark them up for the player container.querySelectorAll('video, audio') .forEach((vTag) => { vTag.classList.add('js-player'); @@ -195,7 +263,6 @@ class ArticleBodyHtml extends React.PureComponent { vTag.setAttribute('data-plyr-config', '{"controls": ["play-large", "play",' + '"progress", "volume", "mute", "rewind", "fast-forward", "current-time",' + '"captions", "restart", "duration"]}'); - } else { vTag.setAttribute('data-plyr-config', '{"controls": ["play-large", "play",' + '"progress", "volume", "mute", "rewind", "fast-forward", "current-time",' + @@ -203,35 +270,86 @@ class ArticleBodyHtml extends React.PureComponent { '"' + vTag.getAttribute('src') + '?item_id=' + item._id + '"' + '}}'); } - imageSourcesUpdated = true; }); - - // If Image tags were not updated, then return the supplied html as-is return imageSourcesUpdated ? container.innerHTML : html; } - render() { - const item = this.props.item; - const html = this.getBodyHTML( - get(item, 'es_highlight.body_html.length', 0) > 0 ? - item.es_highlight.body_html[0] : - item.body_html - ); + clickClicked(event) { + if (event != null) { + const target = event.target; + if (target && target.tagName === 'A' && this.isLinkExternal(target.href)) { + event.preventDefault(); + event.stopPropagation(); - if (!html) { + const nextWindow = window.open(target.href, '_blank', 'noopener'); + + if (nextWindow) { + nextWindow.opener = null; + } + } + } + } + + isLinkExternal(href) { + try { + const url = new URL(href); + return url.host !== window.location.host && ['http:', 'https:'].includes(url.protocol); + } catch (e) { + return false; + } + } + + copyClicked() { + this.props.reportCopy(this.props.item); + } + + addContextMenuEventListeners() { + const tree = this.bodyRef.current; + if (tree) { + tree.querySelectorAll('[data-disable-download="true"]').forEach((element) => { + element.addEventListener('contextmenu', this.preventContextMenu); + }); + } + } + + removeContextMenuEventListeners() { + const tree = this.bodyRef.current; + if (tree) { + tree.querySelectorAll('[data-disable-download="true"]').forEach((element) => { + element.removeEventListener('contextmenu', this.preventContextMenu); + }); + } + } + + preventContextMenu(event) { + event.preventDefault(); + } + + render() { + if (!this.state.sanitizedHtml) { return null; } return ( -
+
+
+ {this.state.memoryUsage && ( +
+

Memory Usage

+

Used JS Heap: {this.state.memoryUsage.usedJSHeapSize.toFixed(2)} MB

+

Total JS Heap: {this.state.memoryUsage.totalJSHeapSize.toFixed(2)} MB

+

JS Heap Limit: {this.state.memoryUsage.jsHeapSizeLimit.toFixed(2)} MB

+
+ )} +
); } }