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