tor-browser

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

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