/* 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://newtab/common/Actions.mjs"; import { Dedupe } from "resource:///modules/Dedupe.sys.mjs"; export { TOP_SITES_DEFAULT_ROWS, TOP_SITES_MAX_SITES_PER_ROW, } from "resource:///modules/topsites/constants.mjs"; 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: { App: false, TopSites: false, DiscoveryStream: false, Weather: false, Wallpaper: false, }, customizeMenuVisible: false, }, Ads: { initialized: false, lastUpdated: null, tiles: {}, spocs: {}, spocPlacements: {}, }, 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: { 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: [], topicsLoading: false, feeds: { data: { // "https://foo.com/feed1": {lastUpdated: 123, data: [], personalized: false} }, loaded: false, }, // Used to show impressions in newtab devtools. impressions: { feed: {}, }, // Used to show blocks in newtab devtools. blocks: {}, spocs: { spocs_endpoint: "", lastUpdated: null, cacheUpdateTime: null, onDemand: { enabled: false, loaded: false, }, 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, }, showTopicSelection: false, report: { visible: false, data: {}, }, sectionPersonalization: {}, }, // Messages received from ASRouter to render in newtab Messages: { // messages received from ASRouter are initially visible isVisible: true, // portID for that tab that was sent the message portID: "", // READONLY Message data received from ASRouter messageData: {}, }, Notifications: { showNotifications: false, toastCounter: 0, toastId: "", // This queue is reset each time SHOW_TOAST_MESSAGE is ran. // For can be a queue in the future, but for now is one item toastQueue: [], }, InferredPersonalization: { initialized: false, lastUpdated: null, inferredInterests: {}, coarseInferredInterests: {}, coarsePrivateInferredInterests: {}, debugFeatures: null, inferredTelemetrySettingsOverrides: {}, }, 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, }, Wallpapers: { wallpaperList: [], highlightSeenCounter: 0, categories: [], uploadedWallpaper: "", }, SectionsLayout: { configs: {}, }, Weather: { initialized: false, lastUpdated: null, query: "", suggestions: [], hourlyForecasts: [], locationData: { city: "", adminArea: "", country: "", }, // Display search input in Weather widget searchActive: false, locationSearchString: "", suggestedLocations: [], }, // Widgets ListsWidget: { // value pointing to last selectled list selected: "taskList", // Default state of an empty task list lists: { taskList: { label: "", tasks: [], completed: [], }, }, }, TimerWidget: { // The timer will have 2 types of states, focus and break. // Focus will the default state timerType: "focus", focus: { // Timer duration set by user; 25 mins by default duration: 25 * 60, // Initial duration - also set by the user; does not update until timer ends or user resets timer initialDuration: 25 * 60, // the Date.now() value when a user starts/resumes a timer startTime: null, // Boolean indicating if timer is currently running isRunning: false, }, break: { duration: 5 * 60, initialDuration: 5 * 60, startTime: null, isRunning: false, }, }, ExternalComponents: { components: [], }, }; 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.TopSites` when receiving the `TOP_SITES_UPDATE` action // so that sponsored tiles can be rendered as usual. See Bug 1826360. return { ...prevState, isForStartupCache: { ...prevState.isForStartupCache, TopSites: false }, }; case at.DISCOVERY_STREAM_SPOCS_UPDATE: // Toggle `isForStartupCache.DiscoveryStream` when receiving the `DISCOVERY_STREAM_SPOCS_UPDATE` action // so that spoc cards can be rendered as usual. return { ...prevState, isForStartupCache: { ...prevState.isForStartupCache, DiscoveryStream: false, }, }; case at.WEATHER_UPDATE: // Toggle `isForStartupCache.Weather` when receiving the `WEATHER_UPDATE` action // so that weather can be rendered as usual. return { ...prevState, isForStartupCache: { ...prevState.isForStartupCache, Weather: false }, }; case at.WALLPAPERS_CUSTOM_SET: // Toggle `isForStartupCache.Wallpaper` when receiving the `WALLPAPERS_CUSTOM_SET` action // so that custom wallpaper can be rendered as usual. return { ...prevState, isForStartupCache: { ...prevState.isForStartupCache, Wallpaper: 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 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.DIALOG_CLOSE: // Reset and hide the confirmation dialog once the action is complete. 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_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), }) ); default: return prevState; } } function Messages(prevState = INITIAL_STATE.Messages, action) { switch (action.type) { case at.MESSAGE_SET: if (prevState.messageData.messageType) { return prevState; } return { ...prevState, messageData: action.data.message, portID: action.data.portID || "", }; case at.MESSAGE_TOGGLE_VISIBILITY: return { ...prevState, isVisible: action.data.isVisible }; 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_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 InferredPersonalization( prevState = INITIAL_STATE.InferredPersonalization, action ) { switch (action.type) { case at.INFERRED_PERSONALIZATION_UPDATE: return { ...prevState, initialized: true, inferredInterests: action.data.inferredInterests, coarseInferredInterests: action.data.coarseInferredInterests, coarsePrivateInferredInterests: action.data.coarsePrivateInferredInterests, inferredTelemetrySettingsOverrides: action.data.inferredTelemetrySettingsOverrides, lastUpdated: action.data.lastUpdated, }; case at.INFERRED_PERSONALIZATION_DEBUG_FEATURES_UPDATE: return { ...prevState, debugFeatures: action.data, }; case at.INFERRED_PERSONALIZATION_RESET: return { ...INITIAL_STATE.InferredPersonalization }; 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_TOPICS_LOADING: return { ...prevState, topicsLoading: action.data, }; case at.DISCOVERY_STREAM_PREFS_SETUP: return { ...prevState, 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.SHOW_PRIVACY_INFO: return { ...prevState, }; 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_DEV_IMPRESSIONS: return { ...prevState, impressions: { ...prevState.impressions, feed: action.data, }, }; case at.DISCOVERY_STREAM_DEV_BLOCKS: return { ...prevState, blocks: action.data, }; 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) { // If spocs have been loaded on this tab, we can ignore future updates. // This should never be true on the main store, only content pages. // We check agasint onDemand just to be safe. It generally shouldn't be needed. if (prevState.spocs?.onDemand?.loaded) { return prevState; } return { ...prevState, spocs: { ...prevState.spocs, lastUpdated: action.data.lastUpdated, data: action.data.spocs, cacheUpdateTime: action.data.spocsCacheUpdateTime, onDemand: { enabled: action.data.spocsOnDemand, loaded: false, }, loaded: true, }, }; } return prevState; case at.DISCOVERY_STREAM_SPOCS_ONDEMAND_LOAD: return { ...prevState, spocs: { ...prevState.spocs, onDemand: { ...prevState.spocs.onDemand, loaded: true, }, }, }; case at.DISCOVERY_STREAM_SPOCS_ONDEMAND_RESET: if (action.data) { return { ...prevState, spocs: { ...prevState.spocs, cacheUpdateTime: action.data.spocsCacheUpdateTime, onDemand: { ...prevState.spocs.onDemand, enabled: action.data.spocsOnDemand, }, }, }; } 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_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.TOPIC_SELECTION_SPOTLIGHT_OPEN: return { ...prevState, showTopicSelection: true, }; case at.TOPIC_SELECTION_SPOTLIGHT_CLOSE: return { ...prevState, showTopicSelection: false, }; case at.SECTION_BLOCKED: return { ...prevState, showBlockSectionConfirmation: true, sectionPersonalization: action.data, }; case at.REPORT_AD_OPEN: return { ...prevState, report: { ...prevState.report, card_type: action.data?.card_type, position: action.data?.position, placement_id: action.data?.placement_id, reporting_url: action.data?.reporting_url, url: action.data?.url, visible: true, }, }; case at.REPORT_CONTENT_OPEN: return { ...prevState, report: { ...prevState.report, card_type: action.data?.card_type, corpus_item_id: action.data?.corpus_item_id, scheduled_corpus_item_id: action.data?.scheduled_corpus_item_id, section_position: action.data?.section_position, section: action.data?.section, title: action.data?.title, topic: action.data?.topic, url: action.data?.url, visible: true, }, }; case at.REPORT_CLOSE: case at.REPORT_AD_SUBMIT: case at.REPORT_CONTENT_SUBMIT: return { ...prevState, report: { ...prevState.report, visible: false, }, }; case at.SECTION_PERSONALIZATION_UPDATE: return { ...prevState, sectionPersonalization: action.data }; 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.SHOW_SEARCH: return Object.assign({ ...prevState, disable: false }); default: return prevState; } } function Wallpapers(prevState = INITIAL_STATE.Wallpapers, action) { switch (action.type) { case at.WALLPAPERS_SET: return { ...prevState, wallpaperList: action.data, }; case at.WALLPAPERS_FEATURE_HIGHLIGHT_COUNTER_INCREMENT: return { ...prevState, highlightSeenCounter: action.data, }; case at.WALLPAPERS_CATEGORY_SET: return { ...prevState, categories: action.data }; case at.WALLPAPERS_CUSTOM_SET: return { ...prevState, uploadedWallpaper: action.data }; default: return prevState; } } function SectionsLayout(prevState = INITIAL_STATE.SectionsLayout, action) { switch (action.type) { case at.SECTIONS_LAYOUT_UPDATE: return { ...prevState, configs: action.data.configs }; default: return prevState; } } function Notifications(prevState = INITIAL_STATE.Notifications, action) { switch (action.type) { case at.SHOW_TOAST_MESSAGE: return { ...prevState, showNotifications: action.data.showNotifications, toastCounter: prevState.toastCounter + 1, toastId: action.data.toastId, toastQueue: [action.data.toastId], }; case at.HIDE_TOAST_MESSAGE: { const { showNotifications, toastId: hiddenToastId } = action.data; const queuedToasts = [...prevState.toastQueue].filter( toastId => toastId !== hiddenToastId ); return { ...prevState, toastCounter: queuedToasts.length, toastQueue: queuedToasts, toastId: "", showNotifications, }; } default: return prevState; } } function Weather(prevState = INITIAL_STATE.Weather, action) { switch (action.type) { case at.WEATHER_UPDATE: return { ...prevState, suggestions: action.data.suggestions, hourlyForecasts: action.data.hourlyForecasts || [], lastUpdated: action.data.lastUpdated, locationData: action.data.locationData || prevState.locationData, initialized: true, }; case at.WEATHER_SEARCH_ACTIVE: return { ...prevState, searchActive: action.data }; case at.WEATHER_LOCATION_SEARCH_UPDATE: return { ...prevState, locationSearchString: action.data }; case at.WEATHER_LOCATION_SUGGESTIONS_UPDATE: return { ...prevState, suggestedLocations: action.data }; case at.WEATHER_LOCATION_DATA_UPDATE: return { ...prevState, locationData: action.data }; default: return prevState; } } function Ads(prevState = INITIAL_STATE.Ads, action) { switch (action.type) { case at.ADS_INIT: return { ...prevState, initialized: true, }; case at.ADS_UPDATE_TILES: return { ...prevState, tiles: action.data.tiles, }; case at.ADS_UPDATE_SPOCS: return { ...prevState, spocs: action.data.spocs, spocPlacements: action.data.spocPlacements, }; case at.ADS_RESET: return { ...INITIAL_STATE.Ads }; default: return prevState; } } function TimerWidget(prevState = INITIAL_STATE.TimerWidget, action) { // fallback to current timerType in state if not provided in action const timerType = action.data?.timerType || prevState.timerType; switch (action.type) { case at.WIDGETS_TIMER_SET: return { ...prevState, ...action.data, }; case at.WIDGETS_TIMER_SET_TYPE: return { ...prevState, timerType: action.data.timerType, }; case at.WIDGETS_TIMER_SET_DURATION: return { ...prevState, [timerType]: { // setting a dynamic key assignment to let us dynamically update timer type's state based on what is set duration: action.data.duration, initialDuration: action.data.duration, startTime: null, isRunning: false, }, }; case at.WIDGETS_TIMER_PLAY: return { ...prevState, [timerType]: { ...prevState[timerType], startTime: Math.floor(Date.now() / 1000), // reflected in seconds isRunning: true, }, }; case at.WIDGETS_TIMER_PAUSE: if (prevState[timerType]?.isRunning) { return { ...prevState, [timerType]: { ...prevState[timerType], duration: action.data.duration, // setting startTime to null on pause because we need to check the exact time the user presses play, // whether it's when the user starts or resumes the timer. This helps get accurate results startTime: null, isRunning: false, }, }; } return prevState; case at.WIDGETS_TIMER_RESET: return { ...prevState, [timerType]: { ...prevState[timerType], duration: action.data.duration, initialDuration: action.data.duration, startTime: null, isRunning: false, }, }; case at.WIDGETS_TIMER_END: return { ...prevState, [timerType]: { ...prevState[timerType], duration: action.data.duration, initialDuration: action.data.duration, startTime: null, isRunning: false, }, }; default: return prevState; } } function ListsWidget(prevState = INITIAL_STATE.ListsWidget, action) { switch (action.type) { case at.WIDGETS_LISTS_SET: return { ...prevState, lists: action.data }; case at.WIDGETS_LISTS_SET_SELECTED: return { ...prevState, selected: action.data }; default: return prevState; } } function ExternalComponents( prevState = INITIAL_STATE.ExternalComponents, action ) { switch (action.type) { case at.REFRESH_EXTERNAL_COMPONENTS: return { ...prevState, components: action.data }; default: return prevState; } } export const reducers = { TopSites, App, Ads, Prefs, Dialog, Sections, Messages, Notifications, Pocket, InferredPersonalization, DiscoveryStream, Search, TimerWidget, ListsWidget, Wallpapers, SectionsLayout, Weather, ExternalComponents, };