PlacesFeed.jsm 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  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 {actionCreators: ac, actionTypes: at} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
  7. ChromeUtils.defineModuleGetter(this, "NewTabUtils",
  8. "resource://gre/modules/NewTabUtils.jsm");
  9. ChromeUtils.defineModuleGetter(this, "PlacesUtils",
  10. "resource://gre/modules/PlacesUtils.jsm");
  11. const LINK_BLOCKED_EVENT = "newtab-linkBlocked";
  12. const PLACES_LINKS_CHANGED_DELAY_TIME = 1000; // time in ms to delay timer for places links changed events
  13. /**
  14. * Observer - a wrapper around history/bookmark observers to add the QueryInterface.
  15. */
  16. class Observer {
  17. constructor(dispatch, observerInterface) {
  18. this.dispatch = dispatch;
  19. this.QueryInterface = ChromeUtils.generateQI([observerInterface, Ci.nsISupportsWeakReference]);
  20. }
  21. }
  22. /**
  23. * HistoryObserver - observes events from PlacesUtils.history
  24. */
  25. class HistoryObserver extends Observer {
  26. constructor(dispatch) {
  27. super(dispatch, Ci.nsINavHistoryObserver);
  28. }
  29. /**
  30. * onDeleteURI - Called when an link is deleted from history.
  31. *
  32. * @param {obj} uri A URI object representing the link's url
  33. * {str} uri.spec The URI as a string
  34. */
  35. onDeleteURI(uri) {
  36. this.dispatch({type: at.PLACES_LINKS_CHANGED});
  37. this.dispatch({
  38. type: at.PLACES_LINK_DELETED,
  39. data: {url: uri.spec},
  40. });
  41. }
  42. /**
  43. * onClearHistory - Called when the user clears their entire history.
  44. */
  45. onClearHistory() {
  46. this.dispatch({type: at.PLACES_HISTORY_CLEARED});
  47. }
  48. // Empty functions to make xpconnect happy
  49. onBeginUpdateBatch() {}
  50. onEndUpdateBatch() {}
  51. onTitleChanged() {}
  52. onFrecencyChanged() {}
  53. onManyFrecenciesChanged() {}
  54. onPageChanged() {}
  55. onDeleteVisits() {}
  56. }
  57. /**
  58. * BookmarksObserver - observes events from PlacesUtils.bookmarks
  59. */
  60. class BookmarksObserver extends Observer {
  61. constructor(dispatch) {
  62. super(dispatch, Ci.nsINavBookmarkObserver);
  63. this.skipTags = true;
  64. }
  65. /**
  66. * onItemRemoved - Called when a bookmark is removed
  67. *
  68. * @param {str} id
  69. * @param {str} folderId
  70. * @param {int} index
  71. * @param {int} type Indicates if the bookmark is an actual bookmark,
  72. * a folder, or a separator.
  73. * @param {str} uri
  74. * @param {str} guid The unique id of the bookmark
  75. */
  76. onItemRemoved(id, folderId, index, type, uri, guid, parentGuid, source) { // eslint-disable-line max-params
  77. if (type === PlacesUtils.bookmarks.TYPE_BOOKMARK &&
  78. source !== PlacesUtils.bookmarks.SOURCES.IMPORT &&
  79. source !== PlacesUtils.bookmarks.SOURCES.RESTORE &&
  80. source !== PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP &&
  81. source !== PlacesUtils.bookmarks.SOURCES.SYNC) {
  82. this.dispatch({type: at.PLACES_LINKS_CHANGED});
  83. this.dispatch({
  84. type: at.PLACES_BOOKMARK_REMOVED,
  85. data: {url: uri.spec, bookmarkGuid: guid},
  86. });
  87. }
  88. }
  89. // Empty functions to make xpconnect happy
  90. onBeginUpdateBatch() {}
  91. onEndUpdateBatch() {}
  92. onItemVisited() {}
  93. onItemMoved() {}
  94. // Disabled due to performance cost, see Issue 3203 /
  95. // https://bugzilla.mozilla.org/show_bug.cgi?id=1392267.
  96. onItemChanged() {}
  97. }
  98. /**
  99. * PlacesObserver - observes events from PlacesUtils.observers
  100. */
  101. class PlacesObserver extends Observer {
  102. constructor(dispatch) {
  103. super(dispatch, Ci.nsINavBookmarkObserver);
  104. this.handlePlacesEvent = this.handlePlacesEvent.bind(this);
  105. }
  106. handlePlacesEvent(events) {
  107. for (let {itemType, source, dateAdded, guid, title, url, isTagging} of events) {
  108. // Skips items that are not bookmarks (like folders), about:* pages or
  109. // default bookmarks, added when the profile is created.
  110. if (isTagging ||
  111. itemType !== PlacesUtils.bookmarks.TYPE_BOOKMARK ||
  112. source === PlacesUtils.bookmarks.SOURCES.IMPORT ||
  113. source === PlacesUtils.bookmarks.SOURCES.RESTORE ||
  114. source === PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP ||
  115. source === PlacesUtils.bookmarks.SOURCES.SYNC ||
  116. (!url.startsWith("http://") && !url.startsWith("https://"))) {
  117. return;
  118. }
  119. this.dispatch({type: at.PLACES_LINKS_CHANGED});
  120. this.dispatch({
  121. type: at.PLACES_BOOKMARK_ADDED,
  122. data: {
  123. bookmarkGuid: guid,
  124. bookmarkTitle: title,
  125. dateAdded: dateAdded * 1000,
  126. url,
  127. },
  128. });
  129. }
  130. }
  131. }
  132. class PlacesFeed {
  133. constructor() {
  134. this.placesChangedTimer = null;
  135. this.customDispatch = this.customDispatch.bind(this);
  136. this.historyObserver = new HistoryObserver(this.customDispatch);
  137. this.bookmarksObserver = new BookmarksObserver(this.customDispatch);
  138. this.placesObserver = new PlacesObserver(this.customDispatch);
  139. }
  140. addObservers() {
  141. // NB: Directly get services without importing the *BIG* PlacesUtils module
  142. Cc["@mozilla.org/browser/nav-history-service;1"]
  143. .getService(Ci.nsINavHistoryService)
  144. .addObserver(this.historyObserver, true);
  145. Cc["@mozilla.org/browser/nav-bookmarks-service;1"]
  146. .getService(Ci.nsINavBookmarksService)
  147. .addObserver(this.bookmarksObserver, true);
  148. PlacesUtils.observers.addListener(["bookmark-added"],
  149. this.placesObserver.handlePlacesEvent);
  150. Services.obs.addObserver(this, LINK_BLOCKED_EVENT);
  151. }
  152. /**
  153. * setTimeout - A custom function that creates an nsITimer that can be cancelled
  154. *
  155. * @param {func} callback A function to be executed after the timer expires
  156. * @param {int} delay The time (in ms) the timer should wait before the function is executed
  157. */
  158. setTimeout(callback, delay) {
  159. let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
  160. timer.initWithCallback(callback, delay, Ci.nsITimer.TYPE_ONE_SHOT);
  161. return timer;
  162. }
  163. customDispatch(action) {
  164. // If we are changing many links at once, delay this action and only dispatch
  165. // one action at the end
  166. if (action.type === at.PLACES_LINKS_CHANGED) {
  167. if (this.placesChangedTimer) {
  168. this.placesChangedTimer.delay = PLACES_LINKS_CHANGED_DELAY_TIME;
  169. } else {
  170. this.placesChangedTimer = this.setTimeout(() => {
  171. this.placesChangedTimer = null;
  172. this.store.dispatch(ac.OnlyToMain(action));
  173. }, PLACES_LINKS_CHANGED_DELAY_TIME);
  174. }
  175. } else {
  176. this.store.dispatch(ac.BroadcastToContent(action));
  177. }
  178. }
  179. removeObservers() {
  180. if (this.placesChangedTimer) {
  181. this.placesChangedTimer.cancel();
  182. this.placesChangedTimer = null;
  183. }
  184. PlacesUtils.history.removeObserver(this.historyObserver);
  185. PlacesUtils.bookmarks.removeObserver(this.bookmarksObserver);
  186. PlacesUtils.observers.removeListener(["bookmark-added"],
  187. this.placesObserver.handlePlacesEvent);
  188. Services.obs.removeObserver(this, LINK_BLOCKED_EVENT);
  189. }
  190. /**
  191. * observe - An observer for the LINK_BLOCKED_EVENT.
  192. * Called when a link is blocked.
  193. *
  194. * @param {null} subject
  195. * @param {str} topic The name of the event
  196. * @param {str} value The data associated with the event
  197. */
  198. observe(subject, topic, value) {
  199. if (topic === LINK_BLOCKED_EVENT) {
  200. this.store.dispatch(ac.BroadcastToContent({
  201. type: at.PLACES_LINK_BLOCKED,
  202. data: {url: value},
  203. }));
  204. }
  205. }
  206. /**
  207. * Open a link in a desired destination defaulting to action's event.
  208. */
  209. openLink(action, where = "", isPrivate = false) {
  210. const params = {
  211. private: isPrivate,
  212. triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
  213. };
  214. // Always include the referrer (even for http links) if we have one
  215. const {event, referrer, typedBonus} = action.data;
  216. if (referrer) {
  217. params.referrerPolicy = Ci.nsIHttpChannel.REFERRER_POLICY_UNSAFE_URL;
  218. params.referrerURI = Services.io.newURI(referrer);
  219. }
  220. // Pocket gives us a special reader URL to open their stories in
  221. const urlToOpen = action.data.type === "pocket" ? action.data.open_url : action.data.url;
  222. // Mark the page as typed for frecency bonus before opening the link
  223. if (typedBonus) {
  224. PlacesUtils.history.markPageAsTyped(Services.io.newURI(urlToOpen));
  225. }
  226. const win = action._target.browser.ownerGlobal;
  227. win.openLinkIn(urlToOpen, where || win.whereToOpenLink(event), params);
  228. }
  229. async saveToPocket(site, browser) {
  230. const {url, title} = site;
  231. try {
  232. let data = await NewTabUtils.activityStreamLinks.addPocketEntry(url, title, browser);
  233. if (data) {
  234. this.store.dispatch(ac.BroadcastToContent({
  235. type: at.PLACES_SAVED_TO_POCKET,
  236. data: {url, open_url: data.item.open_url, title, pocket_id: data.item.item_id},
  237. }));
  238. }
  239. } catch (err) {
  240. Cu.reportError(err);
  241. }
  242. }
  243. /**
  244. * Deletes an item from a user's saved to Pocket feed
  245. * @param {int} itemID
  246. * The unique ID given by Pocket for that item; used to look the item up when deleting
  247. */
  248. async deleteFromPocket(itemID) {
  249. try {
  250. await NewTabUtils.activityStreamLinks.deletePocketEntry(itemID);
  251. this.store.dispatch({type: at.POCKET_LINK_DELETED_OR_ARCHIVED});
  252. } catch (err) {
  253. Cu.reportError(err);
  254. }
  255. }
  256. /**
  257. * Archives an item from a user's saved to Pocket feed
  258. * @param {int} itemID
  259. * The unique ID given by Pocket for that item; used to look the item up when archiving
  260. */
  261. async archiveFromPocket(itemID) {
  262. try {
  263. await NewTabUtils.activityStreamLinks.archivePocketEntry(itemID);
  264. this.store.dispatch({type: at.POCKET_LINK_DELETED_OR_ARCHIVED});
  265. } catch (err) {
  266. Cu.reportError(err);
  267. }
  268. }
  269. fillSearchTopSiteTerm({_target, data}) {
  270. _target.browser.ownerGlobal.gURLBar.search(`${data.label} `);
  271. }
  272. _getSearchPrefix() {
  273. const searchAliases = Services.search.defaultEngine.wrappedJSObject.__internalAliases;
  274. if (searchAliases && searchAliases.length > 0) {
  275. return `${searchAliases[0]} `;
  276. }
  277. return "";
  278. }
  279. handoffSearchToAwesomebar({_target, data, meta}) {
  280. const searchAlias = this._getSearchPrefix();
  281. const urlBar = _target.browser.ownerGlobal.gURLBar;
  282. let isFirstChange = true;
  283. if (!data || !data.text) {
  284. urlBar.setHiddenFocus();
  285. } else {
  286. // Pass the provided text to the awesomebar. Prepend the @engine shortcut.
  287. urlBar.search(`${searchAlias}${data.text}`);
  288. isFirstChange = false;
  289. }
  290. const checkFirstChange = () => {
  291. // Check if this is the first change since we hidden focused. If it is,
  292. // remove hidden focus styles, prepend the search alias and hide the
  293. // in-content search.
  294. if (isFirstChange) {
  295. isFirstChange = false;
  296. urlBar.removeHiddenFocus();
  297. urlBar.search(searchAlias);
  298. this.store.dispatch(ac.OnlyToOneContent({type: at.HIDE_SEARCH}, meta.fromTarget));
  299. urlBar.removeEventListener("compositionstart", checkFirstChange);
  300. urlBar.removeEventListener("paste", checkFirstChange);
  301. }
  302. };
  303. const onKeydown = ev => {
  304. // Check if the keydown will cause a value change.
  305. if (ev.key.length === 1 && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
  306. checkFirstChange();
  307. }
  308. // If the Esc button is pressed, we are done. Show in-content search and cleanup.
  309. if (ev.key === "Escape") {
  310. onDone(); // eslint-disable-line no-use-before-define
  311. }
  312. };
  313. const onDone = () => {
  314. // We are done. Show in-content search again and cleanup.
  315. this.store.dispatch(ac.OnlyToOneContent({type: at.SHOW_SEARCH}, meta.fromTarget));
  316. urlBar.removeHiddenFocus();
  317. urlBar.removeEventListener("keydown", onKeydown);
  318. urlBar.removeEventListener("mousedown", onDone);
  319. urlBar.removeEventListener("blur", onDone);
  320. urlBar.removeEventListener("compositionstart", checkFirstChange);
  321. urlBar.removeEventListener("paste", checkFirstChange);
  322. };
  323. urlBar.addEventListener("keydown", onKeydown);
  324. urlBar.addEventListener("mousedown", onDone);
  325. urlBar.addEventListener("blur", onDone);
  326. urlBar.addEventListener("compositionstart", checkFirstChange);
  327. urlBar.addEventListener("paste", checkFirstChange);
  328. }
  329. onAction(action) {
  330. switch (action.type) {
  331. case at.INIT:
  332. // Briefly avoid loading services for observing for better startup timing
  333. Services.tm.dispatchToMainThread(() => this.addObservers());
  334. break;
  335. case at.UNINIT:
  336. this.removeObservers();
  337. break;
  338. case at.BLOCK_URL: {
  339. const {url, pocket_id} = action.data;
  340. NewTabUtils.activityStreamLinks.blockURL({url, pocket_id});
  341. break;
  342. }
  343. case at.BOOKMARK_URL:
  344. NewTabUtils.activityStreamLinks.addBookmark(action.data, action._target.browser.ownerGlobal);
  345. break;
  346. case at.DELETE_BOOKMARK_BY_ID:
  347. NewTabUtils.activityStreamLinks.deleteBookmark(action.data);
  348. break;
  349. case at.DELETE_HISTORY_URL: {
  350. const {url, forceBlock, pocket_id} = action.data;
  351. NewTabUtils.activityStreamLinks.deleteHistoryEntry(url);
  352. if (forceBlock) {
  353. NewTabUtils.activityStreamLinks.blockURL({url, pocket_id});
  354. }
  355. break;
  356. }
  357. case at.OPEN_NEW_WINDOW:
  358. this.openLink(action, "window");
  359. break;
  360. case at.OPEN_PRIVATE_WINDOW:
  361. this.openLink(action, "window", true);
  362. break;
  363. case at.SAVE_TO_POCKET:
  364. this.saveToPocket(action.data.site, action._target.browser);
  365. break;
  366. case at.DELETE_FROM_POCKET:
  367. this.deleteFromPocket(action.data.pocket_id);
  368. break;
  369. case at.ARCHIVE_FROM_POCKET:
  370. this.archiveFromPocket(action.data.pocket_id);
  371. break;
  372. case at.FILL_SEARCH_TERM:
  373. this.fillSearchTopSiteTerm(action);
  374. break;
  375. case at.HANDOFF_SEARCH_TO_AWESOMEBAR:
  376. this.handoffSearchToAwesomebar(action);
  377. break;
  378. case at.OPEN_LINK: {
  379. this.openLink(action);
  380. break;
  381. }
  382. }
  383. }
  384. }
  385. this.PlacesFeed = PlacesFeed;
  386. // Exported for testing only
  387. PlacesFeed.HistoryObserver = HistoryObserver;
  388. PlacesFeed.BookmarksObserver = BookmarksObserver;
  389. PlacesFeed.PlacesObserver = PlacesObserver;
  390. const EXPORTED_SYMBOLS = ["PlacesFeed"];