Esempio n. 1
0
  handleSuggestionsFetchRequested = ({
    value,
  }: OnSuggestionsFetchRequestedParams) => {
    if (!value) {
      log.debug(oneLine`Ignoring suggestions fetch requested because
        value is not supplied: ${value}`);
      return;
    }

    if (value.length < SEARCH_TERM_MIN_LENGTH) {
      log.debug(oneLine`Ignoring suggestions fetch because query
      does not meet the required length (${SEARCH_TERM_MIN_LENGTH})`);

      this.props.dispatch(autocompleteCancel());
      return;
    }

    if (value.length > SEARCH_TERM_MAX_LENGTH) {
      log.debug(oneLine`Ignoring suggestions fetch because query
        exceeds max length (${SEARCH_TERM_MAX_LENGTH})`);

      this.props.dispatch(autocompleteCancel());
      return;
    }

    const filters = this.createFiltersFromQuery(value);

    this.setState({ autocompleteIsOpen: true });

    this.dispatchAutocompleteStart({ filters });
  };
Esempio n. 2
0
export const allPages = async (
  getNextResponse: GetNextResponseType,
  { pageLimit = 100 }: AllPagesOptions = {},
): Promise<PaginatedApiResponse<any>> => {
  let results = [];
  let nextURL;
  let count = 0;
  let pageSize = 0;

  for (let page = 1; page <= pageLimit; page++) {
    const response = await getNextResponse(nextURL);
    if (!count) {
      // Every response page returns a count for all results.
      count = response.count;
    }
    if (!pageSize) {
      pageSize = response.page_size;
    }
    results = results.concat(response.results);

    if (response.next) {
      nextURL = response.next;
      log.debug(`Fetching next page "${nextURL}"`);
    } else {
      return { count, page_size: pageSize, results };
    }
  }

  // If we get this far the callback may not be advancing pages correctly.
  throw new Error(`Fetched too many pages (the limit is ${pageLimit})`);
};
  onSelectElementChange = (event) => {
    event.preventDefault();

    const { filters } = this.props;
    const newFilters = { ...filters };

    // Get the filter we're supposed to change and set it.
    const filterName = event.currentTarget.getAttribute('name');
    newFilters[filterName] = event.currentTarget.value;

    // If the filters haven't changed we're not going to change the URL.
    if (newFilters[filterName] === filters[filterName]) {
      log.debug(oneLine`onSelectElementChange() called in SearchFilters but
        the filter ${filterName} did not change–not changing route.`);
      return false;
    }

    if (newFilters[filterName] === NO_FILTER) {
      delete newFilters[filterName];
    }

    this.doSearch({ newFilters });

    return false;
  };
Esempio n. 4
0
  render() {
    const { className, i18n, recommendations } = this.props;

    if (!recommendations) {
      log.debug(
        'No recommandations, hiding the AddonRecommendations component.',
      );
      return null;
    }

    const { addons, loading, outcome } = recommendations;
    const classnames = makeClassName('AddonRecommendations', className);

    let header = <LoadingText width={100} />;
    if (!loading) {
      header =
        outcome === OUTCOME_RECOMMENDED
          ? i18n.gettext('Other users with this extension also installed')
          : i18n.gettext('Other popular extensions');
    }

    return (
      <AddonsCard
        addonInstallSource={outcome || ''}
        addons={addons}
        className={classnames}
        header={header}
        loading={loading}
        placeholderCount={4}
        showMetadata
        showSummary={false}
        type="horizontal"
      />
    );
  }
