src/amo/reducers/addons.js (479 lines of code) (raw):

/* @flow */ import invariant from 'invariant'; import { CLIENT_APP_ANDROID, ADDON_TYPE_EXTENSION } from 'amo/constants'; import { UNLOAD_ADDON_REVIEWS, UPDATE_RATING_COUNTS, } from 'amo/actions/reviews'; import type { UnloadAddonReviewsAction, UpdateRatingCountsAction, } from 'amo/actions/reviews'; import { makeInternalPromoted, selectLocalizedContent, selectCategoryObject, } from 'amo/reducers/utils'; import { SET_LANG } from 'amo/reducers/api'; import type { ExternalAddonInfoType } from 'amo/api/addonInfo'; import type { AppState } from 'amo/store'; import type { AddonType, ExternalAddonType, ExternalPreviewType, GroupedRatingsType, PartialExternalAddonType, PreviewType, } from 'amo/types/addons'; import type { LocalizedUrlWithOutgoing, UrlWithOutgoing } from 'amo/types/api'; import type { ErrorHandlerType } from 'amo/types/errorHandler'; export const FETCH_ADDON_INFO: 'FETCH_ADDON_INFO' = 'FETCH_ADDON_INFO'; export const LOAD_ADDON_INFO: 'LOAD_ADDON_INFO' = 'LOAD_ADDON_INFO'; export const FETCH_ADDON: 'FETCH_ADDON' = 'FETCH_ADDON'; export const LOAD_ADDON: 'LOAD_ADDON' = 'LOAD_ADDON'; type AddonID = number; export type AddonInfoType = { eula: string | null, privacyPolicy: string | null, }; export type AddonsState = {| // Flow wants hash maps with string keys. // See: https://zhenyong.github.io/flowtype/docs/objects.html#objects-as-maps byID: { [addonId: string]: AddonType }, byIdInURL: { [id: string]: AddonID }, byGUID: { [addonGUID: string]: AddonID }, bySlug: { [addonSlug: string]: AddonID }, infoBySlug: { [slug: string]: {| info: AddonInfoType, loading: boolean |}, }, lang: string, loadingByIdInURL: { [id: string]: boolean }, |}; export const initialState: AddonsState = { byID: {}, byIdInURL: {}, byGUID: {}, bySlug: {}, infoBySlug: {}, // We default lang to '' to avoid having to add a lot of invariants to our // code, and protect against a lang of '' in selectLocalizedContent. lang: '', loadingByIdInURL: {}, }; type FetchAddonParams = {| errorHandler: ErrorHandlerType, showGroupedRatings?: boolean, slug: string, assumeNonPublic?: boolean, |}; export type FetchAddonAction = {| type: typeof FETCH_ADDON, payload: {| errorHandlerId: string, showGroupedRatings?: boolean, slug: string, assumeNonPublic?: boolean, |}, |}; export function fetchAddon({ errorHandler, showGroupedRatings = false, slug, assumeNonPublic = false, }: FetchAddonParams): FetchAddonAction { if (!errorHandler) { throw new Error('errorHandler cannot be empty'); } if (!slug) { throw new Error('slug cannot be empty'); } return { type: FETCH_ADDON, payload: { errorHandlerId: errorHandler.id, showGroupedRatings, slug, assumeNonPublic, }, }; } type LoadAddonParams = {| addon: ExternalAddonType, slug: string, |}; export type LoadAddonAction = {| payload: LoadAddonParams, type: typeof LOAD_ADDON, |}; export function loadAddon({ addon, slug }: LoadAddonParams): LoadAddonAction { invariant(addon, 'addon is required'); invariant(slug, 'slug is required'); return { type: LOAD_ADDON, payload: { addon, slug }, }; } type FetchAddonInfoParams = {| errorHandlerId: string, slug: string, |}; export type FetchAddonInfoAction = {| type: typeof FETCH_ADDON_INFO, payload: FetchAddonInfoParams, |}; export const fetchAddonInfo = ({ errorHandlerId, slug, }: FetchAddonInfoParams): FetchAddonInfoAction => { invariant(errorHandlerId, 'errorHandlerId is required'); invariant(slug, 'slug is required'); return { type: FETCH_ADDON_INFO, payload: { errorHandlerId, slug }, }; }; type LoadAddonInfoParams = {| info: ExternalAddonInfoType, slug: string, |}; type LoadAddonInfoAction = {| type: typeof LOAD_ADDON_INFO, payload: LoadAddonInfoParams, |}; export const loadAddonInfo = ({ info, slug, }: LoadAddonInfoParams): LoadAddonInfoAction => { invariant(info, 'info is required'); invariant(slug, 'slug is required'); return { type: LOAD_ADDON_INFO, payload: { info, slug }, }; }; export const createInternalPreviews = ( previews: Array<ExternalPreviewType>, lang: string, ): Array<PreviewType> => { return previews.map((preview) => ({ h: preview.image_size[1], src: preview.image_url, thumbnail_h: preview.thumbnail_size[1], thumbnail_src: preview.thumbnail_url, thumbnail_w: preview.thumbnail_size[0], title: selectLocalizedContent(preview.caption, lang), w: preview.image_size[0], })); }; export const selectLocalizedUrlWithOutgoing = ( url: LocalizedUrlWithOutgoing | null, lang: string, ): UrlWithOutgoing | null => { if (url && url.url && url.outgoing) { return { url: selectLocalizedContent(url.url, lang), outgoing: selectLocalizedContent(url.outgoing, lang), }; } return null; }; export function createInternalAddon( apiAddon: ExternalAddonType | PartialExternalAddonType, lang: string, ): AddonType { const addon: AddonType = { authors: apiAddon.authors, average_daily_users: apiAddon.average_daily_users, categories: selectCategoryObject(apiAddon), contributions_url: apiAddon.contributions_url, created: apiAddon.created, default_locale: apiAddon.default_locale, description: selectLocalizedContent(apiAddon.description, lang), developer_comments: selectLocalizedContent( apiAddon.developer_comments, lang, ), edit_url: apiAddon.edit_url, guid: apiAddon.guid, has_eula: apiAddon.has_eula, has_privacy_policy: apiAddon.has_privacy_policy, homepage: selectLocalizedUrlWithOutgoing(apiAddon.homepage, lang), icon_url: apiAddon.icon_url, icons: apiAddon.icons, id: apiAddon.id, is_disabled: apiAddon.is_disabled, is_experimental: apiAddon.is_experimental, is_source_public: apiAddon.is_source_public, last_updated: apiAddon.last_updated, latest_unlisted_version: apiAddon.latest_unlisted_version, locale_disambiguation: apiAddon.locale_disambiguation, name: selectLocalizedContent(apiAddon.name, lang), previews: apiAddon.previews ? createInternalPreviews(apiAddon.previews, lang) : undefined, promoted: makeInternalPromoted(apiAddon.promoted), ratings: apiAddon.ratings, requires_payment: apiAddon.requires_payment, review_url: apiAddon.review_url, slug: apiAddon.slug, status: apiAddon.status, summary: selectLocalizedContent(apiAddon.summary, lang), support_email: selectLocalizedContent(apiAddon.support_email, lang), support_url: selectLocalizedUrlWithOutgoing(apiAddon.support_url, lang), tags: apiAddon.tags, target_locale: apiAddon.target_locale, type: apiAddon.type, url: apiAddon.url, weekly_downloads: apiAddon.weekly_downloads, // These are custom properties not in the API response. currentVersionId: apiAddon.current_version ? apiAddon.current_version.id : null, isMozillaSignedExtension: false, isAndroidCompatible: false, }; const currentVersion = apiAddon.current_version; if (currentVersion) { addon.isMozillaSignedExtension = currentVersion.file.is_mozilla_signed_extension; addon.isAndroidCompatible = addon.type === ADDON_TYPE_EXTENSION && !!currentVersion.compatibility[CLIENT_APP_ANDROID] && currentVersion.compatibility[CLIENT_APP_ANDROID].max === '*'; } return addon; } export const getAddonByID = ( addons: AddonsState, id: AddonID, ): AddonType | null => { return addons.byID[`${id}`] || null; }; export const getAddonByIdInURL = ( addons: AddonsState, id: string, ): AddonType | null => { const addonId = addons.byIdInURL[id]; return getAddonByID(addons, addonId); }; export const isAddonLoading = (state: AppState, id: string): boolean => { if (typeof id !== 'string') { return false; } return Boolean(state.addons.loadingByIdInURL[id]); }; export const getAllAddons = (state: AppState): Array<AddonType> => { const addons = state.addons.byID; // $FlowFixMe: see https://github.com/facebook/flow/issues/2221. return Object.values(addons); }; type GetBySlugParams = {| slug: string, state: AddonsState, |}; export const getAddonInfoBySlug = ({ slug, state, }: GetBySlugParams): AddonInfoType | null => { invariant(slug, 'slug is required'); invariant(state, 'state is required'); const infoForSlug = state.infoBySlug[slug]; return (infoForSlug && infoForSlug.info) || null; }; export const isAddonInfoLoading = ({ slug, state, }: GetBySlugParams): boolean => { invariant(slug, 'slug is required'); invariant(state, 'state is required'); const infoForSlug = state.infoBySlug[slug]; return Boolean(infoForSlug && infoForSlug.loading); }; export const createInternalAddonInfo = ( addonInfo: ExternalAddonInfoType, lang: string, ): AddonInfoType => { return { eula: selectLocalizedContent(addonInfo.eula, lang), privacyPolicy: selectLocalizedContent(addonInfo.privacy_policy, lang), }; }; export function createGroupedRatings( grouping: $Shape<GroupedRatingsType> = {}, ): GroupedRatingsType { return { /* eslint-disable quote-props */ '1': 0, '2': 0, '3': 0, '4': 0, '5': 0, /* eslint-enable quote-props */ ...grouping, }; } type Action = | FetchAddonAction | FetchAddonInfoAction | LoadAddonInfoAction | LoadAddonAction | UnloadAddonReviewsAction | UpdateRatingCountsAction; export default function addonsReducer( // eslint-disable-next-line default-param-last state: AddonsState = initialState, action: Action, ): AddonsState { switch (action.type) { case SET_LANG: return { ...state, lang: action.payload.lang, }; case FETCH_ADDON: { const { slug } = action.payload; return { ...state, loadingByIdInURL: { ...state.loadingByIdInURL, [slug]: true, }, }; } case LOAD_ADDON: { const { addon: loadedAddon, slug } = action.payload; const byID = { ...state.byID }; const byGUID = { ...state.byGUID }; const bySlug = { ...state.bySlug }; const byIdInURL = { ...state.byIdInURL }; const loadingByIdInURL = { ...state.loadingByIdInURL }; const addon = createInternalAddon(loadedAddon, state.lang); // Flow wants hash maps with string keys. // See: https://zhenyong.github.io/flowtype/docs/objects.html#objects-as-maps byID[`${addon.id}`] = addon; byIdInURL[slug] = addon.id; loadingByIdInURL[slug] = false; if (addon.slug) { bySlug[addon.slug.toLowerCase()] = addon.id; } if (addon.guid) { byGUID[addon.guid] = addon.id; } return { ...state, byID, byGUID, bySlug, byIdInURL, loadingByIdInURL, }; } case UNLOAD_ADDON_REVIEWS: { const { addonId } = action.payload; const addon = getAddonByID(state, addonId); if (addon) { return { ...state, byID: { ...state.byID, [`${addonId}`]: undefined, }, byGUID: { ...state.byGUID, [addon.guid]: undefined, }, bySlug: { ...state.bySlug, [addon.slug.toLowerCase()]: undefined, }, loadingByIdInURL: { ...state.loadingByIdInURL, [addon.slug]: undefined, }, }; } return state; } case UPDATE_RATING_COUNTS: { const { addonId, oldReview, newReview } = action.payload; const addon = getAddonByID(state, addonId); if (!addon) { return state; } const { ratings } = addon; let average = ratings ? ratings.average : 0; let ratingCount = ratings ? ratings.count : 0; let reviewCount = ratings ? ratings.text_count : 0; const newGroupedRatings = ratings ? { ...ratings.grouped_counts } : createGroupedRatings(); if ( oldReview && oldReview.score && newGroupedRatings[oldReview.score] > 0 ) { newGroupedRatings[oldReview.score] -= 1; } if (newReview && newReview.score) { newGroupedRatings[newReview.score] += 1; } let countForAverage = ratingCount; if (average && countForAverage && oldReview && oldReview.score) { // If average and countForAverage are defined and greater than 0, // begin by subtracting the old rating to reset the baseline. const countAfterRemoval = countForAverage - 1; if (countAfterRemoval === 0) { // There are no ratings left. average = 0; } else { // Expand all existing rating scores, take away the old score, // and recalculate the average. average = (average * countForAverage - oldReview.score) / countAfterRemoval; } countForAverage = countAfterRemoval; } // Expand all existing rating scores, add in the new score, // and recalculate the average. average = (average * countForAverage + Number(newReview.score)) / (countForAverage + 1); // Adjust rating / review counts. if (!oldReview) { // A new rating / review was added. ratingCount += 1; if (newReview.body) { reviewCount += 1; } } else if (!oldReview.body && newReview.body) { // A rating was converted into a review. reviewCount += 1; } return { ...state, byID: { ...state.byID, [addonId]: { ...addon, ratings: { ...ratings, average, // It's impossible to recalculate the bayesian_average // (i.e. median) so we set it to the average as an // approximation. bayesian_average: average, count: ratingCount, grouped_counts: newGroupedRatings, text_count: reviewCount, }, }, }, }; } case FETCH_ADDON_INFO: { const { slug } = action.payload; return { ...state, infoBySlug: { ...state.infoBySlug, [slug]: { info: undefined, loading: true, }, }, }; } case LOAD_ADDON_INFO: { const { slug, info } = action.payload; return { ...state, infoBySlug: { ...state.infoBySlug, [slug]: { info: createInternalAddonInfo(info, state.lang), loading: false, }, }, }; } default: return state; } }