tor-browser

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

selectLayoutRender.mjs (11153B)


      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 export const selectLayoutRender = ({ state = {}, prefs = {} }) => {
      6  const { layout, feeds, spocs } = state;
      7  let spocIndexPlacementMap = {};
      8 
      9  /* This function fills spoc positions on a per placement basis with available spocs.
     10   * It does this by looping through each position for a placement and replacing a rec with a spoc.
     11   * If it runs out of spocs or positions, it stops.
     12   * If it sees the same placement again, it remembers the previous spoc index, and continues.
     13   * If it sees a blocked spoc, it skips that position leaving in a regular story.
     14   */
     15  function fillSpocPositionsForPlacement(
     16    data,
     17    spocsPositions,
     18    spocsData,
     19    placementName
     20  ) {
     21    if (
     22      !spocIndexPlacementMap[placementName] &&
     23      spocIndexPlacementMap[placementName] !== 0
     24    ) {
     25      spocIndexPlacementMap[placementName] = 0;
     26    }
     27    const results = [...data];
     28    for (let position of spocsPositions) {
     29      const spoc = spocsData[spocIndexPlacementMap[placementName]];
     30      // If there are no spocs left, we can stop filling positions.
     31      if (!spoc) {
     32        break;
     33      }
     34 
     35      // A placement could be used in two sections.
     36      // In these cases, we want to maintain the index of the previous section.
     37      // If we didn't do this, it might duplicate spocs.
     38      spocIndexPlacementMap[placementName]++;
     39 
     40      // A spoc that's blocked is removed from the source for subsequent newtab loads.
     41      // If we have a spoc in the source that's blocked, it means it was *just* blocked,
     42      // and in this case, we skip this position, and show a regular spoc instead.
     43      if (!spocs.blocked.includes(spoc.url)) {
     44        results.splice(position.index, 0, spoc);
     45      }
     46    }
     47 
     48    return results;
     49  }
     50 
     51  const positions = {};
     52  const DS_COMPONENTS = [
     53    "Message",
     54    "SectionTitle",
     55    "Navigation",
     56    "Widgets",
     57    "CardGrid",
     58    "HorizontalRule",
     59    "PrivacyLink",
     60  ];
     61 
     62  const filterArray = [];
     63 
     64  // Filter sections is Topsites are turned off
     65  if (!prefs["feeds.topsites"]) {
     66    filterArray.push("TopSites");
     67  }
     68 
     69  // Filter sections is Widgets are turned off
     70  // Note extra logic is required bc this feature can be enabled via Nimbus
     71  const nimbusWidgetsTrainhopEnabled = prefs.trainhopConfig?.widgets?.enabled;
     72  const nimbusWidgetsEnabled = prefs.widgetsConfig?.enabled;
     73  const widgetsEnabled = prefs["widgets.system.enabled"];
     74  if (
     75    !nimbusWidgetsTrainhopEnabled &&
     76    !nimbusWidgetsEnabled &&
     77    !widgetsEnabled
     78  ) {
     79    filterArray.push("Widgets");
     80  }
     81 
     82  // Filter sections is Recommended Stories are turned off
     83  const pocketEnabled =
     84    prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"];
     85  if (!pocketEnabled) {
     86    filterArray.push(
     87      // Bug 1980459 - Do not remove Widgets if DS is disabled
     88      ...DS_COMPONENTS.filter(component => component !== "Widgets")
     89    );
     90  }
     91 
     92  // function to determine amount of tiles shown per section per viewport
     93  function getMaxTiles(responsiveLayouts) {
     94    return responsiveLayouts
     95      .flatMap(responsiveLayout => responsiveLayout)
     96      .reduce((acc, t) => {
     97        acc[t.columnCount] = t.tiles.length;
     98 
     99        // Update maxTile if current tile count is greater
    100        if (!acc.maxTile || t.tiles.length > acc.maxTile) {
    101          acc.maxTile = t.tiles.length;
    102        }
    103        return acc;
    104      }, {});
    105  }
    106 
    107  const placeholderComponent = component => {
    108    if (!component.feed) {
    109      // TODO we now need a placeholder for topsites.
    110      return {
    111        ...component,
    112        data: {
    113          spocs: [],
    114        },
    115      };
    116    }
    117    const data = {
    118      recommendations: [],
    119      sections: [
    120        {
    121          layout: {
    122            responsiveLayouts: [],
    123          },
    124          data: [],
    125        },
    126      ],
    127    };
    128 
    129    let items = 0;
    130    if (component.properties && component.properties.items) {
    131      items = component.properties.items;
    132    }
    133    for (let i = 0; i < items; i++) {
    134      data.recommendations.push({ placeholder: true });
    135    }
    136 
    137    const sectionsEnabled = prefs["discoverystream.sections.enabled"];
    138    if (sectionsEnabled) {
    139      for (let i = 0; i < items; i++) {
    140        data.sections[0].data.push({ placeholder: true });
    141      }
    142    }
    143 
    144    return { ...component, data };
    145  };
    146 
    147  // TODO update devtools to show placements
    148  const handleSpocs = (data = [], spocsPositions, spocsPlacement) => {
    149    let result = [...data];
    150    // Do we ever expect to possibly have a spoc.
    151    if (spocsPositions?.length) {
    152      const placement = spocsPlacement || {};
    153      const placementName = placement.name || "newtab_spocs";
    154      const spocsData = spocs.data[placementName];
    155 
    156      // We expect a spoc, spocs are loaded, and the server returned spocs.
    157      if (spocs.loaded && spocsData?.items?.length) {
    158        // Since banner-type ads are placed by row and don't use the normal spoc position,
    159        // dont combine with content
    160        const excludedSpocs = ["billboard", "leaderboard"];
    161        const filteredSpocs = spocsData?.items?.filter(
    162          item => !excludedSpocs.includes(item.format)
    163        );
    164        result = fillSpocPositionsForPlacement(
    165          result,
    166          spocsPositions,
    167          filteredSpocs,
    168          placementName
    169        );
    170      }
    171    }
    172    return result;
    173  };
    174 
    175  const handleSections = (sections = [], recommendations = []) => {
    176    let result = sections.sort((a, b) => a.receivedRank - b.receivedRank);
    177 
    178    const sectionsMap = recommendations.reduce((acc, recommendation) => {
    179      const { section } = recommendation;
    180      acc[section] = acc[section] || [];
    181      acc[section].push(recommendation);
    182      return acc;
    183    }, {});
    184 
    185    result.forEach(section => {
    186      const { sectionKey } = section;
    187      section.data = sectionsMap[sectionKey];
    188    });
    189 
    190    return result;
    191  };
    192 
    193  const handleComponent = component => {
    194    if (component?.spocs?.positions?.length) {
    195      const placement = component.placement || {};
    196      const placementName = placement.name || "newtab_spocs";
    197      const spocsData = spocs.data[placementName];
    198      if (spocs.loaded && spocsData?.items?.length) {
    199        return {
    200          ...component,
    201          data: {
    202            spocs: spocsData.items
    203              .filter(spoc => spoc && !spocs.blocked.includes(spoc.url))
    204              .map((spoc, index) => ({
    205                ...spoc,
    206                pos: index,
    207              })),
    208          },
    209        };
    210      }
    211    }
    212    return {
    213      ...component,
    214      data: {
    215        spocs: [],
    216      },
    217    };
    218  };
    219 
    220  const handleComponentWithFeed = component => {
    221    positions[component.type] = positions[component.type] || 0;
    222    let data = {
    223      recommendations: [],
    224      sections: [],
    225    };
    226 
    227    const feed = feeds.data[component.feed.url];
    228    if (feed?.data) {
    229      data = {
    230        ...feed.data,
    231        recommendations: [...(feed.data.recommendations || [])],
    232        sections: [...(feed.data.sections || [])],
    233      };
    234    }
    235 
    236    if (component && component.properties && component.properties.offset) {
    237      data = {
    238        ...data,
    239        recommendations: data.recommendations.slice(
    240          component.properties.offset
    241        ),
    242      };
    243    }
    244    const spocsPositions = component?.spocs?.positions;
    245    const spocsPlacement = component?.placement;
    246 
    247    const sectionsEnabled = prefs["discoverystream.sections.enabled"];
    248    data = {
    249      ...data,
    250      ...(sectionsEnabled
    251        ? {
    252            sections: handleSections(data.sections, data.recommendations).map(
    253              section => {
    254                const sectionsSpocsPositions = [];
    255                section.layout.responsiveLayouts
    256                  // Initial position for spocs is going to be for the smallest breakpoint.
    257                  // We can then move it from there via breakpoints.
    258                  .find(item => item.columnCount === 1)
    259                  .tiles.forEach(tile => {
    260                    if (tile.hasAd) {
    261                      sectionsSpocsPositions.push({ index: tile.position });
    262                    }
    263                  });
    264                return {
    265                  ...section,
    266                  data: handleSpocs(
    267                    section.data,
    268                    sectionsSpocsPositions,
    269                    spocsPlacement
    270                  ),
    271                };
    272              }
    273            ),
    274            // We don't fill spocs in recs if sections are enabled,
    275            // because recs are not going to be seen.
    276            recommendations: data.recommendations,
    277          }
    278        : {
    279            recommendations: handleSpocs(
    280              data.recommendations,
    281              spocsPositions,
    282              spocsPlacement
    283            ),
    284          }),
    285    };
    286 
    287    let items = 0;
    288    if (component.properties && component.properties.items) {
    289      items = Math.min(component.properties.items, data.recommendations.length);
    290    }
    291 
    292    // loop through a component items
    293    // Store the items position sequentially for multiple components of the same type.
    294    // Example: A second card grid starts pos offset from the last card grid.
    295    for (let i = 0; i < items; i++) {
    296      data.recommendations[i] = {
    297        ...data.recommendations[i],
    298        pos: positions[component.type]++,
    299      };
    300    }
    301 
    302    // Setup absolute positions for sections layout.
    303    if (sectionsEnabled) {
    304      let currentPosition = 0;
    305      data.sections.forEach(section => {
    306        // We assume the count for the breakpoint with the most tiles.
    307        const { maxTile } = getMaxTiles(section?.layout?.responsiveLayouts);
    308        for (let i = 0; i < maxTile; i++) {
    309          if (section.data[i]) {
    310            section.data[i] = {
    311              ...section.data[i],
    312              pos: currentPosition++,
    313            };
    314          }
    315        }
    316      });
    317    }
    318 
    319    return { ...component, data };
    320  };
    321 
    322  const renderLayout = () => {
    323    const renderedLayoutArray = [];
    324    for (const row of layout.filter(
    325      r => r.components.filter(c => !filterArray.includes(c.type)).length
    326    )) {
    327      let components = [];
    328      renderedLayoutArray.push({
    329        ...row,
    330        components,
    331      });
    332      for (const component of row.components.filter(
    333        c => !filterArray.includes(c.type)
    334      )) {
    335        const spocsConfig = component.spocs;
    336        if (spocsConfig || component.feed) {
    337          if (
    338            (component.feed && !feeds.data[component.feed.url]) ||
    339            (spocsConfig &&
    340              spocsConfig.positions &&
    341              spocsConfig.positions.length &&
    342              !spocs.loaded)
    343          ) {
    344            components.push(placeholderComponent(component));
    345          } else if (component.feed) {
    346            components.push(handleComponentWithFeed(component));
    347          } else {
    348            components.push(handleComponent(component));
    349          }
    350        } else {
    351          components.push(component);
    352        }
    353      }
    354    }
    355    return renderedLayoutArray;
    356  };
    357 
    358  const layoutRender = renderLayout();
    359 
    360  return { layoutRender };
    361 };