DSImage.jsx (8103B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import React from "react"; 6 7 const PLACEHOLDER_IMAGE_DATA_ARRAY = [ 8 { 9 rotation: "0deg", 10 offsetx: "20px", 11 offsety: "8px", 12 scale: "45%", 13 }, 14 { 15 rotation: "54deg", 16 offsetx: "-26px", 17 offsety: "62px", 18 scale: "55%", 19 }, 20 { 21 rotation: "-30deg", 22 offsetx: "78px", 23 offsety: "30px", 24 scale: "68%", 25 }, 26 { 27 rotation: "-22deg", 28 offsetx: "0", 29 offsety: "92px", 30 scale: "60%", 31 }, 32 { 33 rotation: "-65deg", 34 offsetx: "66px", 35 offsety: "28px", 36 scale: "60%", 37 }, 38 { 39 rotation: "22deg", 40 offsetx: "-35px", 41 offsety: "62px", 42 scale: "52%", 43 }, 44 { 45 rotation: "-25deg", 46 offsetx: "86px", 47 offsety: "-15px", 48 scale: "68%", 49 }, 50 ]; 51 52 const PLACEHOLDER_IMAGE_COLORS_ARRAY = 53 "#0090ED #FF4F5F #2AC3A2 #FF7139 #A172FF #FFA437 #FF2A8A".split(" "); 54 55 function generateIndex({ keyCode, max }) { 56 if (!keyCode) { 57 // Just grab a random index if we cannot generate an index from a key. 58 return Math.floor(Math.random() * max); 59 } 60 61 const hashStr = str => { 62 let hash = 0; 63 for (let i = 0; i < str.length; i++) { 64 let charCode = str.charCodeAt(i); 65 hash += charCode; 66 } 67 return hash; 68 }; 69 70 const hash = hashStr(keyCode); 71 return hash % max; 72 } 73 74 export function PlaceholderImage({ urlKey, titleKey }) { 75 const dataIndex = generateIndex({ 76 keyCode: urlKey, 77 max: PLACEHOLDER_IMAGE_DATA_ARRAY.length, 78 }); 79 const colorIndex = generateIndex({ 80 keyCode: titleKey, 81 max: PLACEHOLDER_IMAGE_COLORS_ARRAY.length, 82 }); 83 const { rotation, offsetx, offsety, scale } = 84 PLACEHOLDER_IMAGE_DATA_ARRAY[dataIndex]; 85 const color = PLACEHOLDER_IMAGE_COLORS_ARRAY[colorIndex]; 86 const style = { 87 "--placeholderBackgroundColor": color, 88 "--placeholderBackgroundRotation": rotation, 89 "--placeholderBackgroundOffsetx": offsetx, 90 "--placeholderBackgroundOffsety": offsety, 91 "--placeholderBackgroundScale": scale, 92 }; 93 94 return <div style={style} className="placeholder-image" />; 95 } 96 97 export class DSImage extends React.PureComponent { 98 constructor(props) { 99 super(props); 100 101 this.onOptimizedImageError = this.onOptimizedImageError.bind(this); 102 this.onNonOptimizedImageError = this.onNonOptimizedImageError.bind(this); 103 this.onLoad = this.onLoad.bind(this); 104 105 this.state = { 106 isLoaded: false, 107 optimizedImageFailed: false, 108 useTransition: false, 109 }; 110 } 111 112 onIdleCallback() { 113 if (!this.state.isLoaded) { 114 this.setState({ 115 useTransition: true, 116 }); 117 } 118 } 119 120 // Wraps the image url with the Pocket proxy to both resize and crop the image. 121 reformatImageURL(url, width, height) { 122 const smart = this.props.smartCrop ? "smart/" : ""; 123 // Change the image URL to request a size tailored for the parent container width 124 // Also: force JPEG, quality 60, no upscaling, no EXIF data 125 // Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html 126 const formattedUrl = `https://img-getpocket.cdn.mozilla.net/${width}x${height}/${smart}filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent( 127 url 128 )}`; 129 return this.secureImageURL(formattedUrl); 130 } 131 132 // Wraps the image URL with the moz-cached-ohttp:// protocol. 133 // This enables Firefox to load resources over Oblivious HTTP (OHTTP), 134 // providing privacy-preserving resource loading. 135 // Applied only when inferred personalization is enabled. 136 // See: https://firefox-source-docs.mozilla.org/browser/components/mozcachedohttp/docs/index.html 137 secureImageURL(url) { 138 if (!this.props.secureImage) { 139 return url; 140 } 141 return `moz-cached-ohttp://newtab-image/?url=${encodeURIComponent(url)}`; 142 } 143 144 componentDidMount() { 145 this.idleCallbackId = this.props.windowObj.requestIdleCallback( 146 this.onIdleCallback.bind(this) 147 ); 148 } 149 150 componentWillUnmount() { 151 if (this.idleCallbackId) { 152 this.props.windowObj.cancelIdleCallback(this.idleCallbackId); 153 } 154 } 155 156 render() { 157 let classNames = `ds-image 158 ${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``} 159 ${this.state && this.state.useTransition ? ` use-transition` : ``} 160 ${this.state && this.state.isLoaded ? ` loaded` : ``} 161 `; 162 163 let img; 164 165 if (this.state) { 166 if ( 167 this.props.optimize && 168 this.props.rawSource && 169 !this.state.optimizedImageFailed 170 ) { 171 const baseSource = this.props.rawSource; 172 173 // We don't care about securing this.props.source, as this exclusivly 174 // comes from an older service that is not personalized. 175 // This can also return a non secure url if this functionality is not enabled. 176 const securedSource = this.secureImageURL(baseSource); 177 178 let sizeRules = []; 179 let srcSetRules = []; 180 181 for (let rule of this.props.sizes) { 182 let { mediaMatcher, width, height } = rule; 183 let sizeRule = `${mediaMatcher} ${width}px`; 184 sizeRules.push(sizeRule); 185 let srcSetRule = `${this.reformatImageURL( 186 baseSource, 187 width, 188 height 189 )} ${width}w`; 190 let srcSetRule2x = `${this.reformatImageURL( 191 baseSource, 192 width * 2, 193 height * 2 194 )} ${width * 2}w`; 195 srcSetRules.push(srcSetRule); 196 srcSetRules.push(srcSetRule2x); 197 } 198 199 if (this.props.sizes.length) { 200 // We have to supply a fallback in the very unlikely event that none of 201 // the media queries match. The smallest dimension was chosen arbitrarily. 202 sizeRules.push( 203 `${this.props.sizes[this.props.sizes.length - 1].width}px` 204 ); 205 } 206 207 img = ( 208 <img 209 loading="lazy" 210 alt={this.props.alt_text} 211 crossOrigin="anonymous" 212 onLoad={this.onLoad} 213 onError={this.onOptimizedImageError} 214 sizes={sizeRules.join(",")} 215 src={securedSource} 216 srcSet={srcSetRules.join(",")} 217 /> 218 ); 219 } else if (this.props.source && !this.state.nonOptimizedImageFailed) { 220 img = ( 221 <img 222 loading="lazy" 223 alt={this.props.alt_text} 224 crossOrigin="anonymous" 225 onLoad={this.onLoad} 226 onError={this.onNonOptimizedImageError} 227 src={this.props.source} 228 /> 229 ); 230 } else { 231 // We consider a failed to load img or source without an image as loaded. 232 classNames = `${classNames} loaded`; 233 // Remove the img element if we have no source. Render a placeholder instead. 234 // This only happens for recent saves without a source. 235 if ( 236 this.props.isRecentSave && 237 !this.props.rawSource && 238 !this.props.source 239 ) { 240 img = ( 241 <PlaceholderImage 242 urlKey={this.props.url} 243 titleKey={this.props.title} 244 /> 245 ); 246 } else { 247 img = <div className="broken-image" />; 248 } 249 } 250 } 251 252 return <picture className={classNames}>{img}</picture>; 253 } 254 255 onOptimizedImageError() { 256 // This will trigger a re-render and the unoptimized 450px image will be used as a fallback 257 this.setState({ 258 optimizedImageFailed: true, 259 }); 260 } 261 262 onNonOptimizedImageError() { 263 this.setState({ 264 nonOptimizedImageFailed: true, 265 }); 266 } 267 268 onLoad() { 269 this.setState({ 270 isLoaded: true, 271 }); 272 } 273 } 274 275 DSImage.defaultProps = { 276 source: null, // The current source style from Pocket API (always 450px) 277 rawSource: null, // Unadulterated image URL to filter through Thumbor 278 extraClassNames: null, // Additional classnames to append to component 279 optimize: true, // Measure parent container to request exact sizes 280 alt_text: null, 281 windowObj: window, // Added to support unit tests 282 sizes: [], 283 };