Esempio n. 5
0
export function search(
  { api, page, auth = false, filters = {} }: SearchParams
) {
  const _filters = { ...filters };
  if (!_filters.clientApp && api.clientApp) {
    log.debug(
      `No clientApp found in filters; using api.clientApp (${api.clientApp})`);
    _filters.clientApp = api.clientApp;
  }
  // TODO: This loads Firefox personas (lightweight themes) for Android
  // until github.com/mozilla/addons-frontend/issues/1723#issuecomment-278793546
  // and https://github.com/mozilla/addons-server/issues/4766 are addressed.
  // Essentially: right now there are no categories for the combo
  // of "Android" + "Themes" but Firefox lightweight themes will work fine
  // on mobile so we request "Firefox" + "Themes" for Android instead.
  // Obviously we need to fix this on the API end so our requests aren't
  // overridden, but for now this will work.
  if (
    _filters.clientApp === 'android' && _filters.addonType === ADDON_TYPE_THEME
  ) {
    log.info(oneLine`addonType: ${_filters.addonType}/clientApp:
      ${_filters.clientApp} is not supported. Changing clientApp to "firefox"`);
    _filters.clientApp = 'firefox';
  }
  return callApi({
    endpoint: 'addons/search',
    schema: { results: [addon] },
    params: {
      ...convertFiltersToQueryParams(_filters),
      page,
    },
    state: api,
    auth,
  });
}
Esempio n. 6
0
    return _mozAddonManager.getAddonByID(guid).then((addon) => {
      if (!addon) {
        throw new Error('Addon not found');
      }
      log.debug('Add-on found', addon);

      return addon;
    });
 onDismissReviewReply = () => {
   const { dispatch, review } = this.props;
   if (!review) {
     log.debug('Cannot hide review form because no review has been loaded.');
     return;
   }
   dispatch(hideReplyToReviewForm({ reviewId: review.id }));
 };
 onReviewSubmitted = () => {
   const { dispatch, review } = this.props;
   if (!review) {
     log.debug('Cannot hide review form because no review has been loaded.');
     return;
   }
   dispatch(hideEditReviewForm({ reviewId: review.id }));
 };
Esempio n. 9
0
    onSubmit (e) {
        e.preventDefault();

        if (this.props.onSubmit) {
            this.props.onSubmit(this.state);
        } else {
            logger.debug('Form: missing `onSubmit` method');
        }
    }
 onClickToBeginReviewReply = (event: SyntheticEvent<any>) => {
   event.preventDefault();
   const { dispatch, review } = this.props;
   if (!review) {
     log.debug('Cannot show review form because no review has been loaded.');
     return;
   }
   dispatch(showReplyToReviewForm({ reviewId: review.id }));
 };
 return this.localState.load().then((storedState) => {
   if (storedState) {
     log.debug(
       oneLine`Initializing AddonReview state from LocalState
         ${this.localState.id}`,
       storedState,
     );
     this.setState(storedState);
   }
 });
 onEscapeReviewOverlay = () => {
   const { dispatch, review } = this.props;
   if (!review) {
     log.debug('Cannot hide review form because no review has been loaded.');
     return;
   }
   // Even though an escaped overlay will be hidden, we still have to
   // synchronize our show/hide state otherwise we won't be able to
   // show the overlay after it has been escaped.
   dispatch(hideEditReviewForm({ reviewId: review.id }));
 };
Esempio n. 13
0
export function postRating({ rating, apiState, addonId, versionId }) {
  const postData = { rating, version: versionId };
  log.debug('about to post add-on rating with', postData);
  return callApi({
    endpoint: `addons/addon/${addonId}/reviews`,
    body: postData,
    method: 'post',
    auth: true,
    state: apiState,
  });
}
Esempio n. 14
0
  dismissReportUI = () => {
    const { addon, dispatch, loading } = this.props;

    if (loading) {
      log.debug(
        "Ignoring dismiss click because we're submitting the abuse report",
      );
      return;
    }

    dispatch(hideAddonAbuseReportUI({ addon }));
  };
 onClickRating = (event) => {
   event.preventDefault();
   const button = event.currentTarget;
   log.debug('Selected rating from form button:', button.value);
   this.props.createRating({
     rating: parseInt(button.value, 10),
     versionId: this.props.version.id,
     apiState: this.props.apiState,
     addonId: this.props.addonId,
     userId: this.props.userId,
   });
 }
