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/. */
5 import { actionTypes as at } from "resource://activity-stream/common/Actions.mjs";
6 import { Dedupe } from "resource://activity-stream/common/Dedupe.sys.mjs";
8 export const TOP_SITES_DEFAULT_ROWS = 1;
9 export const TOP_SITES_MAX_SITES_PER_ROW = 8;
10 const PREF_COLLECTION_DISMISSIBLE = "discoverystream.isCollectionDismissible";
12 const dedupe = new Dedupe(site => site && site.url);
14 export const INITIAL_STATE = {
16 // Have we received real data from the app yet?
19 isForStartupCache: false,
20 customizeMenuVisible: false,
22 ASRouter: { initialized: false },
24 // Have we received real data from history yet?
26 // The history (and possibly default) links
28 // Used in content only to dispatch action to TopSiteForm.
30 // Used in content only to open the SearchShortcutsForm modal.
31 showSearchShortcutsForm: false,
32 // The list of available search shortcuts.
34 // The "Share-of-Voice" allocations generated by TopSitesFeed
38 // {position: 0, assignedPartner: "amp"},
39 // {position: 1, assignedPartner: "moz-sales"},
45 values: { featureConfig: {} },
57 // This is the new pocket configurable layout state.
59 // This is a JSON-parsed copy of the discoverystream.config pref value.
60 config: { enabled: false },
62 isPrivacyInfoModalVisible: false,
63 isCollectionDismissible: false,
66 // "https://foo.com/feed1": {lastUpdated: 123, data: [], personalized: false}
74 // "spocs": {title: "", context: "", items: [], personalized: false},
75 // "placement1": {title: "", context: "", items: [], personalized: false},
83 utmSource: "pocket-newtab",
84 utmCampaign: undefined,
85 utmContent: undefined,
88 isUserLoggedIn: false,
89 recentSavesEnabled: false,
96 // When search hand-off is enabled, we render a big button that is styled to
97 // look like a search textbox. If the button is clicked, we style
98 // the button as if it was a focused search box and show a fake cursor but
99 // really focus the awesomebar without the focus styles ("hidden focus").
101 // Hide the search box after handing off to AwesomeBar and user starts typing.
109 function App(prevState = INITIAL_STATE.App, action) {
110 switch (action.type) {
112 return Object.assign({}, prevState, action.data || {}, {
115 case at.TOP_SITES_UPDATED:
116 // Toggle `isForStartupCache` when receiving the `TOP_SITES_UPDATE` action
117 // so that sponsored tiles can be rendered as usual. See Bug 1826360.
118 return Object.assign({}, prevState, action.data || {}, {
119 isForStartupCache: false,
121 case at.SHOW_PERSONALIZE:
122 return Object.assign({}, prevState, {
123 customizeMenuVisible: true,
125 case at.HIDE_PERSONALIZE:
126 return Object.assign({}, prevState, {
127 customizeMenuVisible: false,
134 function ASRouter(prevState = INITIAL_STATE.ASRouter, action) {
135 switch (action.type) {
136 case at.AS_ROUTER_INITIALIZED:
137 return { ...action.data, initialized: true };
144 * insertPinned - Inserts pinned links in their specified slots
146 * @param {array} a list of links
147 * @param {array} a list of pinned links
148 * @return {array} resulting list of links with pinned links inserted
150 export function insertPinned(links, pinned) {
151 // Remove any pinned links
152 const pinnedUrls = pinned.map(link => link && link.url);
153 let newLinks = links.filter(link =>
154 link ? !pinnedUrls.includes(link.url) : false
156 newLinks = newLinks.map(link => {
157 if (link && link.isPinned) {
158 delete link.isPinned;
159 delete link.pinIndex;
164 // Then insert them in their specified location
165 pinned.forEach((val, index) => {
169 let link = Object.assign({}, val, { isPinned: true, pinIndex: index });
170 if (index > newLinks.length) {
171 newLinks[index] = link;
173 newLinks.splice(index, 0, link);
180 function TopSites(prevState = INITIAL_STATE.TopSites, action) {
183 switch (action.type) {
184 case at.TOP_SITES_UPDATED:
185 if (!action.data || !action.data.links) {
188 return Object.assign(
191 { initialized: true, rows: action.data.links },
192 action.data.pref ? { pref: action.data.pref } : {}
194 case at.TOP_SITES_PREFS_UPDATED:
195 return Object.assign({}, prevState, { pref: action.data.pref });
196 case at.TOP_SITES_EDIT:
197 return Object.assign({}, prevState, {
199 index: action.data.index,
200 previewResponse: null,
203 case at.TOP_SITES_CANCEL_EDIT:
204 return Object.assign({}, prevState, { editForm: null });
205 case at.TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL:
206 return Object.assign({}, prevState, { showSearchShortcutsForm: true });
207 case at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL:
208 return Object.assign({}, prevState, { showSearchShortcutsForm: false });
209 case at.PREVIEW_RESPONSE:
211 !prevState.editForm ||
212 action.data.url !== prevState.editForm.previewUrl
216 return Object.assign({}, prevState, {
218 index: prevState.editForm.index,
219 previewResponse: action.data.preview,
220 previewUrl: action.data.url,
223 case at.PREVIEW_REQUEST:
224 if (!prevState.editForm) {
227 return Object.assign({}, prevState, {
229 index: prevState.editForm.index,
230 previewResponse: null,
231 previewUrl: action.data.url,
234 case at.PREVIEW_REQUEST_CANCEL:
235 if (!prevState.editForm) {
238 return Object.assign({}, prevState, {
240 index: prevState.editForm.index,
241 previewResponse: null,
244 case at.SCREENSHOT_UPDATED:
245 newRows = prevState.rows.map(row => {
246 if (row && row.url === action.data.url) {
248 return Object.assign({}, row, { screenshot: action.data.screenshot });
253 ? Object.assign({}, prevState, { rows: newRows })
255 case at.PLACES_BOOKMARK_ADDED:
259 newRows = prevState.rows.map(site => {
260 if (site && site.url === action.data.url) {
261 const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data;
262 return Object.assign({}, site, {
265 bookmarkDateCreated: dateAdded,
270 return Object.assign({}, prevState, { rows: newRows });
271 case at.PLACES_BOOKMARKS_REMOVED:
275 newRows = prevState.rows.map(site => {
276 if (site && action.data.urls.includes(site.url)) {
277 const newSite = Object.assign({}, site);
278 delete newSite.bookmarkGuid;
279 delete newSite.bookmarkTitle;
280 delete newSite.bookmarkDateCreated;
285 return Object.assign({}, prevState, { rows: newRows });
286 case at.PLACES_LINKS_DELETED:
290 newRows = prevState.rows.filter(
291 site => !action.data.urls.includes(site.url)
293 return Object.assign({}, prevState, { rows: newRows });
294 case at.UPDATE_SEARCH_SHORTCUTS:
295 return { ...prevState, searchShortcuts: action.data.searchShortcuts };
298 ready: action.data.ready,
299 positions: action.data.positions,
301 return { ...prevState, sov };
307 function Dialog(prevState = INITIAL_STATE.Dialog, action) {
308 switch (action.type) {
310 return Object.assign({}, prevState, { visible: true, data: action.data });
311 case at.DIALOG_CANCEL:
312 return Object.assign({}, prevState, { visible: false });
313 case at.DELETE_HISTORY_URL:
314 return Object.assign({}, INITIAL_STATE.Dialog);
320 function Prefs(prevState = INITIAL_STATE.Prefs, action) {
322 switch (action.type) {
323 case at.PREFS_INITIAL_VALUES:
324 return Object.assign({}, prevState, {
328 case at.PREF_CHANGED:
329 newValues = Object.assign({}, prevState.values);
330 newValues[action.data.name] = action.data.value;
331 return Object.assign({}, prevState, { values: newValues });
337 function Sections(prevState = INITIAL_STATE.Sections, action) {
340 switch (action.type) {
341 case at.SECTION_DEREGISTER:
342 return prevState.filter(section => section.id !== action.data);
343 case at.SECTION_REGISTER:
344 // If section exists in prevState, update it
345 newState = prevState.map(section => {
346 if (section && section.id === action.data.id) {
348 return Object.assign({}, section, action.data);
352 // Otherwise, append it
354 const initialized = !!(action.data.rows && !!action.data.rows.length);
355 const section = Object.assign(
356 { title: "", rows: [], enabled: false },
360 newState.push(section);
363 case at.SECTION_UPDATE:
364 newState = prevState.map(section => {
365 if (section && section.id === action.data.id) {
366 // If the action is updating rows, we should consider initialized to be true.
367 // This can be overridden if initialized is defined in the action.data
368 const initialized = action.data.rows ? { initialized: true } : {};
370 // Make sure pinned cards stay at their current position when rows are updated.
371 // Disabling a section (SECTION_UPDATE with empty rows) does not retain pinned cards.
374 !!action.data.rows.length &&
375 section.rows.find(card => card.pinned)
377 const rows = Array.from(action.data.rows);
378 section.rows.forEach((card, index) => {
380 // Only add it if it's not already there.
381 if (rows[index].guid !== card.guid) {
382 rows.splice(index, 0, card);
386 return Object.assign(
390 Object.assign({}, action.data, { rows })
394 return Object.assign({}, section, initialized, action.data);
399 if (!action.data.dedupeConfigurations) {
403 action.data.dedupeConfigurations.forEach(dedupeConf => {
404 newState = newState.map(section => {
405 if (section.id === dedupeConf.id) {
406 const dedupedRows = dedupeConf.dedupeFrom.reduce(
407 (rows, dedupeSectionId) => {
408 const dedupeSection = newState.find(
409 s => s.id === dedupeSectionId
411 const [, newRows] = dedupe.group(dedupeSection.rows, rows);
417 return Object.assign({}, section, { rows: dedupedRows });
425 case at.SECTION_UPDATE_CARD:
426 return prevState.map(section => {
427 if (section && section.id === action.data.id && section.rows) {
428 const newRows = section.rows.map(card => {
429 if (card.url === action.data.url) {
430 return Object.assign({}, card, action.data.options);
434 return Object.assign({}, section, { rows: newRows });
438 case at.PLACES_BOOKMARK_ADDED:
442 return prevState.map(section =>
443 Object.assign({}, section, {
444 rows: section.rows.map(item => {
445 // find the item within the rows that is attempted to be bookmarked
446 if (item.url === action.data.url) {
447 const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data;
448 return Object.assign({}, item, {
451 bookmarkDateCreated: dateAdded,
459 case at.PLACES_SAVED_TO_POCKET:
463 return prevState.map(section =>
464 Object.assign({}, section, {
465 rows: section.rows.map(item => {
466 if (item.url === action.data.url) {
467 return Object.assign({}, item, {
468 open_url: action.data.open_url,
469 pocket_id: action.data.pocket_id,
470 title: action.data.title,
478 case at.PLACES_BOOKMARKS_REMOVED:
482 return prevState.map(section =>
483 Object.assign({}, section, {
484 rows: section.rows.map(item => {
485 // find the bookmark within the rows that is attempted to be removed
486 if (action.data.urls.includes(item.url)) {
487 const newSite = Object.assign({}, item);
488 delete newSite.bookmarkGuid;
489 delete newSite.bookmarkTitle;
490 delete newSite.bookmarkDateCreated;
491 if (!newSite.type || newSite.type === "bookmark") {
492 newSite.type = "history";
500 case at.PLACES_LINKS_DELETED:
504 return prevState.map(section =>
505 Object.assign({}, section, {
506 rows: section.rows.filter(
507 site => !action.data.urls.includes(site.url)
511 case at.PLACES_LINK_BLOCKED:
515 return prevState.map(section =>
516 Object.assign({}, section, {
517 rows: section.rows.filter(site => site.url !== action.data.url),
520 case at.DELETE_FROM_POCKET:
521 case at.ARCHIVE_FROM_POCKET:
522 return prevState.map(section =>
523 Object.assign({}, section, {
524 rows: section.rows.filter(
525 site => site.pocket_id !== action.data.pocket_id
534 function Pocket(prevState = INITIAL_STATE.Pocket, action) {
535 switch (action.type) {
536 case at.POCKET_WAITING_FOR_SPOC:
537 return { ...prevState, waitingForSpoc: action.data };
538 case at.POCKET_LOGGED_IN:
539 return { ...prevState, isUserLoggedIn: !!action.data };
544 ctaButton: action.data.cta_button,
545 ctaText: action.data.cta_text,
546 ctaUrl: action.data.cta_url,
547 useCta: action.data.use_cta,
555 function Personalization(prevState = INITIAL_STATE.Personalization, action) {
556 switch (action.type) {
557 case at.DISCOVERY_STREAM_PERSONALIZATION_LAST_UPDATED:
560 lastUpdated: action.data.lastUpdated,
562 case at.DISCOVERY_STREAM_PERSONALIZATION_INIT:
567 case at.DISCOVERY_STREAM_PERSONALIZATION_RESET:
568 return { ...INITIAL_STATE.Personalization };
574 // eslint-disable-next-line complexity
575 function DiscoveryStream(prevState = INITIAL_STATE.DiscoveryStream, action) {
576 // Return if action data is empty, or spocs or feeds data is not loaded
577 const isNotReady = () =>
578 !action.data || !prevState.spocs.loaded || !prevState.feeds.loaded;
580 const handlePlacements = handleSites => {
581 const { data, placements } = prevState.spocs;
584 const forPlacement = placement => {
585 const placementSpocs = data[placement.name];
589 !placementSpocs.items ||
590 !placementSpocs.items.length
595 result[placement.name] = {
597 items: handleSites(placementSpocs.items),
601 if (!placements || !placements.length) {
602 [{ name: "spocs" }].forEach(forPlacement);
604 placements.forEach(forPlacement);
609 const nextState = handleSites => ({
613 data: handlePlacements(handleSites),
617 data: Object.keys(prevState.feeds.data).reduce(
618 (accumulator, feed_url) => {
619 accumulator[feed_url] = {
621 ...prevState.feeds.data[feed_url].data,
622 recommendations: handleSites(
623 prevState.feeds.data[feed_url].data.recommendations
634 switch (action.type) {
635 case at.DISCOVERY_STREAM_CONFIG_CHANGE:
636 // Fall through to a separate action is so it doesn't trigger a listener update on init
637 case at.DISCOVERY_STREAM_CONFIG_SETUP:
638 return { ...prevState, config: action.data || {} };
639 case at.DISCOVERY_STREAM_EXPERIMENT_DATA:
640 return { ...prevState, experimentData: action.data || {} };
641 case at.DISCOVERY_STREAM_LAYOUT_UPDATE:
644 layout: action.data.layout || [],
646 case at.DISCOVERY_STREAM_COLLECTION_DISMISSIBLE_TOGGLE:
649 isCollectionDismissible: action.data.value,
651 case at.DISCOVERY_STREAM_PREFS_SETUP:
654 recentSavesEnabled: action.data.recentSavesEnabled,
655 pocketButtonEnabled: action.data.pocketButtonEnabled,
656 saveToPocketCard: action.data.saveToPocketCard,
657 hideDescriptions: action.data.hideDescriptions,
658 compactImages: action.data.compactImages,
659 imageGradient: action.data.imageGradient,
660 newSponsoredLabel: action.data.newSponsoredLabel,
661 titleLines: action.data.titleLines,
662 descLines: action.data.descLines,
663 readTime: action.data.readTime,
665 case at.DISCOVERY_STREAM_RECENT_SAVES:
668 recentSavesData: action.data.recentSaves,
670 case at.DISCOVERY_STREAM_POCKET_STATE_SET:
673 isUserLoggedIn: action.data.isUserLoggedIn,
675 case at.HIDE_PRIVACY_INFO:
678 isPrivacyInfoModalVisible: false,
680 case at.SHOW_PRIVACY_INFO:
683 isPrivacyInfoModalVisible: true,
685 case at.DISCOVERY_STREAM_LAYOUT_RESET:
686 return { ...INITIAL_STATE.DiscoveryStream, config: prevState.config };
687 case at.DISCOVERY_STREAM_FEEDS_UPDATE:
695 case at.DISCOVERY_STREAM_FEED_UPDATE:
697 newData[action.data.url] = action.data.feed;
703 ...prevState.feeds.data,
708 case at.DISCOVERY_STREAM_SPOCS_CAPS:
713 frequency_caps: [...prevState.spocs.frequency_caps, ...action.data],
716 case at.DISCOVERY_STREAM_SPOCS_ENDPOINT:
720 ...INITIAL_STATE.DiscoveryStream.spocs,
723 INITIAL_STATE.DiscoveryStream.spocs.spocs_endpoint,
726 case at.DISCOVERY_STREAM_SPOCS_PLACEMENTS:
732 action.data.placements ||
733 INITIAL_STATE.DiscoveryStream.spocs.placements,
736 case at.DISCOVERY_STREAM_SPOCS_UPDATE:
742 lastUpdated: action.data.lastUpdated,
743 data: action.data.spocs,
749 case at.DISCOVERY_STREAM_SPOC_BLOCKED:
754 blocked: [...prevState.spocs.blocked, action.data.url],
757 case at.DISCOVERY_STREAM_LINK_BLOCKED:
761 items.filter(item => item.url !== action.data.url)
764 case at.PLACES_SAVED_TO_POCKET:
765 const addPocketInfo = item => {
766 if (item.url === action.data.url) {
767 return Object.assign({}, item, {
768 open_url: action.data.open_url,
769 pocket_id: action.data.pocket_id,
770 context_type: "pocket",
777 : nextState(items => items.map(addPocketInfo));
779 case at.DELETE_FROM_POCKET:
780 case at.ARCHIVE_FROM_POCKET:
784 items.filter(item => item.pocket_id !== action.data.pocket_id)
787 case at.PLACES_BOOKMARK_ADDED:
788 const updateBookmarkInfo = item => {
789 if (item.url === action.data.url) {
790 const { bookmarkGuid, bookmarkTitle, dateAdded } = action.data;
791 return Object.assign({}, item, {
794 bookmarkDateCreated: dateAdded,
795 context_type: "bookmark",
802 : nextState(items => items.map(updateBookmarkInfo));
804 case at.PLACES_BOOKMARKS_REMOVED:
805 const removeBookmarkInfo = item => {
806 if (action.data.urls.includes(item.url)) {
807 const newSite = Object.assign({}, item);
808 delete newSite.bookmarkGuid;
809 delete newSite.bookmarkTitle;
810 delete newSite.bookmarkDateCreated;
811 if (!newSite.context_type || newSite.context_type === "bookmark") {
812 newSite.context_type = "removedBookmark";
820 : nextState(items => items.map(removeBookmarkInfo));
821 case at.PREF_CHANGED:
822 if (action.data.name === PREF_COLLECTION_DISMISSIBLE) {
825 isCollectionDismissible: action.data.value,
834 function Search(prevState = INITIAL_STATE.Search, action) {
835 switch (action.type) {
836 case at.DISABLE_SEARCH:
837 return Object.assign({ ...prevState, disable: true });
838 case at.FAKE_FOCUS_SEARCH:
839 return Object.assign({ ...prevState, fakeFocus: true });
841 return Object.assign({ ...prevState, disable: false, fakeFocus: false });
847 function Wallpapers(prevState = INITIAL_STATE.Wallpapers, action) {
848 switch (action.type) {
849 case at.WALLPAPERS_SET:
850 return { wallpaperList: action.data };
856 export const reducers = {