TopSitesFeed.jsm 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  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
  3. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. "use strict";
  5. const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
  6. const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
  7. const {actionCreators: ac, actionTypes: at} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
  8. const {TippyTopProvider} = ChromeUtils.import("resource://activity-stream/lib/TippyTopProvider.jsm");
  9. const {insertPinned, TOP_SITES_MAX_SITES_PER_ROW} = ChromeUtils.import("resource://activity-stream/common/Reducers.jsm");
  10. const {Dedupe} = ChromeUtils.import("resource://activity-stream/common/Dedupe.jsm");
  11. const {shortURL} = ChromeUtils.import("resource://activity-stream/lib/ShortURL.jsm");
  12. const {getDefaultOptions} = ChromeUtils.import("resource://activity-stream/lib/ActivityStreamStorage.jsm");
  13. const {
  14. CUSTOM_SEARCH_SHORTCUTS,
  15. SEARCH_SHORTCUTS_EXPERIMENT,
  16. SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF,
  17. SEARCH_SHORTCUTS_HAVE_PINNED_PREF,
  18. checkHasSearchEngine,
  19. getSearchProvider,
  20. } = ChromeUtils.import("resource://activity-stream/lib/SearchShortcuts.jsm");
  21. ChromeUtils.defineModuleGetter(this, "filterAdult",
  22. "resource://activity-stream/lib/FilterAdult.jsm");
  23. ChromeUtils.defineModuleGetter(this, "LinksCache",
  24. "resource://activity-stream/lib/LinksCache.jsm");
  25. ChromeUtils.defineModuleGetter(this, "NewTabUtils",
  26. "resource://gre/modules/NewTabUtils.jsm");
  27. ChromeUtils.defineModuleGetter(this, "Screenshots",
  28. "resource://activity-stream/lib/Screenshots.jsm");
  29. ChromeUtils.defineModuleGetter(this, "PageThumbs",
  30. "resource://gre/modules/PageThumbs.jsm");
  31. const DEFAULT_SITES_PREF = "default.sites";
  32. const DEFAULT_TOP_SITES = [];
  33. const FRECENCY_THRESHOLD = 100 + 1; // 1 visit (skip first-run/one-time pages)
  34. const MIN_FAVICON_SIZE = 96;
  35. const CACHED_LINK_PROPS_TO_MIGRATE = ["screenshot", "customScreenshot"];
  36. const PINNED_FAVICON_PROPS_TO_MIGRATE = ["favicon", "faviconRef", "faviconSize"];
  37. const SECTION_ID = "topsites";
  38. const ROWS_PREF = "topSitesRows";
  39. // Search experiment stuff
  40. const FILTER_DEFAULT_SEARCH_PREF = "improvesearch.noDefaultSearchTile";
  41. const SEARCH_FILTERS = [
  42. "google",
  43. "search.yahoo",
  44. "yahoo",
  45. "bing",
  46. "ask",
  47. "duckduckgo",
  48. ];
  49. function getShortURLForCurrentSearch() {
  50. const url = shortURL({url: Services.search.defaultEngine.searchForm});
  51. return url;
  52. }
  53. this.TopSitesFeed = class TopSitesFeed {
  54. constructor() {
  55. this._tippyTopProvider = new TippyTopProvider();
  56. XPCOMUtils.defineLazyGetter(this, "_currentSearchHostname", getShortURLForCurrentSearch);
  57. this.dedupe = new Dedupe(this._dedupeKey);
  58. this.frecentCache = new LinksCache(NewTabUtils.activityStreamLinks,
  59. "getTopSites", CACHED_LINK_PROPS_TO_MIGRATE, (oldOptions, newOptions) =>
  60. // Refresh if no old options or requesting more items
  61. !(oldOptions.numItems >= newOptions.numItems));
  62. this.pinnedCache = new LinksCache(NewTabUtils.pinnedLinks, "links",
  63. [...CACHED_LINK_PROPS_TO_MIGRATE, ...PINNED_FAVICON_PROPS_TO_MIGRATE]);
  64. PageThumbs.addExpirationFilter(this);
  65. }
  66. init() {
  67. // If the feed was previously disabled PREFS_INITIAL_VALUES was never received
  68. this.refreshDefaults(this.store.getState().Prefs.values[DEFAULT_SITES_PREF]);
  69. this._storage = this.store.dbStorage.getDbTable("sectionPrefs");
  70. this.refresh({broadcast: true});
  71. Services.obs.addObserver(this, "browser-search-engine-modified");
  72. }
  73. uninit() {
  74. PageThumbs.removeExpirationFilter(this);
  75. Services.obs.removeObserver(this, "browser-search-engine-modified");
  76. }
  77. observe(subj, topic, data) {
  78. // We should update the current top sites if the search engine has been changed since
  79. // the search engine that gets filtered out of top sites has changed.
  80. if (topic === "browser-search-engine-modified" && data === "engine-default" && this.store.getState().Prefs.values[FILTER_DEFAULT_SEARCH_PREF]) {
  81. delete this._currentSearchHostname;
  82. this._currentSearchHostname = getShortURLForCurrentSearch();
  83. this.refresh({broadcast: true});
  84. }
  85. }
  86. _dedupeKey(site) {
  87. return site && site.hostname;
  88. }
  89. refreshDefaults(sites) {
  90. // Clear out the array of any previous defaults
  91. DEFAULT_TOP_SITES.length = 0;
  92. // Add default sites if any based on the pref
  93. if (sites) {
  94. for (const url of sites.split(",")) {
  95. const site = {
  96. isDefault: true,
  97. url,
  98. };
  99. site.hostname = shortURL(site);
  100. DEFAULT_TOP_SITES.push(site);
  101. }
  102. }
  103. }
  104. filterForThumbnailExpiration(callback) {
  105. const {rows} = this.store.getState().TopSites;
  106. callback(rows.reduce((acc, site) => {
  107. acc.push(site.url);
  108. if (site.customScreenshotURL) {
  109. acc.push(site.customScreenshotURL);
  110. }
  111. return acc;
  112. }, []));
  113. }
  114. /**
  115. * shouldFilterSearchTile - is default filtering enabled and does a given hostname match the user's default search engine?
  116. *
  117. * @param {string} hostname a top site hostname, such as "amazon" or "foo"
  118. * @returns {bool}
  119. */
  120. shouldFilterSearchTile(hostname) {
  121. if (this.store.getState().Prefs.values[FILTER_DEFAULT_SEARCH_PREF] &&
  122. (SEARCH_FILTERS.includes(hostname) || hostname === this._currentSearchHostname)) {
  123. return true;
  124. }
  125. return false;
  126. }
  127. /**
  128. * _maybeInsertSearchShortcuts - if the search shortcuts experiment is running,
  129. * insert search shortcuts if needed
  130. * @param {Array} plainPinnedSites (from the pinnedSitesCache)
  131. * @returns {Boolean} Did we insert any search shortcuts?
  132. */
  133. async _maybeInsertSearchShortcuts(plainPinnedSites) {
  134. // Only insert shortcuts if the experiment is running
  135. if (this.store.getState().Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT]) {
  136. // We don't want to insert shortcuts we've previously inserted
  137. const prevInsertedShortcuts = this.store.getState().Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF]
  138. .split(",").filter(s => s); // Filter out empty strings
  139. const newInsertedShortcuts = [];
  140. const shouldPin = this.store.getState().Prefs.values[SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF]
  141. .split(",")
  142. .map(getSearchProvider)
  143. .filter(s => s && s.shortURL !== this._currentSearchHostname);
  144. // If we've previously inserted all search shortcuts return early
  145. if (shouldPin.every(shortcut => prevInsertedShortcuts.includes(shortcut.shortURL))) {
  146. return false;
  147. }
  148. const numberOfSlots = this.store.getState().Prefs.values[ROWS_PREF] * TOP_SITES_MAX_SITES_PER_ROW;
  149. // The plainPinnedSites array is populated with pinned sites at their
  150. // respective indices, and null everywhere else, but is not always the
  151. // right length
  152. const emptySlots = Math.max(numberOfSlots - plainPinnedSites.length, 0);
  153. const pinnedSites = [...plainPinnedSites].concat(
  154. Array(emptySlots).fill(null)
  155. );
  156. const tryToInsertSearchShortcut = async shortcut => {
  157. const nextAvailable = pinnedSites.indexOf(null);
  158. // Only add a search shortcut if the site isn't already pinned, we
  159. // haven't previously inserted it, there's space to pin it, and the
  160. // search engine is available in Firefox
  161. if (
  162. !pinnedSites.find(s => s && s.hostname === shortcut.shortURL) &&
  163. !prevInsertedShortcuts.includes(shortcut.shortURL) &&
  164. nextAvailable > -1 &&
  165. await checkHasSearchEngine(shortcut.keyword)
  166. ) {
  167. const site = await this.topSiteToSearchTopSite({url: shortcut.url});
  168. this._pinSiteAt(site, nextAvailable);
  169. pinnedSites[nextAvailable] = site;
  170. newInsertedShortcuts.push(shortcut.shortURL);
  171. }
  172. };
  173. for (let shortcut of shouldPin) {
  174. await tryToInsertSearchShortcut(shortcut);
  175. }
  176. if (newInsertedShortcuts.length) {
  177. this.store.dispatch(ac.SetPref(SEARCH_SHORTCUTS_HAVE_PINNED_PREF, prevInsertedShortcuts.concat(newInsertedShortcuts).join(",")));
  178. return true;
  179. }
  180. }
  181. return false;
  182. }
  183. async getLinksWithDefaults() {
  184. const numItems = this.store.getState().Prefs.values[ROWS_PREF] * TOP_SITES_MAX_SITES_PER_ROW;
  185. const searchShortcutsExperiment = this.store.getState().Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT];
  186. // We must wait for search services to initialize in order to access default
  187. // search engine properties without triggering a synchronous initialization
  188. await Services.search.init();
  189. // Get all frecent sites from history.
  190. let frecent = [];
  191. const cache = await this.frecentCache.request({
  192. // We need to overquery due to the top 5 alexa search + default search possibly being removed
  193. numItems: numItems + SEARCH_FILTERS.length + 1,
  194. topsiteFrecency: FRECENCY_THRESHOLD,
  195. });
  196. for (let link of cache) {
  197. const hostname = shortURL(link);
  198. if (!this.shouldFilterSearchTile(hostname)) {
  199. frecent.push({
  200. ...(searchShortcutsExperiment ? await this.topSiteToSearchTopSite(link) : link),
  201. hostname,
  202. });
  203. }
  204. }
  205. // Remove any defaults that have been blocked.
  206. let notBlockedDefaultSites = [];
  207. for (let link of DEFAULT_TOP_SITES) {
  208. const searchProvider = getSearchProvider(shortURL(link));
  209. if (NewTabUtils.blockedLinks.isBlocked({url: link.url})) {
  210. continue;
  211. } else if (this.shouldFilterSearchTile(link.hostname)) {
  212. continue;
  213. // If we've previously blocked a search shortcut, remove the default top site
  214. // that matches the hostname
  215. } else if (searchProvider && NewTabUtils.blockedLinks.isBlocked({url: searchProvider.url})) {
  216. continue;
  217. }
  218. notBlockedDefaultSites.push(
  219. searchShortcutsExperiment ? await this.topSiteToSearchTopSite(link) : link,
  220. );
  221. }
  222. // Get pinned links augmented with desired properties
  223. let plainPinned = await this.pinnedCache.request();
  224. // Insert search shortcuts if we need to.
  225. // _maybeInsertSearchShortcuts returns true if any search shortcuts are
  226. // inserted, meaning we need to expire and refresh the pinnedCache
  227. if (await this._maybeInsertSearchShortcuts(plainPinned)) {
  228. this.pinnedCache.expire();
  229. plainPinned = await this.pinnedCache.request();
  230. }
  231. const pinned = await Promise.all(plainPinned.map(async link => {
  232. if (!link) {
  233. return link;
  234. }
  235. // Copy all properties from a frecent link and add more
  236. const finder = other => other.url === link.url;
  237. // Remove frecent link's screenshot if pinned link has a custom one
  238. const frecentSite = frecent.find(finder);
  239. if (frecentSite && link.customScreenshotURL) {
  240. delete frecentSite.screenshot;
  241. }
  242. // If the link is a frecent site, do not copy over 'isDefault', else check
  243. // if the site is a default site
  244. const copy = Object.assign(
  245. {},
  246. frecentSite || {isDefault: !!notBlockedDefaultSites.find(finder)},
  247. link,
  248. {hostname: shortURL(link)},
  249. {searchTopSite: !!link.searchTopSite}
  250. );
  251. // Add in favicons if we don't already have it
  252. if (!copy.favicon) {
  253. try {
  254. NewTabUtils.activityStreamProvider._faviconBytesToDataURI(await
  255. NewTabUtils.activityStreamProvider._addFavicons([copy]));
  256. for (const prop of PINNED_FAVICON_PROPS_TO_MIGRATE) {
  257. copy.__sharedCache.updateLink(prop, copy[prop]);
  258. }
  259. } catch (e) {
  260. // Some issue with favicon, so just continue without one
  261. }
  262. }
  263. return copy;
  264. }));
  265. // Remove any duplicates from frecent and default sites
  266. const [, dedupedFrecent, dedupedDefaults] = this.dedupe.group(
  267. pinned, frecent, notBlockedDefaultSites);
  268. const dedupedUnpinned = [...dedupedFrecent, ...dedupedDefaults];
  269. // Remove adult sites if we need to
  270. const checkedAdult = this.store.getState().Prefs.values.filterAdult ?
  271. filterAdult(dedupedUnpinned) : dedupedUnpinned;
  272. // Insert the original pinned sites into the deduped frecent and defaults
  273. const withPinned = insertPinned(checkedAdult, pinned).slice(0, numItems);
  274. // Now, get a tippy top icon, a rich icon, or screenshot for every item
  275. for (const link of withPinned) {
  276. if (link) {
  277. // If there is a custom screenshot this is the only image we display
  278. if (link.customScreenshotURL) {
  279. this._fetchScreenshot(link, link.customScreenshotURL);
  280. } else if (link.searchTopSite && !link.isDefault) {
  281. this._tippyTopProvider.processSite(link);
  282. } else {
  283. this._fetchIcon(link);
  284. }
  285. // Remove internal properties that might be updated after dispatch
  286. delete link.__sharedCache;
  287. // Indicate that these links should get a frecency bonus when clicked
  288. link.typedBonus = true;
  289. }
  290. }
  291. return withPinned;
  292. }
  293. /**
  294. * Refresh the top sites data for content.
  295. * @param {bool} options.broadcast Should the update be broadcasted.
  296. */
  297. async refresh(options = {}) {
  298. if (!this._tippyTopProvider.initialized) {
  299. await this._tippyTopProvider.init();
  300. }
  301. const links = await this.getLinksWithDefaults();
  302. const newAction = {type: at.TOP_SITES_UPDATED, data: {links}};
  303. let storedPrefs;
  304. try {
  305. storedPrefs = await this._storage.get(SECTION_ID) || {};
  306. } catch (e) {
  307. storedPrefs = {};
  308. Cu.reportError("Problem getting stored prefs for TopSites");
  309. }
  310. newAction.data.pref = getDefaultOptions(storedPrefs);
  311. if (options.broadcast) {
  312. // Broadcast an update to all open content pages
  313. this.store.dispatch(ac.BroadcastToContent(newAction));
  314. } else {
  315. // Don't broadcast only update the state and update the preloaded tab.
  316. this.store.dispatch(ac.AlsoToPreloaded(newAction));
  317. }
  318. }
  319. async updateCustomSearchShortcuts() {
  320. if (!this.store.getState().Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT]) {
  321. return;
  322. }
  323. if (!this._tippyTopProvider.initialized) {
  324. await this._tippyTopProvider.init();
  325. }
  326. // Populate the state with available search shortcuts
  327. const searchShortcuts = (await Services.search.getDefaultEngines()).reduce((result, engine) => {
  328. const shortcut = CUSTOM_SEARCH_SHORTCUTS.find(s => engine.wrappedJSObject._internalAliases.includes(s.keyword));
  329. if (shortcut) {
  330. result.push(this._tippyTopProvider.processSite({...shortcut}));
  331. }
  332. return result;
  333. }, []);
  334. this.store.dispatch(ac.BroadcastToContent({
  335. type: at.UPDATE_SEARCH_SHORTCUTS,
  336. data: {searchShortcuts},
  337. }));
  338. }
  339. async topSiteToSearchTopSite(site) {
  340. const searchProvider = getSearchProvider(shortURL(site));
  341. if (!searchProvider || !await checkHasSearchEngine(searchProvider.keyword)) {
  342. return site;
  343. }
  344. return {
  345. ...site,
  346. searchTopSite: true,
  347. label: searchProvider.keyword,
  348. };
  349. }
  350. /**
  351. * Get an image for the link preferring tippy top, rich favicon, screenshots.
  352. */
  353. async _fetchIcon(link) {
  354. // Nothing to do if we already have a rich icon from the page
  355. if (link.favicon && link.faviconSize >= MIN_FAVICON_SIZE) {
  356. return;
  357. }
  358. // Nothing more to do if we can use a default tippy top icon
  359. this._tippyTopProvider.processSite(link);
  360. if (link.tippyTopIcon) {
  361. return;
  362. }
  363. // Make a request for a better icon
  364. this._requestRichIcon(link.url);
  365. // Also request a screenshot if we don't have one yet
  366. await this._fetchScreenshot(link, link.url);
  367. }
  368. /**
  369. * Fetch, cache and broadcast a screenshot for a specific topsite.
  370. * @param link cached topsite object
  371. * @param url where to fetch the image from
  372. */
  373. async _fetchScreenshot(link, url) {
  374. if (link.screenshot) {
  375. return;
  376. }
  377. await Screenshots.maybeCacheScreenshot(link, url, "screenshot",
  378. screenshot => this.store.dispatch(ac.BroadcastToContent({
  379. data: {screenshot, url: link.url},
  380. type: at.SCREENSHOT_UPDATED,
  381. })));
  382. }
  383. /**
  384. * Dispatch screenshot preview to target or notify if request failed.
  385. * @param customScreenshotURL {string} The URL used to capture the screenshot
  386. * @param target {string} Id of content process where to dispatch the result
  387. */
  388. async getScreenshotPreview(url, target) {
  389. const preview = await Screenshots.getScreenshotForURL(url) || "";
  390. this.store.dispatch(ac.OnlyToOneContent({
  391. data: {url, preview},
  392. type: at.PREVIEW_RESPONSE,
  393. }, target));
  394. }
  395. _requestRichIcon(url) {
  396. this.store.dispatch({
  397. type: at.RICH_ICON_MISSING,
  398. data: {url},
  399. });
  400. }
  401. updateSectionPrefs(collapsed) {
  402. this.store.dispatch(ac.BroadcastToContent({type: at.TOP_SITES_PREFS_UPDATED, data: {pref: collapsed}}));
  403. }
  404. /**
  405. * Inform others that top sites data has been updated due to pinned changes.
  406. */
  407. _broadcastPinnedSitesUpdated() {
  408. // Pinned data changed, so make sure we get latest
  409. this.pinnedCache.expire();
  410. // Refresh to update pinned sites with screenshots, trigger deduping, etc.
  411. this.refresh({broadcast: true});
  412. }
  413. /**
  414. * Pin a site at a specific position saving only the desired keys.
  415. * @param customScreenshotURL {string} User set URL of preview image for site
  416. * @param label {string} User set string of custom site name
  417. */
  418. async _pinSiteAt({customScreenshotURL, label, url, searchTopSite}, index) {
  419. const toPin = {url};
  420. if (label) {
  421. toPin.label = label;
  422. }
  423. if (customScreenshotURL) {
  424. toPin.customScreenshotURL = customScreenshotURL;
  425. }
  426. if (searchTopSite) {
  427. toPin.searchTopSite = searchTopSite;
  428. }
  429. NewTabUtils.pinnedLinks.pin(toPin, index);
  430. await this._clearLinkCustomScreenshot({customScreenshotURL, url});
  431. }
  432. async _clearLinkCustomScreenshot(site) {
  433. // If screenshot url changed or was removed we need to update the cached link obj
  434. if (site.customScreenshotURL !== undefined) {
  435. const pinned = await this.pinnedCache.request();
  436. const link = pinned.find(pin => pin && pin.url === site.url);
  437. if (link && link.customScreenshotURL !== site.customScreenshotURL) {
  438. link.__sharedCache.updateLink("screenshot", undefined);
  439. }
  440. }
  441. }
  442. /**
  443. * Handle a pin action of a site to a position.
  444. */
  445. async pin(action) {
  446. const {site, index} = action.data;
  447. // If valid index provided, pin at that position
  448. if (index >= 0) {
  449. await this._pinSiteAt(site, index);
  450. this._broadcastPinnedSitesUpdated();
  451. } else {
  452. // Bug 1458658. If the top site is being pinned from an 'Add a Top Site' option,
  453. // then we want to make sure to unblock that link if it has previously been
  454. // blocked. We know if the site has been added because the index will be -1.
  455. if (index === -1) {
  456. NewTabUtils.blockedLinks.unblock({url: site.url});
  457. this.frecentCache.expire();
  458. }
  459. this.insert(action);
  460. }
  461. }
  462. /**
  463. * Handle an unpin action of a site.
  464. */
  465. unpin(action) {
  466. const {site} = action.data;
  467. NewTabUtils.pinnedLinks.unpin(site);
  468. this._broadcastPinnedSitesUpdated();
  469. }
  470. disableSearchImprovements() {
  471. Services.prefs.clearUserPref(`browser.newtabpage.activity-stream.${SEARCH_SHORTCUTS_HAVE_PINNED_PREF}`);
  472. this.unpinAllSearchShortcuts();
  473. }
  474. unpinAllSearchShortcuts() {
  475. for (let pinnedLink of NewTabUtils.pinnedLinks.links) {
  476. if (pinnedLink && pinnedLink.searchTopSite) {
  477. NewTabUtils.pinnedLinks.unpin(pinnedLink);
  478. }
  479. }
  480. this.pinnedCache.expire();
  481. }
  482. /**
  483. * Insert a site to pin at a position shifting over any other pinned sites.
  484. */
  485. _insertPin(site, index, draggedFromIndex) {
  486. // Don't insert any pins past the end of the visible top sites. Otherwise,
  487. // we can end up with a bunch of pinned sites that can never be unpinned again
  488. // from the UI.
  489. const topSitesCount = this.store.getState().Prefs.values[ROWS_PREF] * TOP_SITES_MAX_SITES_PER_ROW;
  490. if (index >= topSitesCount) {
  491. return;
  492. }
  493. let pinned = NewTabUtils.pinnedLinks.links;
  494. if (!pinned[index]) {
  495. this._pinSiteAt(site, index);
  496. } else {
  497. pinned[draggedFromIndex] = null;
  498. // Find the hole to shift the pinned site(s) towards. We shift towards the
  499. // hole left by the site being dragged.
  500. let holeIndex = index;
  501. const indexStep = index > draggedFromIndex ? -1 : 1;
  502. while (pinned[holeIndex]) {
  503. holeIndex += indexStep;
  504. }
  505. if (holeIndex >= topSitesCount || holeIndex < 0) {
  506. // There are no holes, so we will effectively unpin the last slot and shifting
  507. // towards it. This only happens when adding a new top site to an already
  508. // fully pinned grid.
  509. holeIndex = topSitesCount - 1;
  510. }
  511. // Shift towards the hole.
  512. const shiftingStep = holeIndex > index ? -1 : 1;
  513. while (holeIndex !== index) {
  514. const nextIndex = holeIndex + shiftingStep;
  515. this._pinSiteAt(pinned[nextIndex], holeIndex);
  516. holeIndex = nextIndex;
  517. }
  518. this._pinSiteAt(site, index);
  519. }
  520. }
  521. /**
  522. * Handle an insert (drop/add) action of a site.
  523. */
  524. async insert(action) {
  525. let {index} = action.data;
  526. // Treat invalid pin index values (e.g., -1, undefined) as insert in the first position
  527. if (!(index > 0)) {
  528. index = 0;
  529. }
  530. // Inserting a top site pins it in the specified slot, pushing over any link already
  531. // pinned in the slot (unless it's the last slot, then it replaces).
  532. this._insertPin(
  533. action.data.site, index,
  534. action.data.draggedFromIndex !== undefined ? action.data.draggedFromIndex : this.store.getState().Prefs.values[ROWS_PREF] * TOP_SITES_MAX_SITES_PER_ROW);
  535. await this._clearLinkCustomScreenshot(action.data.site);
  536. this._broadcastPinnedSitesUpdated();
  537. }
  538. updatePinnedSearchShortcuts({addedShortcuts, deletedShortcuts}) {
  539. // Unpin the deletedShortcuts.
  540. deletedShortcuts.forEach(({url}) => {
  541. NewTabUtils.pinnedLinks.unpin({url});
  542. });
  543. // Pin the addedShortcuts.
  544. const numberOfSlots = this.store.getState().Prefs.values[ROWS_PREF] * TOP_SITES_MAX_SITES_PER_ROW;
  545. addedShortcuts.forEach(shortcut => {
  546. // Find first hole in pinnedLinks.
  547. let index = NewTabUtils.pinnedLinks.links.findIndex(link => !link);
  548. if (index < 0 && NewTabUtils.pinnedLinks.links.length + 1 < numberOfSlots) {
  549. // pinnedLinks can have less slots than the total available.
  550. index = NewTabUtils.pinnedLinks.links.length;
  551. }
  552. if (index >= 0) {
  553. NewTabUtils.pinnedLinks.pin(shortcut, index);
  554. } else {
  555. // No slots available, we need to do an insert in first slot and push over other pinned links.
  556. this._insertPin(shortcut, 0, numberOfSlots);
  557. }
  558. });
  559. this._broadcastPinnedSitesUpdated();
  560. }
  561. onAction(action) {
  562. switch (action.type) {
  563. case at.INIT:
  564. this.init();
  565. this.updateCustomSearchShortcuts();
  566. break;
  567. case at.SYSTEM_TICK:
  568. this.refresh({broadcast: false});
  569. break;
  570. // All these actions mean we need new top sites
  571. case at.PLACES_HISTORY_CLEARED:
  572. case at.PLACES_LINK_DELETED:
  573. this.frecentCache.expire();
  574. this.refresh({broadcast: true});
  575. break;
  576. case at.PLACES_LINKS_CHANGED:
  577. this.frecentCache.expire();
  578. this.refresh({broadcast: false});
  579. break;
  580. case at.PLACES_LINK_BLOCKED:
  581. this.frecentCache.expire();
  582. this.pinnedCache.expire();
  583. this.refresh({broadcast: true});
  584. break;
  585. case at.PREF_CHANGED:
  586. switch (action.data.name) {
  587. case DEFAULT_SITES_PREF:
  588. this.refreshDefaults(action.data.value);
  589. break;
  590. case ROWS_PREF:
  591. case FILTER_DEFAULT_SEARCH_PREF:
  592. case SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF:
  593. this.refresh({broadcast: true});
  594. break;
  595. case SEARCH_SHORTCUTS_EXPERIMENT:
  596. if (action.data.value) {
  597. this.updateCustomSearchShortcuts();
  598. } else {
  599. this.disableSearchImprovements();
  600. }
  601. this.refresh({broadcast: true});
  602. }
  603. break;
  604. case at.UPDATE_SECTION_PREFS:
  605. if (action.data.id === SECTION_ID) {
  606. this.updateSectionPrefs(action.data.value);
  607. }
  608. break;
  609. case at.PREFS_INITIAL_VALUES:
  610. this.refreshDefaults(action.data[DEFAULT_SITES_PREF]);
  611. break;
  612. case at.TOP_SITES_PIN:
  613. this.pin(action);
  614. break;
  615. case at.TOP_SITES_UNPIN:
  616. this.unpin(action);
  617. break;
  618. case at.TOP_SITES_INSERT:
  619. this.insert(action);
  620. break;
  621. case at.PREVIEW_REQUEST:
  622. this.getScreenshotPreview(action.data.url, action.meta.fromTarget);
  623. break;
  624. case at.UPDATE_PINNED_SEARCH_SHORTCUTS:
  625. this.updatePinnedSearchShortcuts(action.data);
  626. break;
  627. case at.UNINIT:
  628. this.uninit();
  629. break;
  630. }
  631. }
  632. };
  633. this.DEFAULT_TOP_SITES = DEFAULT_TOP_SITES;
  634. const EXPORTED_SYMBOLS = ["TopSitesFeed", "DEFAULT_TOP_SITES"];