Esempio n. 16
0
function getNoScriptStyles({ appName }) {
  const cssPath = path.join(config.get('basePath'), `src/${appName}/noscript.css`);
  try {
    return fs.readFileSync(cssPath);
  } catch (e) {
    if (e.code !== 'ENOENT') {
      log.info(`noscript styles could not be parsed from ${cssPath}`);
    } else {
      log.debug(`noscript styles not found at ${cssPath}`);
    }
  }
  return undefined;
}
  onClickToEditReview = (event: SyntheticEvent<any>) => {
    const { dispatch, isReplyToReviewId, review } = this.props;
    event.preventDefault();

    if (isReplyToReviewId !== undefined) {
      dispatch(showReplyToReviewForm({ reviewId: isReplyToReviewId }));
    } else {
      if (!review) {
        log.debug('Cannot edit a review because no review has been loaded.');
        return;
      }
      dispatch(showEditReviewForm({ reviewId: review.id }));
    }
  };
Esempio n. 18
0
  handleSuggestionSelected = (
    event: SyntheticEvent<any>,
    { suggestion }: {| suggestion: SuggestionType |},
  ) => {
    event.preventDefault();

    if (this.props.loadingSuggestions) {
      log.debug('Ignoring a click on the suggestion while loading');
      return;
    }

    this.setState({ autocompleteIsOpen: false, searchValue: '' });
    this.props.onSuggestionSelected(suggestion);
  };
Esempio n. 19
0
export const findInstallURL = ({
  _findFileForPlatform = findFileForPlatform,
  appendSource = true,
  defaultInstallSource,
  location,
  platformFiles,
  userAgentInfo,
}: FindInstallUrlParams): string | void => {
  let source;
  if (appendSource) {
    invariant(
      location,
      'The location parameter is required when appendSource is true',
    );
    source = location.query.src || defaultInstallSource;
  }

  const platformFile = _findFileForPlatform({
    platformFiles,
    userAgentInfo,
  });

  const installURL = platformFile && platformFile.url;

  if (!installURL) {
    // This could happen for themes which do not have version files.
    log.debug(
      oneLine`No file exists for os
      ${JSON.stringify(userAgentInfo.os)}; platform files:`,
      platformFiles,
    );

    return undefined;
  }

  if (!source) {
    return installURL;
  }

  // Add ?src=...
  const parseQuery = true;
  const urlParts = url.parse(installURL, parseQuery);
  return url.format({
    ...urlParts,
    // Reset the search string so we can define a new one.
    search: undefined,
    query: { ...urlParts.query, src: source },
  });
};
 messages.forEach((messageItem) => {
   let message = messageItem;
   if (typeof message === 'object') {
     // This handles an unlikely scenario where an API error response
     // contains nested objects within objects. If this happens in real
     // life let's fix it or make the display prettier.
     // Until then, let's just prevent it from triggering an exception.
     message = JSON.stringify(message);
   }
   if (code === API_ERROR_SIGNATURE_EXPIRED) {
     // This API error describes exactly what happened but that isn't
     // very helpful for AMO users. Let's help them figure it out.
     log.debug(`Detected ${code}, replacing API message: ${message}`);
     message = i18n.gettext('Your session has expired');
   }
   items.push(message);
 });
Esempio n. 21
0
function* fetchLatestUserReview({
  payload: { addonId, errorHandlerId, userId },
}: FetchLatestUserReviewAction): Saga {
  const errorHandler = createErrorHandler(errorHandlerId);

  yield put(errorHandler.createClearingAction());

  try {
    const state: AppState = yield select(getState);

    const params: GetLatestUserReviewParams = {
      addon: addonId,
      apiState: state.api,
      user: userId,
    };

    const review: GetLatestUserReviewResponse = yield call(
      getLatestUserReview,
      params,
    );

    const _setLatestReview = (value) => {
      return setLatestReview({
        userId,
        addonId,
        review: value,
      });
    };

    if (review) {
      yield put(setReview(review));
      yield put(_setLatestReview(review));
    } else {
      log.debug(
        `No saved review found for userId ${userId}, addonId ${addonId}`,
      );
      yield put(_setLatestReview(null));
    }
  } catch (error) {
    log.warn(
      `Failed to fetchLatestUserReview for addonId "${addonId}", userId "${userId}": ${error}`,
    );
    yield put(errorHandler.createErrorAction(error));
  }
}
    constructor(props: withExperimentInternalProps) {
      super(props);

      log.info('[WithExperiment.constructor] props:', {
        _cookie: props._cookie,
        variantA: props.variantA,
        variantB: props.variantB,
      });

      log.info('[WithExperiment.constructor] this.props:', {
        _cookie: this.props._cookie,
        variantA: this.props.variantA,
        variantB: this.props.variantB,
      });

      if (!this.isExperimentEnabled()) {
        log.debug(`Experiment "${defaultId}" is not enabled by config.`);
        return;
      }

      const { _cookie, randomizer, variantA, variantB } = this.props;

      this.experimentCookie = _cookie.load(this.getCookieName());

      log.info(
        '[WithExperiment.constructor] cookie name:',
        this.getCookieName(),
      );

      log.info(
        '[WithExperiment.constructor] experiment cookie loaded:',
        this.experimentCookie,
      );

      if (this.experimentCookie === undefined) {
        this.experimentCookie = randomizer() >= 0.5 ? variantA : variantB;
        _cookie.save(this.getCookieName(), this.experimentCookie, cookieConfig);

        log.info(
          '[WithExperiment.constructor] experiment cookie saved:',
          this.experimentCookie,
        );
      }
    }
