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 6ChromeUtils.import("resource://gre/modules/Services.jsm"); 7 8const {actionTypes: at} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm", {}); 9 10const {shortURL} = ChromeUtils.import("resource://activity-stream/lib/ShortURL.jsm", {}); 11const {SectionsManager} = ChromeUtils.import("resource://activity-stream/lib/SectionsManager.jsm", {}); 12const {TOP_SITES_DEFAULT_ROWS, TOP_SITES_MAX_SITES_PER_ROW} = ChromeUtils.import("resource://activity-stream/common/Reducers.jsm", {}); 13const {Dedupe} = ChromeUtils.import("resource://activity-stream/common/Dedupe.jsm", {}); 14 15ChromeUtils.defineModuleGetter(this, "filterAdult", 16 "resource://activity-stream/lib/FilterAdult.jsm"); 17ChromeUtils.defineModuleGetter(this, "LinksCache", 18 "resource://activity-stream/lib/LinksCache.jsm"); 19ChromeUtils.defineModuleGetter(this, "NewTabUtils", 20 "resource://gre/modules/NewTabUtils.jsm"); 21ChromeUtils.defineModuleGetter(this, "Screenshots", 22 "resource://activity-stream/lib/Screenshots.jsm"); 23ChromeUtils.defineModuleGetter(this, "PageThumbs", 24 "resource://gre/modules/PageThumbs.jsm"); 25 26const HIGHLIGHTS_MAX_LENGTH = 9; 27const MANY_EXTRA_LENGTH = HIGHLIGHTS_MAX_LENGTH * 5 + TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW; 28const SECTION_ID = "highlights"; 29const SYNC_BOOKMARKS_FINISHED_EVENT = "weave:engine:sync:applied"; 30const BOOKMARKS_RESTORE_SUCCESS_EVENT = "bookmarks-restore-success"; 31const BOOKMARKS_RESTORE_FAILED_EVENT = "bookmarks-restore-failed"; 32 33this.HighlightsFeed = class HighlightsFeed { 34 constructor() { 35 this.dedupe = new Dedupe(this._dedupeKey); 36 this.linksCache = new LinksCache(NewTabUtils.activityStreamLinks, 37 "getHighlights", ["image"]); 38 PageThumbs.addExpirationFilter(this); 39 } 40 41 _dedupeKey(site) { 42 // Treat bookmarks and pocket items as un-dedupable, otherwise show one of a url 43 return site && ((site.pocket_id || site.type === "bookmark") ? {} : site.url); 44 } 45 46 init() { 47 Services.obs.addObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT); 48 Services.obs.addObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT); 49 Services.obs.addObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT); 50 SectionsManager.onceInitialized(this.postInit.bind(this)); 51 } 52 53 postInit() { 54 SectionsManager.enableSection(SECTION_ID); 55 this.fetchHighlights({broadcast: true}); 56 } 57 58 uninit() { 59 SectionsManager.disableSection(SECTION_ID); 60 PageThumbs.removeExpirationFilter(this); 61 Services.obs.removeObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT); 62 Services.obs.removeObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT); 63 Services.obs.removeObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT); 64 } 65 66 observe(subject, topic, data) { 67 // When we receive a notification that a sync has happened for bookmarks, 68 // or Places finished importing or restoring bookmarks, refresh highlights 69 const manyBookmarksChanged = 70 (topic === SYNC_BOOKMARKS_FINISHED_EVENT && data === "bookmarks") || 71 topic === BOOKMARKS_RESTORE_SUCCESS_EVENT || 72 topic === BOOKMARKS_RESTORE_FAILED_EVENT; 73 if (manyBookmarksChanged) { 74 this.fetchHighlights({broadcast: true}); 75 } 76 } 77 78 filterForThumbnailExpiration(callback) { 79 const state = this.store.getState().Sections.find(section => section.id === SECTION_ID); 80 81 callback(state && state.initialized ? state.rows.reduce((acc, site) => { 82 // Screenshots call in `fetchImage` will search for preview_image_url or 83 // fallback to URL, so we prevent both from being expired. 84 acc.push(site.url); 85 if (site.preview_image_url) { 86 acc.push(site.preview_image_url); 87 } 88 return acc; 89 }, []) : []); 90 } 91 92 /** 93 * Refresh the highlights data for content. 94 * @param {bool} options.broadcast Should the update be broadcasted. 95 */ 96 async fetchHighlights(options = {}) { 97 // We need TopSites for deduping, so wait for TOP_SITES_UPDATED. 98 if (!this.store.getState().TopSites.initialized) { 99 return; 100 } 101 102 // We broadcast when we want to force an update, so get fresh links 103 if (options.broadcast) { 104 this.linksCache.expire(); 105 } 106 107 // Request more than the expected length to allow for items being removed by 108 // deduping against Top Sites or multiple history from the same domain, etc. 109 const manyPages = await this.linksCache.request({ 110 numItems: MANY_EXTRA_LENGTH, 111 excludePocket: !this.store.getState().Prefs.values["section.highlights.includePocket"] 112 }); 113 114 // Remove adult highlights if we need to 115 const checkedAdult = this.store.getState().Prefs.values.filterAdult ? 116 filterAdult(manyPages) : manyPages; 117 118 // Remove any Highlights that are in Top Sites already 119 const [, deduped] = this.dedupe.group(this.store.getState().TopSites.rows, checkedAdult); 120 121 // Keep all "bookmark"s and at most one (most recent) "history" per host 122 const highlights = []; 123 const hosts = new Set(); 124 for (const page of deduped) { 125 const hostname = shortURL(page); 126 // Skip this history page if we already something from the same host 127 if (page.type === "history" && hosts.has(hostname)) { 128 continue; 129 } 130 131 // If we already have the image for the card, use that immediately. Else 132 // asynchronously fetch the image. 133 if (!page.image) { 134 this.fetchImage(page); 135 } 136 137 // Adjust the type for 'history' items that are also 'bookmarked' 138 if (page.type === "history" && page.bookmarkGuid) { 139 page.type = "bookmark"; 140 } 141 142 // We want the page, so update various fields for UI 143 Object.assign(page, { 144 hasImage: true, // We always have an image - fall back to a screenshot 145 hostname, 146 type: page.type, 147 pocket_id: page.pocket_id 148 }); 149 150 // Add the "bookmark", "pocket", or not-skipped "history" 151 highlights.push(page); 152 hosts.add(hostname); 153 154 // Remove internal properties that might be updated after dispatch 155 delete page.__sharedCache; 156 157 // Skip the rest if we have enough items 158 if (highlights.length === HIGHLIGHTS_MAX_LENGTH) { 159 break; 160 } 161 } 162 163 const {initialized} = this.store.getState().Sections.find(section => section.id === SECTION_ID); 164 // Broadcast when required or if it is the first update. 165 const shouldBroadcast = options.broadcast || !initialized; 166 167 SectionsManager.updateSection(SECTION_ID, {rows: highlights}, shouldBroadcast); 168 } 169 170 /** 171 * Fetch an image for a given highlight and update the card with it. If no 172 * image is available then fallback to fetching a screenshot. 173 */ 174 fetchImage(page) { 175 // Request a screenshot if we don't already have one pending 176 const {preview_image_url: imageUrl, url} = page; 177 Screenshots.maybeCacheScreenshot(page, imageUrl || url, "image", image => { 178 SectionsManager.updateSectionCard(SECTION_ID, url, {image}, true); 179 }); 180 } 181 182 /** 183 * Deletes an item from a user's saved to Pocket feed and then refreshes highlights 184 * @param {int} itemID 185 * The unique ID given by Pocket for that item; used to look the item up when deleting 186 */ 187 async deleteFromPocket(itemID) { 188 try { 189 await NewTabUtils.activityStreamLinks.deletePocketEntry(itemID); 190 this.fetchHighlights({broadcast: true}); 191 } catch (err) { 192 Cu.reportError(err); 193 } 194 } 195 196 /** 197 * Archives an item from a user's saved to Pocket feed and then refreshes highlights 198 * @param {int} itemID 199 * The unique ID given by Pocket for that item; used to look the item up when archiving 200 */ 201 async archiveFromPocket(itemID) { 202 try { 203 await NewTabUtils.activityStreamLinks.archivePocketEntry(itemID); 204 this.fetchHighlights({broadcast: true}); 205 } catch (err) { 206 Cu.reportError(err); 207 } 208 } 209 210 onAction(action) { 211 switch (action.type) { 212 case at.INIT: 213 this.init(); 214 break; 215 case at.SYSTEM_TICK: 216 this.fetchHighlights({broadcast: false}); 217 break; 218 case at.MIGRATION_COMPLETED: 219 case at.PLACES_HISTORY_CLEARED: 220 case at.PLACES_LINKS_DELETED: 221 case at.PLACES_LINK_BLOCKED: 222 this.fetchHighlights({broadcast: true}); 223 break; 224 case at.DELETE_FROM_POCKET: 225 this.deleteFromPocket(action.data.pocket_id); 226 break; 227 case at.ARCHIVE_FROM_POCKET: 228 this.archiveFromPocket(action.data.pocket_id); 229 break; 230 case at.PLACES_BOOKMARK_ADDED: 231 case at.PLACES_BOOKMARK_REMOVED: 232 case at.PLACES_SAVED_TO_POCKET: 233 this.linksCache.expire(); 234 this.fetchHighlights({broadcast: false}); 235 break; 236 case at.TOP_SITES_UPDATED: 237 this.fetchHighlights({broadcast: false}); 238 break; 239 case at.UNINIT: 240 this.uninit(); 241 break; 242 } 243 } 244}; 245 246const EXPORTED_SYMBOLS = ["HighlightsFeed", "SECTION_ID", "MANY_EXTRA_LENGTH", "SYNC_BOOKMARKS_FINISHED_EVENT", "BOOKMARKS_RESTORE_SUCCESS_EVENT", "BOOKMARKS_RESTORE_FAILED_EVENT"]; 247