291 lines
9.6 KiB
JavaScript
291 lines
9.6 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/. */
|
|
|
|
const lazy = {};
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs",
|
|
PersonalityProvider:
|
|
"resource://newtab/lib/PersonalityProvider/PersonalityProvider.sys.mjs",
|
|
});
|
|
|
|
import {
|
|
actionTypes as at,
|
|
actionCreators as ac,
|
|
} from "resource://newtab/common/Actions.mjs";
|
|
|
|
const CACHE_KEY = "personalization";
|
|
const PREF_PERSONALIZATION_MODEL_KEYS =
|
|
"discoverystream.personalization.modelKeys";
|
|
const PREF_USER_TOPSTORIES = "feeds.section.topstories";
|
|
const PREF_SYSTEM_TOPSTORIES = "feeds.system.topstories";
|
|
const PREF_PERSONALIZATION = "discoverystream.personalization.enabled";
|
|
const MIN_PERSONALIZATION_UPDATE_TIME = 12 * 60 * 60 * 1000; // 12 hours
|
|
const PREF_PERSONALIZATION_OVERRIDE =
|
|
"discoverystream.personalization.override";
|
|
|
|
// The main purpose of this class is to handle interactions with the recommendation provider.
|
|
// A recommendation provider scores a list of stories, currently this is a personality provider.
|
|
// So all calls to the provider, anything involved with the setup of the provider,
|
|
// accessing prefs for the provider, or updaing devtools with provider state, is contained in here.
|
|
export class RecommendationProvider {
|
|
constructor() {
|
|
// Persistent cache for remote endpoint data.
|
|
this.cache = new lazy.PersistentCache(CACHE_KEY, true);
|
|
}
|
|
|
|
async setProvider(isStartup = false, scores) {
|
|
// A provider is already set. This can happen when new stories come in
|
|
// and we need to update their scores.
|
|
// We can use the existing one, a fresh one is created after startup.
|
|
// Using the existing one might be a bit out of date,
|
|
// but it's fine for now. We can rely on restarts for updates.
|
|
// See bug 1629931 for improvements to this.
|
|
if (!this.provider) {
|
|
this.provider = new lazy.PersonalityProvider(this.modelKeys);
|
|
this.provider.setScores(scores);
|
|
}
|
|
|
|
if (this.provider && this.provider.init) {
|
|
await this.provider.init();
|
|
this.store.dispatch(
|
|
ac.BroadcastToContent({
|
|
type: at.DISCOVERY_STREAM_PERSONALIZATION_INIT,
|
|
meta: {
|
|
isStartup,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
async enable(isStartup) {
|
|
await this.loadPersonalizationScoresCache(isStartup);
|
|
Services.obs.addObserver(this, "idle-daily");
|
|
this.loaded = true;
|
|
this.store.dispatch(
|
|
ac.BroadcastToContent({
|
|
type: at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED,
|
|
meta: {
|
|
isStartup,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
get showStories() {
|
|
// Combine user-set stories opt-out with Mozilla-set config
|
|
return (
|
|
this.store.getState().Prefs.values[PREF_SYSTEM_TOPSTORIES] &&
|
|
this.store.getState().Prefs.values[PREF_USER_TOPSTORIES]
|
|
);
|
|
}
|
|
|
|
get personalized() {
|
|
// If stories are not displayed, no point in trying to personalize them.
|
|
if (!this.showStories) {
|
|
return false;
|
|
}
|
|
const spocsPersonalized =
|
|
this.store.getState().Prefs.values?.pocketConfig?.spocsPersonalized;
|
|
const recsPersonalized =
|
|
this.store.getState().Prefs.values?.pocketConfig?.recsPersonalized;
|
|
const personalization =
|
|
this.store.getState().Prefs.values[PREF_PERSONALIZATION];
|
|
|
|
// There is a server sent flag to keep personalization on.
|
|
// If the server stops sending this, we turn personalization off,
|
|
// until the server starts returning the signal.
|
|
const overrideState =
|
|
this.store.getState().Prefs.values[PREF_PERSONALIZATION_OVERRIDE];
|
|
|
|
return (
|
|
personalization &&
|
|
!overrideState &&
|
|
(spocsPersonalized || recsPersonalized)
|
|
);
|
|
}
|
|
|
|
get modelKeys() {
|
|
if (!this._modelKeys) {
|
|
this._modelKeys =
|
|
this.store.getState().Prefs.values[PREF_PERSONALIZATION_MODEL_KEYS];
|
|
}
|
|
|
|
return this._modelKeys;
|
|
}
|
|
|
|
/*
|
|
* This creates a new recommendationProvider using fresh data,
|
|
* It's run on a last updated timer. This is the opposite of loadPersonalizationScoresCache.
|
|
* This is also much slower so we only trigger this in the background on idle-daily.
|
|
* It causes new profiles to pick up personalization slowly because the first time
|
|
* a new profile is run you don't have any old cache to use, so it needs to wait for the first
|
|
* idle-daily. Older profiles can rely on cache during the idle-daily gap. Idle-daily is
|
|
* usually run once every 24 hours.
|
|
*/
|
|
async updatePersonalizationScores() {
|
|
if (
|
|
!this.personalized ||
|
|
Date.now() - this.personalizationLastUpdated <
|
|
MIN_PERSONALIZATION_UPDATE_TIME
|
|
) {
|
|
return;
|
|
}
|
|
|
|
await this.setProvider();
|
|
|
|
const personalization = { scores: this.provider.getScores() };
|
|
this.personalizationLastUpdated = Date.now();
|
|
|
|
this.store.dispatch(
|
|
ac.BroadcastToContent({
|
|
type: at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED,
|
|
data: {
|
|
lastUpdated: this.personalizationLastUpdated,
|
|
},
|
|
})
|
|
);
|
|
personalization._timestamp = this.personalizationLastUpdated;
|
|
this.cache.set("personalization", personalization);
|
|
}
|
|
|
|
/*
|
|
* This just re hydrates the provider from cache.
|
|
* We can call this on startup because it's generally fast.
|
|
* It reports to devtools the last time the data in the cache was updated.
|
|
*/
|
|
async loadPersonalizationScoresCache(isStartup = false) {
|
|
const cachedData = (await this.cache.get()) || {};
|
|
const { personalization } = cachedData;
|
|
|
|
if (this.personalized && personalization?.scores) {
|
|
await this.setProvider(isStartup, personalization.scores);
|
|
|
|
this.personalizationLastUpdated = personalization._timestamp;
|
|
|
|
this.store.dispatch(
|
|
ac.BroadcastToContent({
|
|
type: at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED,
|
|
data: {
|
|
lastUpdated: this.personalizationLastUpdated,
|
|
},
|
|
meta: {
|
|
isStartup,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
// This turns personalization on/off if the server sends the override command.
|
|
// The server sends a true signal to keep personalization on. So a malfunctioning
|
|
// server would more likely mistakenly turn off personalization, and not turn it on.
|
|
// This is safer, because the override is for cases where personalization is causing issues.
|
|
// So having it mistakenly go off is safe, but it mistakenly going on could be bad.
|
|
personalizationOverride(overrideCommand) {
|
|
// Are we currently in an override state.
|
|
// This is useful to know if we want to do a cleanup.
|
|
const overrideState =
|
|
this.store.getState().Prefs.values[PREF_PERSONALIZATION_OVERRIDE];
|
|
|
|
// Is this profile currently set to be personalized.
|
|
const personalization =
|
|
this.store.getState().Prefs.values[PREF_PERSONALIZATION];
|
|
|
|
// If we have an override command, profile is currently personalized,
|
|
// and is not currently being overridden, we can set the override pref.
|
|
if (overrideCommand && personalization && !overrideState) {
|
|
this.store.dispatch(ac.SetPref(PREF_PERSONALIZATION_OVERRIDE, true));
|
|
}
|
|
|
|
// This is if we need to revert an override and do cleanup.
|
|
// We do this if we are in an override state,
|
|
// but not currently receiving the override signal.
|
|
if (!overrideCommand && overrideState) {
|
|
this.store.dispatch({
|
|
type: at.CLEAR_PREF,
|
|
data: { name: PREF_PERSONALIZATION_OVERRIDE },
|
|
});
|
|
}
|
|
}
|
|
|
|
async calculateItemRelevanceScore(item) {
|
|
if (this.provider) {
|
|
const scoreResult = await this.provider.calculateItemRelevanceScore(item);
|
|
if (scoreResult === 0 || scoreResult) {
|
|
item.score = scoreResult;
|
|
}
|
|
}
|
|
}
|
|
|
|
teardown() {
|
|
if (this.provider && this.provider.teardown) {
|
|
// This removes any in memory listeners if available.
|
|
this.provider.teardown();
|
|
}
|
|
if (this.loaded) {
|
|
Services.obs.removeObserver(this, "idle-daily");
|
|
}
|
|
this.loaded = false;
|
|
}
|
|
|
|
async resetState() {
|
|
this._modelKeys = null;
|
|
this.personalizationLastUpdated = null;
|
|
this.provider = null;
|
|
await this.cache.set("personalization", {});
|
|
this.store.dispatch(
|
|
ac.OnlyToMain({
|
|
type: at.DISCOVERY_STREAM_PERSONALIZATION_RESET,
|
|
})
|
|
);
|
|
}
|
|
|
|
async observe(subject, topic) {
|
|
switch (topic) {
|
|
case "idle-daily":
|
|
await this.updatePersonalizationScores();
|
|
this.store.dispatch(
|
|
ac.BroadcastToContent({
|
|
type: at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED,
|
|
})
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
async onAction(action) {
|
|
switch (action.type) {
|
|
case at.INIT:
|
|
await this.enable(true /* isStartup */);
|
|
break;
|
|
case at.DISCOVERY_STREAM_CONFIG_CHANGE:
|
|
this.teardown();
|
|
await this.resetState();
|
|
await this.enable();
|
|
break;
|
|
case at.DISCOVERY_STREAM_DEV_IDLE_DAILY:
|
|
Services.obs.notifyObservers(null, "idle-daily");
|
|
break;
|
|
case at.PREF_CHANGED:
|
|
switch (action.data.name) {
|
|
case PREF_PERSONALIZATION_MODEL_KEYS:
|
|
this.store.dispatch(
|
|
ac.BroadcastToContent({
|
|
type: at.DISCOVERY_STREAM_CONFIG_RESET,
|
|
})
|
|
);
|
|
break;
|
|
}
|
|
break;
|
|
case at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE:
|
|
let enabled = this.store.getState().Prefs.values[PREF_PERSONALIZATION];
|
|
this.store.dispatch(ac.SetPref(PREF_PERSONALIZATION, !enabled));
|
|
break;
|
|
case at.DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE:
|
|
this.personalizationOverride(action.data.override);
|
|
break;
|
|
}
|
|
}
|
|
}
|