Card.jsx (11422B)
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 { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; 6 import { cardContextTypes } from "./types"; 7 import { connect } from "react-redux"; 8 import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; 9 import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; 10 import React from "react"; 11 import { ScreenshotUtils } from "content-src/lib/screenshot-utils"; 12 13 // Keep track of pending image loads to only request once 14 const gImageLoading = new Map(); 15 16 /** 17 * Card component. 18 * Cards are found within a Section component and contain information about a link such 19 * as preview image, page title, page description, and some context about if the page 20 * was visited, bookmarked, trending etc... 21 * Each Section can make an unordered list of Cards which will create one instane of 22 * this class. Each card will then get a context menu which reflects the actions that 23 * can be done on this Card. 24 */ 25 export class _Card extends React.PureComponent { 26 constructor(props) { 27 super(props); 28 this.state = { 29 activeCard: null, 30 imageLoaded: false, 31 cardImage: null, 32 }; 33 this.onMenuButtonUpdate = this.onMenuButtonUpdate.bind(this); 34 this.onLinkClick = this.onLinkClick.bind(this); 35 } 36 37 /** 38 * Helper to conditionally load an image and update state when it loads. 39 */ 40 async maybeLoadImage() { 41 // No need to load if it's already loaded or no image 42 const { cardImage } = this.state; 43 if (!cardImage) { 44 return; 45 } 46 47 const imageUrl = cardImage.url; 48 if (!this.state.imageLoaded) { 49 // Initialize a promise to share a load across multiple card updates 50 if (!gImageLoading.has(imageUrl)) { 51 const loaderPromise = new Promise((resolve, reject) => { 52 const loader = new Image(); 53 loader.addEventListener("load", resolve); 54 loader.addEventListener("error", reject); 55 loader.src = imageUrl; 56 }); 57 58 // Save and remove the promise only while it's pending 59 gImageLoading.set(imageUrl, loaderPromise); 60 loaderPromise 61 .catch(ex => ex) 62 .then(() => gImageLoading.delete(imageUrl)); 63 } 64 65 // Wait for the image whether just started loading or reused promise 66 try { 67 await gImageLoading.get(imageUrl); 68 } catch (ex) { 69 // Ignore the failed image without changing state 70 return; 71 } 72 73 // Only update state if we're still waiting to load the original image 74 if ( 75 ScreenshotUtils.isRemoteImageLocal( 76 this.state.cardImage, 77 this.props.link.image 78 ) && 79 !this.state.imageLoaded 80 ) { 81 this.setState({ imageLoaded: true }); 82 } 83 } 84 } 85 86 /** 87 * Helper to obtain the next state based on nextProps and prevState. 88 * 89 * NOTE: Rename this method to getDerivedStateFromProps when we update React 90 * to >= 16.3. We will need to update tests as well. We cannot rename this 91 * method to getDerivedStateFromProps now because there is a mismatch in 92 * the React version that we are using for both testing and production. 93 * (i.e. react-test-render => "16.3.2", react => "16.2.0"). 94 * 95 * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43. 96 */ 97 static getNextStateFromProps(nextProps, prevState) { 98 const { image } = nextProps.link; 99 const imageInState = ScreenshotUtils.isRemoteImageLocal( 100 prevState.cardImage, 101 image 102 ); 103 let nextState = null; 104 105 // Image is updating. 106 if (!imageInState && nextProps.link) { 107 nextState = { imageLoaded: false }; 108 } 109 110 if (imageInState) { 111 return nextState; 112 } 113 114 // Since image was updated, attempt to revoke old image blob URL, if it exists. 115 ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.cardImage); 116 117 nextState = nextState || {}; 118 nextState.cardImage = ScreenshotUtils.createLocalImageObject(image); 119 120 return nextState; 121 } 122 123 onMenuButtonUpdate(isOpen) { 124 if (isOpen) { 125 this.setState({ activeCard: this.props.index }); 126 } else { 127 this.setState({ activeCard: null }); 128 } 129 } 130 131 /** 132 * Report to telemetry additional information about the item. 133 */ 134 _getTelemetryInfo() { 135 // Filter out "history" type for being the default 136 if (this.props.link.type !== "history") { 137 return { value: { card_type: this.props.link.type } }; 138 } 139 140 return null; 141 } 142 143 onLinkClick(event) { 144 event.preventDefault(); 145 const { altKey, button, ctrlKey, metaKey, shiftKey } = event; 146 if (this.props.link.type === "download") { 147 this.props.dispatch( 148 ac.OnlyToMain({ 149 type: at.OPEN_DOWNLOAD_FILE, 150 data: Object.assign(this.props.link, { 151 event: { button, ctrlKey, metaKey, shiftKey }, 152 }), 153 }) 154 ); 155 } else { 156 this.props.dispatch( 157 ac.OnlyToMain({ 158 type: at.OPEN_LINK, 159 data: Object.assign(this.props.link, { 160 event: { altKey, button, ctrlKey, metaKey, shiftKey }, 161 }), 162 }) 163 ); 164 } 165 if (this.props.isWebExtension) { 166 this.props.dispatch( 167 ac.WebExtEvent(at.WEBEXT_CLICK, { 168 source: this.props.eventSource, 169 url: this.props.link.url, 170 action_position: this.props.index, 171 }) 172 ); 173 } else { 174 this.props.dispatch( 175 ac.UserEvent( 176 Object.assign( 177 { 178 event: "CLICK", 179 source: this.props.eventSource, 180 action_position: this.props.index, 181 }, 182 this._getTelemetryInfo() 183 ) 184 ) 185 ); 186 187 if (this.props.shouldSendImpressionStats) { 188 this.props.dispatch( 189 ac.ImpressionStats({ 190 source: this.props.eventSource, 191 click: 0, 192 tiles: [{ id: this.props.link.guid, pos: this.props.index }], 193 }) 194 ); 195 } 196 } 197 } 198 199 componentDidMount() { 200 this.maybeLoadImage(); 201 } 202 203 componentDidUpdate() { 204 this.maybeLoadImage(); 205 } 206 207 // NOTE: Remove this function when we update React to >= 16.3 since React will 208 // call getDerivedStateFromProps automatically. We will also need to 209 // rename getNextStateFromProps to getDerivedStateFromProps. 210 componentWillMount() { 211 const nextState = _Card.getNextStateFromProps(this.props, this.state); 212 if (nextState) { 213 this.setState(nextState); 214 } 215 } 216 217 // NOTE: Remove this function when we update React to >= 16.3 since React will 218 // call getDerivedStateFromProps automatically. We will also need to 219 // rename getNextStateFromProps to getDerivedStateFromProps. 220 componentWillReceiveProps(nextProps) { 221 const nextState = _Card.getNextStateFromProps(nextProps, this.state); 222 if (nextState) { 223 this.setState(nextState); 224 } 225 } 226 227 componentWillUnmount() { 228 ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.cardImage); 229 } 230 231 render() { 232 const { 233 index, 234 className, 235 link, 236 dispatch, 237 contextMenuOptions, 238 eventSource, 239 shouldSendImpressionStats, 240 } = this.props; 241 const { props } = this; 242 const title = link.title || link.hostname; 243 const isContextMenuOpen = this.state.activeCard === index; 244 // Display "now" as "trending" until we have new strings #3402 245 const { icon, fluentID } = 246 cardContextTypes[link.type === "now" ? "trending" : link.type] || {}; 247 const hasImage = this.state.cardImage || link.hasImage; 248 const imageStyle = { 249 backgroundImage: this.state.cardImage 250 ? `url(${this.state.cardImage.url})` 251 : "none", 252 }; 253 const outerClassName = [ 254 "card-outer", 255 className, 256 isContextMenuOpen && "active", 257 props.placeholder && "placeholder", 258 ] 259 .filter(v => v) 260 .join(" "); 261 262 return ( 263 <li className={outerClassName}> 264 <a 265 href={link.type === "pocket" ? link.open_url : link.url} 266 onClick={!props.placeholder ? this.onLinkClick : undefined} 267 > 268 <div className="card"> 269 <div className="card-preview-image-outer"> 270 {hasImage && ( 271 <div 272 className={`card-preview-image${ 273 this.state.imageLoaded ? " loaded" : "" 274 }`} 275 style={imageStyle} 276 /> 277 )} 278 </div> 279 <div className="card-details"> 280 {link.type === "download" && ( 281 <div 282 className="card-host-name alternate" 283 data-l10n-id="newtab-menu-open-file" 284 /> 285 )} 286 {link.hostname && ( 287 <div className="card-host-name"> 288 {link.hostname.slice(0, 100)} 289 {link.type === "download" && ` \u2014 ${link.description}`} 290 </div> 291 )} 292 <div 293 className={[ 294 "card-text", 295 icon ? "" : "no-context", 296 link.description ? "" : "no-description", 297 link.hostname ? "" : "no-host-name", 298 ].join(" ")} 299 > 300 <h4 className="card-title" dir="auto"> 301 {link.title} 302 </h4> 303 <p className="card-description" dir="auto"> 304 {link.description} 305 </p> 306 </div> 307 <div className="card-context"> 308 {icon && !link.context && ( 309 <span 310 aria-haspopup="true" 311 className={`card-context-icon icon icon-${icon}`} 312 /> 313 )} 314 {link.icon && link.context && ( 315 <span 316 aria-haspopup="true" 317 className="card-context-icon icon" 318 style={{ backgroundImage: `url('${link.icon}')` }} 319 /> 320 )} 321 {fluentID && !link.context && ( 322 <div className="card-context-label" data-l10n-id={fluentID} /> 323 )} 324 {link.context && ( 325 <div className="card-context-label">{link.context}</div> 326 )} 327 </div> 328 </div> 329 </div> 330 </a> 331 {!props.placeholder && ( 332 <ContextMenuButton 333 tooltip="newtab-menu-content-tooltip" 334 tooltipArgs={{ title }} 335 onUpdate={this.onMenuButtonUpdate} 336 > 337 <LinkMenu 338 dispatch={dispatch} 339 index={index} 340 source={eventSource} 341 options={link.contextMenuOptions || contextMenuOptions} 342 site={link} 343 siteInfo={this._getTelemetryInfo()} 344 shouldSendImpressionStats={shouldSendImpressionStats} 345 /> 346 </ContextMenuButton> 347 )} 348 </li> 349 ); 350 } 351 } 352 _Card.defaultProps = { link: {} }; 353 export const Card = connect(state => ({ 354 platform: state.Prefs.values.platform, 355 }))(_Card); 356 export const PlaceholderCard = props => ( 357 <Card placeholder={true} className={props.className} /> 358 );