diff options
Diffstat (limited to 'browser/components/newtab/common/Reducers.sys.mjs')
-rw-r--r-- | browser/components/newtab/common/Reducers.sys.mjs | 855 |
1 files changed, 855 insertions, 0 deletions
diff --git a/browser/components/newtab/common/Reducers.sys.mjs b/browser/components/newtab/common/Reducers.sys.mjs new file mode 100644 index 0000000000..d4f879b834 --- /dev/null +++ b/browser/components/newtab/common/Reducers.sys.mjs @@ -0,0 +1,855 @@ +/* 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/. */ + +import { actionTypes as at } from "resource://activity-stream/common/Actions.sys.mjs"; +import { Dedupe } from "resource://activity-stream/common/Dedupe.sys.mjs"; + +export const TOP_SITES_DEFAULT_ROWS = 1; +export const TOP_SITES_MAX_SITES_PER_ROW = 8; +const PREF_COLLECTION_DISMISSIBLE = "discoverystream.isCollectionDismissible"; + +const dedupe = new Dedupe(site => site && site.url); + +export const INITIAL_STATE = { + App: { + // Have we received real data from the app yet? + initialized: false, + locale: "", + isForStartupCache: false, + customizeMenuVisible: false, + }, + ASRouter: { initialized: false }, + TopSites: { + // Have we received real data from history yet? + initialized: false, + // The history (and possibly default) links + rows: [], + // Used in content only to dispatch action to TopSiteForm. + editForm: null, + // Used in content only to open the SearchShortcutsForm modal. + showSearchShortcutsForm: false, + // The list of available search shortcuts. + searchShortcuts: [], + // The "Share-of-Voice" allocations generated by TopSitesFeed + sov: { + ready: false, + positions: [ + // {position: 0, assignedPartner: "amp"}, + // {position: 1, assignedPartner: "moz-sales"}, + ], + }, + }, + Prefs: { + initialized: false, + values: { featureConfig: {} }, + }, + Dialog: { + visible: false, + data: {}, + }, + Sections: [], + Pocket: { + isUserLoggedIn: null, + pocketCta: {}, + waitingForSpoc: true, + }, + // This is the new pocket configurable layout state. + DiscoveryStream: { + // This is a JSON-parsed copy of the discoverystream.config pref value. + config: { enabled: false }, + layout: [], + isPrivacyInfoModalVisible: false, + isCollectionDismissible: false, + feeds: { + data: { + // "https://foo.com/feed1": {lastUpdated: 123, data: [], personalized: false} + }, + loaded: false, + }, + spocs: { + spocs_endpoint: "", + lastUpdated: null, + data: { + // "spocs": {title: "", context: "", items: [], personalized: false}, + // "placement1": {title: "", context: "", items: [], personalized: false}, + }, + loaded: false, + frequency_caps: [], + blocked: [], + placements: [], + }, + experimentData: { + utmSource: "pocket-newtab", + utmCampaign: undefined, + utmContent: undefined, + }, + recentSavesData: [], + isUserLoggedIn: false, + recentSavesEnabled: false, + }, + Personalization: { + lastUpdated: null, + initialized: false, + }, + Search: { + // When search hand-off is enabled, we render a big button that is styled to + // look like a search textbox. If the button is clicked, we style + // the button as if it was a focused search box and show a fake cursor but + // really focus the awesomebar without the focus styles ("hidden focus"). + fakeFocus: false, + // Hide the search box after handing off to AwesomeBar and user starts typing. + hide: false, + }, +}; + +function App(prevState = INITIAL_STATE.App, action) { + switch (action.type) { + case at.INIT: + return Object.assign({}, prevState, action.data || {}, { + initialized: true, + }); + case at.TOP_SITES_UPDATED: + // Toggle `isForStartupCache` when receiving the `TOP_SITES_UPDATE` action + // so that sponsored tiles can be rendered as usual. See Bug 1826360. + return Object.assign({}, prevState, action.data || {}, { + isForStartupCache: false, + }); + case at.SHOW_PERSONALIZE: + return Object.assign({}, prevState, { + customizeMenuVisible: true, + }); + case at.HIDE_PERSONALIZE: + return Object.assign({}, prevState, { + customizeMenuVisible: false, + }); + default: + return prevState; + } +} + +function ASRouter(prevState = INITIAL_STATE.ASRouter, action) { + switch (action.type) { + case at.AS_ROUTER_INITIALIZED: + return { ...action.data, initialized: true }; + default: + return prevState; + } +} + +/** + * insertPinned - Inserts pinned links in their specified slots + * + * @param {array} a list of links + * @param {array} a list of pinned links + * @return {array} resulting list of links with pinned links inserted + */ +export function insertPinned(links, pinned) { + // Remove any pinned links + const pinnedUrls = pinned.map(link => link && link.url); + let newLinks = links.filter(link => + link ? !pinnedUrls.includes(link.url) : false + ); + newLinks = newLinks.map(link => { + if (link && link.isPinned) { + delete link.isPinned; + delete link.pinIndex; + } + return link; + }); + + // Then insert them in their specified location + pinned.forEach((val, index) => { + if (!val) { + return; + } + let link = Object.assign({}, val, { isPinned: true, pinIndex: index }); + if (index > newLinks.length) { + newLinks[index] = link; + } else { + newLinks.splice(index, 0, link); + } + }); + + return newLinks; +} + +function TopSites(prevState = INITIAL_STATE.TopSites, action) { + let hasMatch; + let newRows; + switch (action.type) { + case at.TOP_SITES_UPDATED: + if (!action.data || !action.data.links) { + return prevState; + } + return Object.assign( + {}, + prevState, + { initialized: true, rows: action.data.links }, + action.data.pref ? { pref: action.data.pref } : {} + ); + case at.TOP_SITES_PREFS_UPDATED: + return Object.assign({}, prevState, { pref: action.data.pref }); + case at.TOP_SITES_EDIT: + return Object.assign({}, prevState, { + editForm: { + index: action.data.index, + previewResponse: null, + }, + }); + case at.TOP_SITES_CANCEL_EDIT: + return Object.assign({}, prevState, { editForm: null }); + case at.TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL: + return Object.assign({}, prevState, { showSearchShortcutsForm: true }); + case at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL: + return Object.assign({}, prevState, { showSearchShortcutsForm: false }); + case at.PREVIEW_RESPONSE: + if ( + !prevState.editForm || + action.data.url !== prevState.editForm.previewUrl + ) { + return prevState; + } + return Object.assign({}, prevState, { + editForm: { + index: prevState.editForm.index, + previewResponse: action.data.preview, + previewUrl: action.data.url, + }, + }); + case at.PREVIEW_REQUEST: + if (!prevState.editForm) { + return prevState; + } + return Object.assign({}, prevState, { + editForm: { + index: prevState.editForm.index, + previewResponse: null, + previewUrl: action.data.url, + }, + }); + case at.PREVIEW_REQUEST_CANCEL: + if (!prevState.editForm) { + return prevState; + } + return Object.assign({}, prevState, { + editForm: { + index: prevState.editForm.index, + previewResponse: null, + }, + }); + case at.SCREENSHOT_UPDATED: + newRows = prevState.rows.map(row => { + if (row && row.url === action.data.url) { + hasMatch = true; + return Object.assign({}, row, { screenshot: action.data.screenshot }); + } + return row; + }); + return hasMatch + ? Object.assign({}, prevState, { rows: newRows }) + : prevState; + case at.PLACES_BOOKMARK_ADDED: + if (!action.data) { + return prevState; + } + newRows = prevState.rows.map(site => { + if (site && site.url === action.data.url) { + const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data; + return Object.assign({}, site, { + bookmarkGuid, + bookmarkTitle, + bookmarkDateCreated: dateAdded, + }); + } + return site; + }); + return Object.assign({}, prevState, { rows: newRows }); + case at.PLACES_BOOKMARKS_REMOVED: + if (!action.data) { + return prevState; + } + newRows = prevState.rows.map(site => { + if (site && action.data.urls.includes(site.url)) { + const newSite = Object.assign({}, site); + delete newSite.bookmarkGuid; + delete newSite.bookmarkTitle; + delete newSite.bookmarkDateCreated; + return newSite; + } + return site; + }); + return Object.assign({}, prevState, { rows: newRows }); + case at.PLACES_LINKS_DELETED: + if (!action.data) { + return prevState; + } + newRows = prevState.rows.filter( + site => !action.data.urls.includes(site.url) + ); + return Object.assign({}, prevState, { rows: newRows }); + case at.UPDATE_SEARCH_SHORTCUTS: + return { ...prevState, searchShortcuts: action.data.searchShortcuts }; + case at.SOV_UPDATED: + const sov = { + ready: action.data.ready, + positions: action.data.positions, + }; + return { ...prevState, sov }; + default: + return prevState; + } +} + +function Dialog(prevState = INITIAL_STATE.Dialog, action) { + switch (action.type) { + case at.DIALOG_OPEN: + return Object.assign({}, prevState, { visible: true, data: action.data }); + case at.DIALOG_CANCEL: + return Object.assign({}, prevState, { visible: false }); + case at.DELETE_HISTORY_URL: + return Object.assign({}, INITIAL_STATE.Dialog); + default: + return prevState; + } +} + +function Prefs(prevState = INITIAL_STATE.Prefs, action) { + let newValues; + switch (action.type) { + case at.PREFS_INITIAL_VALUES: + return Object.assign({}, prevState, { + initialized: true, + values: action.data, + }); + case at.PREF_CHANGED: + newValues = Object.assign({}, prevState.values); + newValues[action.data.name] = action.data.value; + return Object.assign({}, prevState, { values: newValues }); + default: + return prevState; + } +} + +function Sections(prevState = INITIAL_STATE.Sections, action) { + let hasMatch; + let newState; + switch (action.type) { + case at.SECTION_DEREGISTER: + return prevState.filter(section => section.id !== action.data); + case at.SECTION_REGISTER: + // If section exists in prevState, update it + newState = prevState.map(section => { + if (section && section.id === action.data.id) { + hasMatch = true; + return Object.assign({}, section, action.data); + } + return section; + }); + // Otherwise, append it + if (!hasMatch) { + const initialized = !!(action.data.rows && !!action.data.rows.length); + const section = Object.assign( + { title: "", rows: [], enabled: false }, + action.data, + { initialized } + ); + newState.push(section); + } + return newState; + case at.SECTION_UPDATE: + newState = prevState.map(section => { + if (section && section.id === action.data.id) { + // If the action is updating rows, we should consider initialized to be true. + // This can be overridden if initialized is defined in the action.data + const initialized = action.data.rows ? { initialized: true } : {}; + + // Make sure pinned cards stay at their current position when rows are updated. + // Disabling a section (SECTION_UPDATE with empty rows) does not retain pinned cards. + if ( + action.data.rows && + !!action.data.rows.length && + section.rows.find(card => card.pinned) + ) { + const rows = Array.from(action.data.rows); + section.rows.forEach((card, index) => { + if (card.pinned) { + // Only add it if it's not already there. + if (rows[index].guid !== card.guid) { + rows.splice(index, 0, card); + } + } + }); + return Object.assign( + {}, + section, + initialized, + Object.assign({}, action.data, { rows }) + ); + } + + return Object.assign({}, section, initialized, action.data); + } + return section; + }); + + if (!action.data.dedupeConfigurations) { + return newState; + } + + action.data.dedupeConfigurations.forEach(dedupeConf => { + newState = newState.map(section => { + if (section.id === dedupeConf.id) { + const dedupedRows = dedupeConf.dedupeFrom.reduce( + (rows, dedupeSectionId) => { + const dedupeSection = newState.find( + s => s.id === dedupeSectionId + ); + const [, newRows] = dedupe.group(dedupeSection.rows, rows); + return newRows; + }, + section.rows + ); + + return Object.assign({}, section, { rows: dedupedRows }); + } + + return section; + }); + }); + + return newState; + case at.SECTION_UPDATE_CARD: + return prevState.map(section => { + if (section && section.id === action.data.id && section.rows) { + const newRows = section.rows.map(card => { + if (card.url === action.data.url) { + return Object.assign({}, card, action.data.options); + } + return card; + }); + return Object.assign({}, section, { rows: newRows }); + } + return section; + }); + case at.PLACES_BOOKMARK_ADDED: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.map(item => { + // find the item within the rows that is attempted to be bookmarked + if (item.url === action.data.url) { + const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data; + return Object.assign({}, item, { + bookmarkGuid, + bookmarkTitle, + bookmarkDateCreated: dateAdded, + type: "bookmark", + }); + } + return item; + }), + }) + ); + case at.PLACES_SAVED_TO_POCKET: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.map(item => { + if (item.url === action.data.url) { + return Object.assign({}, item, { + open_url: action.data.open_url, + pocket_id: action.data.pocket_id, + title: action.data.title, + type: "pocket", + }); + } + return item; + }), + }) + ); + case at.PLACES_BOOKMARKS_REMOVED: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.map(item => { + // find the bookmark within the rows that is attempted to be removed + if (action.data.urls.includes(item.url)) { + const newSite = Object.assign({}, item); + delete newSite.bookmarkGuid; + delete newSite.bookmarkTitle; + delete newSite.bookmarkDateCreated; + if (!newSite.type || newSite.type === "bookmark") { + newSite.type = "history"; + } + return newSite; + } + return item; + }), + }) + ); + case at.PLACES_LINKS_DELETED: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.filter( + site => !action.data.urls.includes(site.url) + ), + }) + ); + case at.PLACES_LINK_BLOCKED: + if (!action.data) { + return prevState; + } + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.filter(site => site.url !== action.data.url), + }) + ); + case at.DELETE_FROM_POCKET: + case at.ARCHIVE_FROM_POCKET: + return prevState.map(section => + Object.assign({}, section, { + rows: section.rows.filter( + site => site.pocket_id !== action.data.pocket_id + ), + }) + ); + default: + return prevState; + } +} + +function Pocket(prevState = INITIAL_STATE.Pocket, action) { + switch (action.type) { + case at.POCKET_WAITING_FOR_SPOC: + return { ...prevState, waitingForSpoc: action.data }; + case at.POCKET_LOGGED_IN: + return { ...prevState, isUserLoggedIn: !!action.data }; + case at.POCKET_CTA: + return { + ...prevState, + pocketCta: { + ctaButton: action.data.cta_button, + ctaText: action.data.cta_text, + ctaUrl: action.data.cta_url, + useCta: action.data.use_cta, + }, + }; + default: + return prevState; + } +} + +function Personalization(prevState = INITIAL_STATE.Personalization, action) { + switch (action.type) { + case at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED: + return { + ...prevState, + lastUpdated: action.data.lastUpdated, + }; + case at.DISCOVERY_STREAM_PERSONALIZATION_INIT: + return { + ...prevState, + initialized: true, + }; + case at.DISCOVERY_STREAM_PERSONALIZATION_RESET: + return { ...INITIAL_STATE.Personalization }; + default: + return prevState; + } +} + +// eslint-disable-next-line complexity +function DiscoveryStream(prevState = INITIAL_STATE.DiscoveryStream, action) { + // Return if action data is empty, or spocs or feeds data is not loaded + const isNotReady = () => + !action.data || !prevState.spocs.loaded || !prevState.feeds.loaded; + + const handlePlacements = handleSites => { + const { data, placements } = prevState.spocs; + const result = {}; + + const forPlacement = placement => { + const placementSpocs = data[placement.name]; + + if ( + !placementSpocs || + !placementSpocs.items || + !placementSpocs.items.length + ) { + return; + } + + result[placement.name] = { + ...placementSpocs, + items: handleSites(placementSpocs.items), + }; + }; + + if (!placements || !placements.length) { + [{ name: "spocs" }].forEach(forPlacement); + } else { + placements.forEach(forPlacement); + } + return result; + }; + + const nextState = handleSites => ({ + ...prevState, + spocs: { + ...prevState.spocs, + data: handlePlacements(handleSites), + }, + feeds: { + ...prevState.feeds, + data: Object.keys(prevState.feeds.data).reduce( + (accumulator, feed_url) => { + accumulator[feed_url] = { + data: { + ...prevState.feeds.data[feed_url].data, + recommendations: handleSites( + prevState.feeds.data[feed_url].data.recommendations + ), + }, + }; + return accumulator; + }, + {} + ), + }, + }); + + switch (action.type) { + case at.DISCOVERY_STREAM_CONFIG_CHANGE: + // Fall through to a separate action is so it doesn't trigger a listener update on init + case at.DISCOVERY_STREAM_CONFIG_SETUP: + return { ...prevState, config: action.data || {} }; + case at.DISCOVERY_STREAM_EXPERIMENT_DATA: + return { ...prevState, experimentData: action.data || {} }; + case at.DISCOVERY_STREAM_LAYOUT_UPDATE: + return { + ...prevState, + layout: action.data.layout || [], + }; + case at.DISCOVERY_STREAM_COLLECTION_DISMISSIBLE_TOGGLE: + return { + ...prevState, + isCollectionDismissible: action.data.value, + }; + case at.DISCOVERY_STREAM_PREFS_SETUP: + return { + ...prevState, + recentSavesEnabled: action.data.recentSavesEnabled, + pocketButtonEnabled: action.data.pocketButtonEnabled, + saveToPocketCard: action.data.saveToPocketCard, + hideDescriptions: action.data.hideDescriptions, + compactImages: action.data.compactImages, + imageGradient: action.data.imageGradient, + newSponsoredLabel: action.data.newSponsoredLabel, + titleLines: action.data.titleLines, + descLines: action.data.descLines, + readTime: action.data.readTime, + }; + case at.DISCOVERY_STREAM_RECENT_SAVES: + return { + ...prevState, + recentSavesData: action.data.recentSaves, + }; + case at.DISCOVERY_STREAM_POCKET_STATE_SET: + return { + ...prevState, + isUserLoggedIn: action.data.isUserLoggedIn, + }; + case at.HIDE_PRIVACY_INFO: + return { + ...prevState, + isPrivacyInfoModalVisible: false, + }; + case at.SHOW_PRIVACY_INFO: + return { + ...prevState, + isPrivacyInfoModalVisible: true, + }; + case at.DISCOVERY_STREAM_LAYOUT_RESET: + return { ...INITIAL_STATE.DiscoveryStream, config: prevState.config }; + case at.DISCOVERY_STREAM_FEEDS_UPDATE: + return { + ...prevState, + feeds: { + ...prevState.feeds, + loaded: true, + }, + }; + case at.DISCOVERY_STREAM_FEED_UPDATE: + const newData = {}; + newData[action.data.url] = action.data.feed; + return { + ...prevState, + feeds: { + ...prevState.feeds, + data: { + ...prevState.feeds.data, + ...newData, + }, + }, + }; + case at.DISCOVERY_STREAM_SPOCS_CAPS: + return { + ...prevState, + spocs: { + ...prevState.spocs, + frequency_caps: [...prevState.spocs.frequency_caps, ...action.data], + }, + }; + case at.DISCOVERY_STREAM_SPOCS_ENDPOINT: + return { + ...prevState, + spocs: { + ...INITIAL_STATE.DiscoveryStream.spocs, + spocs_endpoint: + action.data.url || + INITIAL_STATE.DiscoveryStream.spocs.spocs_endpoint, + }, + }; + case at.DISCOVERY_STREAM_SPOCS_PLACEMENTS: + return { + ...prevState, + spocs: { + ...prevState.spocs, + placements: + action.data.placements || + INITIAL_STATE.DiscoveryStream.spocs.placements, + }, + }; + case at.DISCOVERY_STREAM_SPOCS_UPDATE: + if (action.data) { + return { + ...prevState, + spocs: { + ...prevState.spocs, + lastUpdated: action.data.lastUpdated, + data: action.data.spocs, + loaded: true, + }, + }; + } + return prevState; + case at.DISCOVERY_STREAM_SPOC_BLOCKED: + return { + ...prevState, + spocs: { + ...prevState.spocs, + blocked: [...prevState.spocs.blocked, action.data.url], + }, + }; + case at.DISCOVERY_STREAM_LINK_BLOCKED: + return isNotReady() + ? prevState + : nextState(items => + items.filter(item => item.url !== action.data.url) + ); + + case at.PLACES_SAVED_TO_POCKET: + const addPocketInfo = item => { + if (item.url === action.data.url) { + return Object.assign({}, item, { + open_url: action.data.open_url, + pocket_id: action.data.pocket_id, + context_type: "pocket", + }); + } + return item; + }; + return isNotReady() + ? prevState + : nextState(items => items.map(addPocketInfo)); + + case at.DELETE_FROM_POCKET: + case at.ARCHIVE_FROM_POCKET: + return isNotReady() + ? prevState + : nextState(items => + items.filter(item => item.pocket_id !== action.data.pocket_id) + ); + + case at.PLACES_BOOKMARK_ADDED: + const updateBookmarkInfo = item => { + if (item.url === action.data.url) { + const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data; + return Object.assign({}, item, { + bookmarkGuid, + bookmarkTitle, + bookmarkDateCreated: dateAdded, + context_type: "bookmark", + }); + } + return item; + }; + return isNotReady() + ? prevState + : nextState(items => items.map(updateBookmarkInfo)); + + case at.PLACES_BOOKMARKS_REMOVED: + const removeBookmarkInfo = item => { + if (action.data.urls.includes(item.url)) { + const newSite = Object.assign({}, item); + delete newSite.bookmarkGuid; + delete newSite.bookmarkTitle; + delete newSite.bookmarkDateCreated; + if (!newSite.context_type || newSite.context_type === "bookmark") { + newSite.context_type = "removedBookmark"; + } + return newSite; + } + return item; + }; + return isNotReady() + ? prevState + : nextState(items => items.map(removeBookmarkInfo)); + case at.PREF_CHANGED: + if (action.data.name === PREF_COLLECTION_DISMISSIBLE) { + return { + ...prevState, + isCollectionDismissible: action.data.value, + }; + } + return prevState; + default: + return prevState; + } +} + +function Search(prevState = INITIAL_STATE.Search, action) { + switch (action.type) { + case at.DISABLE_SEARCH: + return Object.assign({ ...prevState, disable: true }); + case at.FAKE_FOCUS_SEARCH: + return Object.assign({ ...prevState, fakeFocus: true }); + case at.SHOW_SEARCH: + return Object.assign({ ...prevState, disable: false, fakeFocus: false }); + default: + return prevState; + } +} + +export const reducers = { + TopSites, + App, + ASRouter, + Prefs, + Dialog, + Sections, + Pocket, + Personalization, + DiscoveryStream, + Search, +}; |