Esempio n. 23
0
  sendReport = ({ text }: OnSubmitParams) => {
    // The button isn't clickable if there is no content, but just in case:
    // we verify there's a message to send.
    if (!text.trim().length) {
      log.debug(oneLine`User managed to click submit button while textarea
        was empty. Ignoring this onClick/sendReport event.`);
      return;
    }

    const { addon, dispatch, errorHandler } = this.props;

    dispatch(
      sendAddonAbuseReport({
        addonSlug: addon.slug,
        errorHandlerId: errorHandler.id,
        message: text,
      }),
    );
  };
Esempio n. 24
0
  // Get a list of permissions from the correct platform file.
  getCurrentPermissions({
    platformFiles,
    userAgentInfo,
    _findFileForPlatform = findFileForPlatform,
  }: GetCurrentPermissionsParams): Array<string> {
    const file = _findFileForPlatform({
      userAgentInfo,
      platformFiles,
    });

    if (!file) {
      log.debug(
        oneLine`No file exists for os
        ${JSON.stringify(userAgentInfo.os)}; platform files:`,
        platformFiles,
      );

      return [];
    }
    return file.permissions || [];
  }
export function prefixMiddleware(req, res, next, { _config = config } = {}) {
  // Split on slashes after removing the leading slash.
  const URLParts = req.originalUrl.replace(/^\//, '').split('/');
  log.debug(URLParts);

  // Get lang and app parts from the URL. At this stage they may be incorrect
  // or missing.
  const [langFromURL, appFromURL] = URLParts;

  // Get language from URL or fall-back to detecting it from accept-language
  // header.
  const acceptLanguage = req.headers['accept-language'];
  const { lang, isLangFromHeader } = getLanguage({
    lang: langFromURL,
    acceptLanguage,
  });
  // Get the application from the UA if one wasn't specified in the URL (or
  // if it turns out to be invalid).
  const application = getClientApp(req.headers['user-agent']);
  // clientApp values that are allowed through to the router
  // TODO: This can be removed when we upgrade to react-router v4.
  const clientAppRoutes = _config.get('clientAppRoutes');

  const hasValidLang = isValidLang(langFromURL);
  const hasValidLocaleException = isValidLocaleUrlException(appFromURL, {
    _config,
  });
  const hasValidClientApp = isValidClientApp(appFromURL, { _config });
  let hasValidClientAppUrlException = isValidClientAppUrlException(appFromURL, {
    _config,
  });

  let isApplicationFromHeader = false;
  let prependedOrMovedApplication = false;

  if (hasValidLocaleException) {
    log.info(oneLine`Second part of URL is a locale exception (${URLParts[1]});
      make sure the clientApp is valid`);

    // Normally we look for a clientApp in the second part of a URL, but URLs
    // that match a locale exception don't have a locale so we look for the
    // clientApp in the first part of the URL.
    if (!isValidClientApp(langFromURL, { _config })) {
      URLParts[0] = application;
      isApplicationFromHeader = true;
      prependedOrMovedApplication = true;
    }
  } else if (
    (hasValidLang && langFromURL !== lang) ||
    hasValidClientApp ||
    hasValidClientAppUrlException
  ) {
    // Replace the first part of the URL if:
    // * It's valid and we've mapped it e.g: pt -> pt-PT.
    // * The lang is invalid but we have a valid application
    //   e.g. /bogus/firefox/.
    log.info(`Replacing lang in URL ${URLParts[0]} -> ${lang}`);
    URLParts[0] = lang;
  } else if (isValidLocaleUrlException(URLParts[0], { _config })) {
    log.info(`Prepending clientApp to URL: ${application}`);
    URLParts.splice(0, 0, application);
    isApplicationFromHeader = true;
    prependedOrMovedApplication = true;
  } else if (!hasValidLang) {
    // If lang wasn't valid or was missing prepend one.
    log.info(`Prepending lang to URL: ${lang}`);
    URLParts.splice(0, 0, lang);
    // If we've prepended the lang to the URL we need to re-check our
    // URL exception and make sure it's valid.
    hasValidClientAppUrlException = isValidClientAppUrlException(URLParts[1], {
      _config,
    });
  }

  if (!hasValidClientApp && isValidClientApp(URLParts[1], { _config })) {
    // We skip prepending an app if we'd previously prepended a lang and the
    // 2nd part of the URL is now a valid app.
    log.info('Application in URL is valid following prepending a lang.');
  } else if (prependedOrMovedApplication) {
    log.info(
      'URL is valid because we added/changed the first part to a clientApp.',
    );
  } else if (hasValidLocaleException || hasValidClientAppUrlException) {
    if (
      clientAppRoutes.includes(URLParts[1]) === false &&
      (hasValidLang || hasValidLocaleException)
    ) {
      log.info('Exception in URL found; we fallback to addons-server.');
      // TODO: Remove this once upgraded to react-router 4.
      res.status(404).end(oneLine`This page does not exist in addons-frontend.
        Returning 404; this error should trigger upstream (usually
        addons-server) to return a valid response.`);
      return next();
    }
    log.info('Exception in URL found; prepending lang to URL.');
  } else if (!hasValidClientApp) {
    // If the app supplied is not valid we need to prepend one.
    log.info(`Prepending application to URL: ${application}`);
    URLParts.splice(1, 0, application);
    isApplicationFromHeader = true;
  }

  // Redirect to the new URL.
  // For safety we'll deny a redirect to a URL starting with '//' since
  // that will be treated as a protocol-free URL.
  const newURL = `/${URLParts.join('/')}`;
  if (newURL !== req.originalUrl && !newURL.startsWith('//')) {
    // Collect vary headers to apply to the redirect
    // so we can make it cacheable.
    // TODO: Make the redirects cacheable by adding expires headers.
    if (isLangFromHeader) {
      res.vary('accept-language');
    }
    if (isApplicationFromHeader) {
      res.vary('user-agent');
    }
    return res.redirect(301, newURL);
  }

  // Add the data to res.locals to be utilised later.
  /* eslint-disable no-param-reassign */
  const [newLang, newApp] = URLParts;
  res.locals.lang = newLang;
  // The newApp part of the URL might not be a client application
  // so it's important to re-check that here before assuming it's good.
  res.locals.clientApp = isValidClientApp(newApp) ? newApp : application;
  // Get detailed info on the current user agent so we can make sure add-ons
  // are compatible with the current clientApp/version combo.
  res.locals.userAgent = req.headers['user-agent'];
  /* eslint-enable no-param-reassign */

  return next();
}
Esempio n. 26
0
  render() {
    const { permissions } = this.props;
    const hostPermissions = [];

    // Group permissions into "site" and "domain" permissions. If any
    // "all urls" permissions are found, break as we'll only need one
    // host permissions message.
    let allUrls = false;
    const wildcards = [];
    const sites = [];
    for (const permission of permissions) {
      if (permission === '<all_urls>') {
        allUrls = true;
        break;
      }
      if (permission.startsWith('moz-extension:')) {
        continue;
      }
      const match = /^[a-z*]+:\/\/([^/]+)\//.exec(permission);
      if (!match) {
        log.debug(
          `Host permission string "${permission}" appears to be invalid.`,
        );
        continue;
      }
      if (match[1] === '*') {
        allUrls = true;
      } else if (match[1].startsWith('*.')) {
        wildcards.push(match[1].slice(2));
      } else {
        sites.push(match[1]);
      }
    }

    const uniqueWildcards = [...new Set(wildcards)];
    const uniqueSites = [...new Set(sites)];

    // Format the host permissions. If we have a wildcard for all urls,
    // a single string will suffice.  Otherwise, show domain wildcards
    // first, then individual host permissions.
    if (allUrls) {
      hostPermissions.push(
        <Permission
          type="hostPermission"
          description={this.getPermissionString({
            messageType: allUrlsMessageType,
          })}
          key="allUrls"
        />,
      );
    } else {
      hostPermissions.push(
        ...this.generateHostPermissions({
          permissions: uniqueWildcards,
          messageType: domainMessageType,
        }),
      );
      hostPermissions.push(
        ...this.generateHostPermissions({
          permissions: uniqueSites,
          messageType: siteMessageType,
        }),
      );
    }
    return <React.Fragment>{hostPermissions}</React.Fragment>;
  }
Esempio n. 27
0
export function prefixMiddleWare(req, res, next, { _config = config } = {}) {
  // Split on slashes after removing the leading slash.
  const URLParts = req.originalUrl.replace(/^\//, '').split('/');
  log.debug(URLParts);

  // Get lang and app parts from the URL. At this stage they may be incorrect
  // or missing.
  const [langFromURL, appFromURL] = URLParts;

  // Get language from URL or fall-back to detecting it from accept-language header.
  const acceptLanguage = req.headers['accept-language'];
  const { lang, isLangFromHeader } = getLanguage({ lang: langFromURL, acceptLanguage });

  const hasValidLang = isValidLang(langFromURL);
  const hasValidClientApp = isValidClientApp(appFromURL, { _config });

  let prependedLang = false;

  if ((hasValidLang && langFromURL !== lang) ||
      (!hasValidLang && hasValidClientApp)) {
    // Replace the first part of the URL if:
    // * It's valid and we've mapped it e.g: pt -> pt-PT.
    // * The lang is invalid but we have a valid application
    //   e.g. /bogus/firefox/.
    log.info(`Replacing lang in URL ${URLParts[0]} -> ${lang}`);
    URLParts[0] = lang;
  } else if (!hasValidLang) {
    // If lang wasn't valid or was missing prepend one.
    log.info(`Prepending lang to URL: ${lang}`);
    URLParts.splice(0, 0, lang);
    prependedLang = true;
  }

  let isApplicationFromHeader = false;

  if (!hasValidClientApp && prependedLang &&
      isValidClientApp(URLParts[1], { _config })) {
    // We skip prepending an app if we'd previously prepended a lang and the
    // 2nd part of the URL is now a valid app.
    log.info('Application in URL is valid following prepending a lang.');
  } else if (!hasValidClientApp) {
    // If the app supplied is not valid we need to prepend one.
    const application = getClientApp(req.headers['user-agent']);
    log.info(`Prepending application to URL: ${application}`);
    URLParts.splice(1, 0, application);
    isApplicationFromHeader = true;
  }

  // Redirect to the new URL.
  // For safety we'll deny a redirect to a URL starting with '//' since
  // that will be treated as a protocol-free URL.
  const newURL = `/${URLParts.join('/')}`;
  if (newURL !== req.originalUrl && !newURL.startsWith('//')) {
    // Collect vary headers to apply to the redirect
    // so we can make it cacheable.
    // TODO: Make the redirects cacheable by adding expires headers.
    const varyHeaders = [];
    if (isLangFromHeader) {
      varyHeaders.push('accept-language');
    }
    if (isApplicationFromHeader) {
      varyHeaders.push('user-agent');
    }
    res.set('vary', varyHeaders);
    return res.redirect(302, newURL);
  }

  // Add the data to res.locals to be utilised later.
  /* eslint-disable no-param-reassign */
  const [newLang, newApp] = URLParts;
  res.locals.lang = newLang;
  res.locals.clientApp = newApp;
  /* eslint-enable no-param-reassign */

  return next();
}
Esempio n. 28
0
export function* updateUserAccount({
  payload: {
    errorHandlerId,
    notifications,
    picture,
    pictureData,
    userFields,
    userId,
  },
}: UpdateUserAccountAction): Saga {
  const errorHandler = createErrorHandler(errorHandlerId);

  yield put(errorHandler.createClearingAction());

  try {
    const state = yield select(getState);

    const userAccountParams: UpdateUserAccountParams = {
      api: state.api,
      picture,
      userId,
      ...userFields,
    };

    const user = yield call(api.updateUserAccount, userAccountParams);

    if (picture) {
      // The post-upload task (resize, etc.) is asynchronous so we set the
      // uploaded file before loading the user account in order to display the
      // latest picture.
      // See: https://github.com/mozilla/addons-frontend/issues/5252
      user.picture_url = pictureData;
    }

    yield put(loadUserAccount({ user }));

    if (Object.keys(notifications).length) {
      const params: UpdateUserNotificationsParams = {
        api: state.api,
        notifications,
        userId,
      };
      const allNotifications = yield call(api.updateUserNotifications, params);

      if (typeof notifications.announcements !== 'undefined') {
        // The Salesforce integration is asynchronous and takes a lot of time
        // so we set the notification to whatever the user has chosen,
        // otherwise we would display the wrong notification value.
        // See: https://github.com/mozilla/addons-frontend/issues/5219
        const index = allNotifications.findIndex(
          (notification) => notification.name === 'announcements',
        );
        if (index !== -1) {
          allNotifications[index].enabled = notifications.announcements;
          log.debug(
            'Optimistically set user value for "announcements" notification',
          );
        }
      }

      yield put(
        loadUserNotifications({
          notifications: allNotifications,
          userId: user.id,
        }),
      );
    }
  } catch (error) {
    log.warn(`Could not update user account: ${error}`);
    yield put(errorHandler.createErrorAction(error));
  } finally {
    yield put(finishUpdateUserAccount());
  }
}
  render() {
    const { _window, code, className, i18n, messages } = this.props;
    const items = [];

    messages.forEach((messageItem) => {
      let message = messageItem;
      if (typeof message === 'object') {
        // This handles an unlikely scenario where an API error response
        // contains nested objects within objects. If this happens in real
        // life let's fix it or make the display prettier.
        // Until then, let's just prevent it from triggering an exception.
        message = JSON.stringify(message);
      }
      if (code === API_ERROR_SIGNATURE_EXPIRED) {
        // This API error describes exactly what happened but that isn't
        // very helpful for AMO users. Let's help them figure it out.
        log.debug(`Detected ${code}, replacing API message: ${message}`);
        message = i18n.gettext('Your session has expired');
      }
      items.push(message);
    });

    if (!items.length) {
      log.debug(`No messages were passed to ErrorList, code: ${code}`);
      items.push(i18n.gettext('An unexpected error occurred'));
    }

    let action;
    let actionText;
    if (code === API_ERROR_SIGNATURE_EXPIRED) {
      // Let the user recover from signature expired errors.
      action = () => _window.location.reload();
      actionText = i18n.gettext('Reload To Continue');
      if (items.length > 1) {
        // There will never be more than one message but if there is, log a message
        // to help someone debug the problem.
        log.warn(
          'The API unexpectedly returned multiple signature expired errors',
        );
      }
    }

    return (
      <ul className={makeClassName('ErrorList', className)}>
        {items.map((item, index) => {
          return (
            <li
              className="ErrorList-item"
              // We don't have message IDs but it's safe to rely on
              // array indices since they are returned from the API
              // in a predictable order.
              // eslint-disable-next-line react/no-array-index-key
              key={`erroritem-${index}`}
            >
              <Notice
                type="error"
                actionOnClick={action}
                actionText={actionText}
              >
                {item}
              </Notice>
            </li>
          );
        })}
      </ul>
    );
  }