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 5import { actionCreators as ac } from "common/Actions.jsm"; 6import { connect } from "react-redux"; 7import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx"; 8import { DSImage } from "../DSImage/DSImage.jsx"; 9import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu"; 10import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; 11import React from "react"; 12import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; 13import { DSContextFooter } from "../DSContextFooter/DSContextFooter.jsx"; 14 15/** 16 * @note exported for testing only 17 */ 18export class ListItem extends React.PureComponent { 19 // TODO performance: get feeds to send appropriately sized images rather 20 // than waiting longer and scaling down on client? 21 constructor(props) { 22 super(props); 23 this.onLinkClick = this.onLinkClick.bind(this); 24 } 25 26 onLinkClick(event) { 27 if (this.props.dispatch) { 28 this.props.dispatch( 29 ac.UserEvent({ 30 event: "CLICK", 31 source: this.props.type.toUpperCase(), 32 action_position: this.props.pos, 33 value: { card_type: this.props.flightId ? "spoc" : "organic" }, 34 }) 35 ); 36 37 this.props.dispatch( 38 ac.ImpressionStats({ 39 source: this.props.type.toUpperCase(), 40 click: 0, 41 tiles: [ 42 { 43 id: this.props.id, 44 pos: this.props.pos, 45 ...(this.props.shim && this.props.shim.click 46 ? { shim: this.props.shim.click } 47 : {}), 48 }, 49 ], 50 }) 51 ); 52 } 53 } 54 55 render() { 56 return ( 57 <li 58 className={`ds-list-item${ 59 this.props.placeholder ? " placeholder" : "" 60 }`} 61 > 62 <SafeAnchor 63 className="ds-list-item-link" 64 dispatch={this.props.dispatch} 65 onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined} 66 url={this.props.url} 67 > 68 <div className="ds-list-item-text"> 69 <p> 70 <span className="ds-list-item-info clamp"> 71 {this.props.domain} 72 </span> 73 </p> 74 <div className="ds-list-item-body"> 75 <div className="ds-list-item-title clamp">{this.props.title}</div> 76 {this.props.excerpt && ( 77 <div className="ds-list-item-excerpt clamp"> 78 {this.props.excerpt} 79 </div> 80 )} 81 </div> 82 <DSContextFooter 83 context={this.props.context} 84 context_type={this.props.context_type} 85 engagement={this.props.engagement} 86 /> 87 </div> 88 <DSImage 89 extraClassNames="ds-list-image" 90 source={this.props.image_src} 91 rawSource={this.props.raw_image_src} 92 /> 93 <ImpressionStats 94 flightId={this.props.flightId} 95 rows={[ 96 { 97 id: this.props.id, 98 pos: this.props.pos, 99 ...(this.props.shim && this.props.shim.impression 100 ? { shim: this.props.shim.impression } 101 : {}), 102 }, 103 ]} 104 dispatch={this.props.dispatch} 105 source={this.props.type} 106 /> 107 </SafeAnchor> 108 {!this.props.placeholder && ( 109 <DSLinkMenu 110 id={this.props.id} 111 index={this.props.pos} 112 dispatch={this.props.dispatch} 113 url={this.props.url} 114 title={this.props.title} 115 source={this.props.source} 116 type={this.props.type} 117 pocket_id={this.props.pocket_id} 118 shim={this.props.shim} 119 bookmarkGuid={this.props.bookmarkGuid} 120 flightId={this.props.flightId} 121 /> 122 )} 123 </li> 124 ); 125 } 126} 127 128export const PlaceholderListItem = props => <ListItem placeholder={true} />; 129 130/** 131 * @note exported for testing only 132 */ 133export function _List(props) { 134 const renderList = () => { 135 const recs = props.data.recommendations.slice( 136 props.recStartingPoint, 137 props.recStartingPoint + props.items 138 ); 139 const recMarkup = []; 140 141 for (let index = 0; index < props.items; index++) { 142 const rec = recs[index]; 143 recMarkup.push( 144 !rec || rec.placeholder ? ( 145 <PlaceholderListItem key={`ds-list-item-${index}`} /> 146 ) : ( 147 <ListItem 148 key={`ds-list-item-${rec.id}`} 149 dispatch={props.dispatch} 150 flightId={rec.flight_id} 151 domain={rec.domain} 152 excerpt={rec.excerpt} 153 id={rec.id} 154 shim={rec.shim} 155 image_src={rec.image_src} 156 raw_image_src={rec.raw_image_src} 157 pos={rec.pos} 158 title={rec.title} 159 context={rec.context} 160 context_type={rec.context_type} 161 type={props.type} 162 url={rec.url} 163 pocket_id={rec.pocket_id} 164 bookmarkGuid={rec.bookmarkGuid} 165 engagement={rec.engagement} 166 /> 167 ) 168 ); 169 } 170 171 const listStyles = [ 172 "ds-list", 173 props.fullWidth ? "ds-list-full-width" : "", 174 props.hasBorders ? "ds-list-borders" : "", 175 props.hasImages ? "ds-list-images" : "", 176 props.hasNumbers ? "ds-list-numbers" : "", 177 ]; 178 179 return <ul className={listStyles.join(" ")}>{recMarkup}</ul>; 180 }; 181 182 const { data } = props; 183 if (!data || !data.recommendations) { 184 return null; 185 } 186 187 // Handle the case where a user has dismissed all recommendations 188 const isEmpty = data.recommendations.length === 0; 189 190 return ( 191 <div> 192 {props.header && props.header.title ? ( 193 <div className="ds-header">{props.header.title}</div> 194 ) : null} 195 {isEmpty ? ( 196 <div className="ds-list empty"> 197 <DSEmptyState 198 status={data.status} 199 dispatch={props.dispatch} 200 feed={props.feed} 201 /> 202 </div> 203 ) : ( 204 renderList() 205 )} 206 </div> 207 ); 208} 209 210_List.defaultProps = { 211 recStartingPoint: 0, // Index of recommendations to start displaying from 212 fullWidth: false, // Display items taking up the whole column 213 hasBorders: false, // Display lines separating each item 214 hasImages: false, // Display images for each item 215 hasNumbers: false, // Display numbers for each item 216 items: 6, // Number of stories to display. TODO: get from endpoint 217}; 218 219export const List = connect(state => ({ 220 DiscoveryStream: state.DiscoveryStream, 221}))(_List); 222