gecko-dev/browser/components/newtab/lib/PlacesFeed.jsm

556 строки
16 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { actionCreators: ac, actionTypes: at } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PartnerLinkAttribution",
"resource:///modules/PartnerLinkAttribution.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm"
);
const LINK_BLOCKED_EVENT = "newtab-linkBlocked";
const PLACES_LINKS_CHANGED_DELAY_TIME = 1000; // time in ms to delay timer for places links changed events
/**
* Observer - a wrapper around history/bookmark observers to add the QueryInterface.
*/
class Observer {
constructor(dispatch, observerInterface) {
this.dispatch = dispatch;
this.QueryInterface = ChromeUtils.generateQI([
observerInterface,
"nsISupportsWeakReference",
]);
}
}
/**
* HistoryObserver - observes events from PlacesUtils.history
*/
class HistoryObserver extends Observer {
constructor(dispatch) {
super(dispatch, Ci.nsINavHistoryObserver);
}
/**
* onDeleteURI - Called when an link is deleted from history.
*
* @param {obj} uri A URI object representing the link's url
* {str} uri.spec The URI as a string
*/
onDeleteURI(uri) {
this.dispatch({ type: at.PLACES_LINKS_CHANGED });
this.dispatch({
type: at.PLACES_LINK_DELETED,
data: { url: uri.spec },
});
}
/**
* onClearHistory - Called when the user clears their entire history.
*/
onClearHistory() {
this.dispatch({ type: at.PLACES_HISTORY_CLEARED });
}
// Empty functions to make xpconnect happy
onBeginUpdateBatch() {}
onEndUpdateBatch() {}
onTitleChanged() {}
onFrecencyChanged() {}
onManyFrecenciesChanged() {}
onPageChanged() {}
onDeleteVisits() {}
}
/**
* BookmarksObserver - observes events from PlacesUtils.bookmarks
*/
class BookmarksObserver extends Observer {
constructor(dispatch) {
super(dispatch, Ci.nsINavBookmarkObserver);
this.skipTags = true;
}
// Empty functions to make xpconnect happy
onBeginUpdateBatch() {}
onEndUpdateBatch() {}
onItemVisited() {}
onItemMoved() {}
// Disabled due to performance cost, see Issue 3203 /
// https://bugzilla.mozilla.org/show_bug.cgi?id=1392267.
onItemChanged() {}
}
/**
* PlacesObserver - observes events from PlacesUtils.observers
*/
class PlacesObserver extends Observer {
constructor(dispatch) {
super(dispatch, Ci.nsINavBookmarkObserver);
this.handlePlacesEvent = this.handlePlacesEvent.bind(this);
}
handlePlacesEvent(events) {
for (let {
itemType,
source,
dateAdded,
guid,
title,
url,
isTagging,
type,
} of events) {
switch (type) {
case "bookmark-added":
// Skips items that are not bookmarks (like folders), about:* pages or
// default bookmarks, added when the profile is created.
if (
isTagging ||
itemType !== PlacesUtils.bookmarks.TYPE_BOOKMARK ||
source === PlacesUtils.bookmarks.SOURCES.IMPORT ||
source === PlacesUtils.bookmarks.SOURCES.RESTORE ||
source === PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP ||
source === PlacesUtils.bookmarks.SOURCES.SYNC ||
(!url.startsWith("http://") && !url.startsWith("https://"))
) {
return;
}
this.dispatch({ type: at.PLACES_LINKS_CHANGED });
this.dispatch({
type: at.PLACES_BOOKMARK_ADDED,
data: {
bookmarkGuid: guid,
bookmarkTitle: title,
dateAdded: dateAdded * 1000,
url,
},
});
break;
case "bookmark-removed":
if (
isTagging ||
(itemType === PlacesUtils.bookmarks.TYPE_BOOKMARK &&
source !== PlacesUtils.bookmarks.SOURCES.IMPORT &&
source !== PlacesUtils.bookmarks.SOURCES.RESTORE &&
source !== PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP &&
source !== PlacesUtils.bookmarks.SOURCES.SYNC)
) {
this.dispatch({ type: at.PLACES_LINKS_CHANGED });
this.dispatch({
type: at.PLACES_BOOKMARK_REMOVED,
data: { url, bookmarkGuid: guid },
});
}
break;
}
}
}
}
class PlacesFeed {
constructor() {
this.placesChangedTimer = null;
this.customDispatch = this.customDispatch.bind(this);
this.historyObserver = new HistoryObserver(this.customDispatch);
this.bookmarksObserver = new BookmarksObserver(this.customDispatch);
this.placesObserver = new PlacesObserver(this.customDispatch);
}
addObservers() {
// NB: Directly get services without importing the *BIG* PlacesUtils module
Cc["@mozilla.org/browser/nav-history-service;1"]
.getService(Ci.nsINavHistoryService)
.addObserver(this.historyObserver, true);
Cc["@mozilla.org/browser/nav-bookmarks-service;1"]
.getService(Ci.nsINavBookmarksService)
.addObserver(this.bookmarksObserver, true);
PlacesUtils.observers.addListener(
["bookmark-added", "bookmark-removed"],
this.placesObserver.handlePlacesEvent
);
Services.obs.addObserver(this, LINK_BLOCKED_EVENT);
}
/**
* setTimeout - A custom function that creates an nsITimer that can be cancelled
*
* @param {func} callback A function to be executed after the timer expires
* @param {int} delay The time (in ms) the timer should wait before the function is executed
*/
setTimeout(callback, delay) {
let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
timer.initWithCallback(callback, delay, Ci.nsITimer.TYPE_ONE_SHOT);
return timer;
}
customDispatch(action) {
// If we are changing many links at once, delay this action and only dispatch
// one action at the end
if (action.type === at.PLACES_LINKS_CHANGED) {
if (this.placesChangedTimer) {
this.placesChangedTimer.delay = PLACES_LINKS_CHANGED_DELAY_TIME;
} else {
this.placesChangedTimer = this.setTimeout(() => {
this.placesChangedTimer = null;
this.store.dispatch(ac.OnlyToMain(action));
}, PLACES_LINKS_CHANGED_DELAY_TIME);
}
} else {
this.store.dispatch(ac.BroadcastToContent(action));
}
}
removeObservers() {
if (this.placesChangedTimer) {
this.placesChangedTimer.cancel();
this.placesChangedTimer = null;
}
PlacesUtils.history.removeObserver(this.historyObserver);
PlacesUtils.bookmarks.removeObserver(this.bookmarksObserver);
PlacesUtils.observers.removeListener(
["bookmark-added", "bookmark-removed"],
this.placesObserver.handlePlacesEvent
);
Services.obs.removeObserver(this, LINK_BLOCKED_EVENT);
}
/**
* observe - An observer for the LINK_BLOCKED_EVENT.
* Called when a link is blocked.
* Links can be blocked outside of newtab,
* which is why we need to listen to this
* on such a generic level.
*
* @param {null} subject
* @param {str} topic The name of the event
* @param {str} value The data associated with the event
*/
observe(subject, topic, value) {
if (topic === LINK_BLOCKED_EVENT) {
this.store.dispatch(
ac.BroadcastToContent({
type: at.PLACES_LINK_BLOCKED,
data: { url: value },
})
);
}
}
/**
* Open a link in a desired destination defaulting to action's event.
*/
openLink(action, where = "", isPrivate = false) {
const params = {
private: isPrivate,
targetBrowser: action._target.browser,
fromChrome: false, // This ensure we maintain user preference for how to open new tabs.
};
// Always include the referrer (even for http links) if we have one
const { event, referrer, typedBonus } = action.data;
if (referrer) {
const ReferrerInfo = Components.Constructor(
"@mozilla.org/referrer-info;1",
"nsIReferrerInfo",
"init"
);
params.referrerInfo = new ReferrerInfo(
Ci.nsIReferrerInfo.UNSAFE_URL,
true,
Services.io.newURI(referrer)
);
}
// Pocket gives us a special reader URL to open their stories in
const urlToOpen =
action.data.type === "pocket" ? action.data.open_url : action.data.url;
try {
let uri = Services.io.newURI(urlToOpen);
if (!["http", "https"].includes(uri.scheme)) {
throw new Error(
`Can't open link using ${uri.scheme} protocol from the new tab page.`
);
}
} catch (e) {
Cu.reportError(e);
return;
}
// Mark the page as typed for frecency bonus before opening the link
if (typedBonus) {
PlacesUtils.history.markPageAsTyped(Services.io.newURI(urlToOpen));
}
const win = action._target.browser.ownerGlobal;
win.openTrustedLinkIn(
urlToOpen,
where || win.whereToOpenLink(event),
params
);
// If there's an original URL e.g. using the unprocessed %YYYYMMDDHH% tag,
// add a visit for that so it may become a frecent top site.
if (action.data.original_url) {
PlacesUtils.history.insert({
url: action.data.original_url,
visits: [{ transition: PlacesUtils.history.TRANSITION_TYPED }],
});
}
}
async saveToPocket(site, browser) {
const { url, title } = site;
try {
let data = await NewTabUtils.activityStreamLinks.addPocketEntry(
url,
title,
browser
);
if (data) {
this.store.dispatch(
ac.BroadcastToContent({
type: at.PLACES_SAVED_TO_POCKET,
data: {
url,
open_url: data.item.open_url,
title,
pocket_id: data.item.item_id,
},
})
);
}
} catch (err) {
Cu.reportError(err);
}
}
/**
* Deletes an item from a user's saved to Pocket feed
* @param {int} itemID
* The unique ID given by Pocket for that item; used to look the item up when deleting
*/
async deleteFromPocket(itemID) {
try {
await NewTabUtils.activityStreamLinks.deletePocketEntry(itemID);
this.store.dispatch({ type: at.POCKET_LINK_DELETED_OR_ARCHIVED });
} catch (err) {
Cu.reportError(err);
}
}
/**
* Archives an item from a user's saved to Pocket feed
* @param {int} itemID
* The unique ID given by Pocket for that item; used to look the item up when archiving
*/
async archiveFromPocket(itemID) {
try {
await NewTabUtils.activityStreamLinks.archivePocketEntry(itemID);
this.store.dispatch({ type: at.POCKET_LINK_DELETED_OR_ARCHIVED });
} catch (err) {
Cu.reportError(err);
}
}
fillSearchTopSiteTerm({ _target, data }) {
_target.browser.ownerGlobal.gURLBar.search(data.label, {
searchModeEntry: "topsites_newtab",
});
}
_getSearchPrefix(isPrivateWindow) {
const searchAliases =
Services.search[
isPrivateWindow ? "defaultPrivateEngine" : "defaultEngine"
].aliases;
if (searchAliases && searchAliases.length) {
return `${searchAliases[0]} `;
}
return "";
}
handoffSearchToAwesomebar({ _target, data, meta }) {
const searchAlias = this._getSearchPrefix(
PrivateBrowsingUtils.isBrowserPrivate(_target.browser)
);
const urlBar = _target.browser.ownerGlobal.gURLBar;
let isFirstChange = true;
if (!data || !data.text) {
urlBar.setHiddenFocus();
} else {
urlBar.search(searchAlias + data.text, {
searchModeEntry: "handoff",
});
isFirstChange = false;
}
const checkFirstChange = () => {
// Check if this is the first change since we hidden focused. If it is,
// remove hidden focus styles, prepend the search alias and hide the
// in-content search.
if (isFirstChange) {
isFirstChange = false;
urlBar.removeHiddenFocus();
urlBar.search(searchAlias, { searchModeEntry: "handoff" });
this.store.dispatch(
ac.OnlyToOneContent({ type: at.HIDE_SEARCH }, meta.fromTarget)
);
urlBar.removeEventListener("compositionstart", checkFirstChange);
urlBar.removeEventListener("paste", checkFirstChange);
}
};
const onKeydown = ev => {
// Check if the keydown will cause a value change.
if (ev.key.length === 1 && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
checkFirstChange();
}
// If the Esc button is pressed, we are done. Show in-content search and cleanup.
if (ev.key === "Escape") {
onDone(); // eslint-disable-line no-use-before-define
}
};
const onDone = () => {
// We are done. Show in-content search again and cleanup.
this.store.dispatch(
ac.OnlyToOneContent({ type: at.SHOW_SEARCH }, meta.fromTarget)
);
urlBar.removeHiddenFocus();
urlBar.removeEventListener("keydown", onKeydown);
urlBar.removeEventListener("mousedown", onDone);
urlBar.removeEventListener("blur", onDone);
urlBar.removeEventListener("compositionstart", checkFirstChange);
urlBar.removeEventListener("paste", checkFirstChange);
};
urlBar.addEventListener("keydown", onKeydown);
urlBar.addEventListener("mousedown", onDone);
urlBar.addEventListener("blur", onDone);
urlBar.addEventListener("compositionstart", checkFirstChange);
urlBar.addEventListener("paste", checkFirstChange);
}
onAction(action) {
switch (action.type) {
case at.INIT:
// Briefly avoid loading services for observing for better startup timing
Services.tm.dispatchToMainThread(() => this.addObservers());
break;
case at.UNINIT:
this.removeObservers();
break;
case at.ABOUT_SPONSORED_TOP_SITES: {
const url = `${Services.urlFormatter.formatURLPref(
"app.support.baseURL"
)}sponsor-privacy`;
const win = action._target.browser.ownerGlobal;
win.openTrustedLinkIn(url, "tab");
break;
}
case at.BLOCK_URL: {
if (action.data) {
action.data.forEach(site => {
const { url, pocket_id } = site;
NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id });
});
}
break;
}
case at.BOOKMARK_URL:
NewTabUtils.activityStreamLinks.addBookmark(
action.data,
action._target.browser.ownerGlobal
);
break;
case at.DELETE_BOOKMARK_BY_ID:
NewTabUtils.activityStreamLinks.deleteBookmark(action.data);
break;
case at.DELETE_HISTORY_URL: {
const { url, forceBlock, pocket_id } = action.data;
NewTabUtils.activityStreamLinks.deleteHistoryEntry(url);
if (forceBlock) {
NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id });
}
break;
}
case at.OPEN_NEW_WINDOW:
this.openLink(action, "window");
break;
case at.OPEN_PRIVATE_WINDOW:
this.openLink(action, "window", true);
break;
case at.SAVE_TO_POCKET:
this.saveToPocket(action.data.site, action._target.browser);
break;
case at.DELETE_FROM_POCKET:
this.deleteFromPocket(action.data.pocket_id);
break;
case at.ARCHIVE_FROM_POCKET:
this.archiveFromPocket(action.data.pocket_id);
break;
case at.FILL_SEARCH_TERM:
this.fillSearchTopSiteTerm(action);
break;
case at.HANDOFF_SEARCH_TO_AWESOMEBAR:
this.handoffSearchToAwesomebar(action);
break;
case at.OPEN_LINK: {
this.openLink(action);
break;
}
case at.PARTNER_LINK_ATTRIBUTION:
PartnerLinkAttribution.makeRequest(action.data);
break;
}
}
}
this.PlacesFeed = PlacesFeed;
// Exported for testing only
PlacesFeed.HistoryObserver = HistoryObserver;
PlacesFeed.BookmarksObserver = BookmarksObserver;
PlacesFeed.PlacesObserver = PlacesObserver;
const EXPORTED_SYMBOLS = ["PlacesFeed"];