tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 );