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