src/settings.js

/**
 * Singleton for settings-related methods and data.
 *
 * @module settings
 */

import TextMasker from './TextMasker';
import cd from './cd';
import controller from './controller';
import pageRegistry from './pageRegistry';
import { getUserInfo, saveGlobalOption, saveLocalOption } from './utils-api';
import { areObjectsEqual, defined, definedAndNotNull, ucFirst } from './utils-general';
import { showConfirmDialog } from './utils-oojs';
import { formatDateImproved, formatDateNative, formatDateRelative } from './utils-timestamp';
import { createSvg, getFooter, wrapHtml } from './utils-window';

export default {
  /**
   * Settings scheme.
   *
   * @property {object} default Default value for each property.
   * @property {string[]} local List of local setting names. Local settings are settings set for the
   *   current wiki only.
   * @property {object} undocumented Undocumented settings with their defaults. Undocumented
   *   settings are settings not shown in the settings dialog and not saved to the server.
   * @property {object} aliases List of aliases for each property for seamless transition when
   *   changing a setting name.
   * @property {string[]} states List of state setting names. States are values to be remembered, or
   *   settings to be removed if the time comes. It is, in fact, user data, despite that we don't
   *   have much of it.
   * @property {object} resetsTo For settings that are resetted not to their default values, those
   *   non-default values are specified here (used to determine whether the "Reset" button should be
   *   enabled).
   * @property {object[]} ui List of pages of the settings dialog, each with its control objects.
   */
  scheme: {
    local: ['insertButtons-altered', 'insertButtons', 'signaturePrefix'],

    undocumented: {
      defaultCommentLinkType: null,
      defaultSectionLinkType: null,
      showLoadingOverlay: true,
    },

    aliases: {
      'insertButtons-altered': ['haveInsertButtonsBeenAltered'],
      'improvePerformance-lastSuggested': ['improvePerformanceLastSuggested'],
      subscribeOnReply: ['watchSectionOnReply'],
    },

    states: [
      'insertButtons-altered',
      'improvePerformance-lastSuggested',
      'manyForms-onboarded',
      'newTopicsSubscription-onboarded',
      'notificationsBlacklist',
      'upload-onboarded',
    ],

    resetsTo: {
      reformatComments: false,
    },
  },

  /**
   * Set the default settings to the settings scheme object.
   *
   * @private
   */
  initDefaults() {
    this.scheme.default = {
      'allowEditOthersComments': false,
      'alwaysExpandAdvanced': false,

      // The order should coincide with the order of checkboxes in the autocompleteTypes setting -
      // otherwise the "Save" and "Reset" buttons in the settings dialog won't work properly.
      'autocompleteTypes': ['mentions', 'commentLinks', 'wikilinks', 'templates', 'tags'],

      'autopreview': true,
      'collapseThreadsLevel': 10,
      'countEditsAsNewComments': false,
      'desktopNotifications': 'unknown',
      'enableThreads': true,
      'hideTimezone': false,
      'highlightNewInterval': 15,
      'improvePerformance': false,
      'improvePerformance-lastSuggested': null,
      'insertButtons': cd.config.defaultInsertButtons || [],
      'insertButtons-altered': false,
      'manyForms-onboarded': false,
      'modifyToc': true,
      'newTopicsSubscription-onboarded': false,
      'notifications': 'all',
      'notifyCollapsedThreads': false,
      'notificationsBlacklist': [],
      'outdentLevel': 15,
      'reformatComments': null,
      'showContribsLink': false,
      'showToolbar': true,
      'signaturePrefix': cd.config.defaultSignaturePrefix,
      'subscribeOnReply': true,
      'timestampFormat': 'default',
      'upload-onboarded': false,
      'useBackgroundHighlighting': true,
      'useTemplateData': true,
      'useTopicSubscription': Boolean(mw.loader.getState('ext.discussionTools.init')),
      'useUiTime': true,

      // On wikis where there is no topic subscriptions, watching pages on replying is the
      // alternative to keep track of discussions.
      'watchOnReply': !mw.loader.getState('ext.discussionTools.init'),
    };
  },

  /**
   * _For internal use._ Initialize the configuration of the UI for the
   * {@link SettingsDialog settings dialog}}. This is better called each time the UI is rendered
   * because some content is date-dependent.
   */
  initUi() {
    const outdentTemplateUrl = cd.config.outdentTemplates.length ?
      pageRegistry.get(`Template:${cd.config.outdentTemplates[0]}`).getUrl() :
      'https://en.wikipedia.org/wiki/Template:Outdent';
    const noOutdentTemplateNote = cd.config.outdentTemplates.length ?
      '' :
      ' ' + cd.sParse('sd-outdentlevel-help-notemplate');

    const fortyThreeMinutesAgo = new Date(Date.now() - cd.g.msInMin * 43);
    const threeDaysAgo = new Date(Date.now() - cd.g.msInDay * 3.3);

    const exampleDefault = formatDateNative(fortyThreeMinutesAgo);
    const exampleImproved1 = formatDateImproved(fortyThreeMinutesAgo);
    const exampleImproved2 = formatDateImproved(threeDaysAgo);
    const exampleRelative1 = formatDateRelative(fortyThreeMinutesAgo);
    const exampleRelative2 = formatDateRelative(threeDaysAgo);

    this.scheme.ui = [
      {
        name: 'talkPage',
        label: cd.s('sd-page-talkpage'),
        controls: [
          {
            name: 'reformatComments',
            type: 'checkbox',
            label: cd.s('sd-reformatcomments'),
          },
          {
            name: 'showContribsLink',
            type: 'checkbox',
            label: cd.s('sd-showcontribslink'),
            classes: ['cd-setting-indented'],
          },
          {
            name: 'allowEditOthersComments',
            type: 'checkbox',
            label: cd.s('sd-alloweditotherscomments'),
          },
          {
            name: 'enableThreads',
            type: 'checkbox',
            label: cd.s('sd-enablethreads'),
          },
          {
            name: 'collapseThreadsLevel',
            type: 'number',
            min: 0,
            max: 999,
            label: cd.s('sd-collapsethreadslevel'),
            help: cd.s('sd-collapsethreadslevel-help'),
            classes: ['cd-setting-indented'],
          },
          {
            name: 'modifyToc',
            type: 'checkbox',
            label: cd.s('sd-modifytoc'),
          },
          {
            name: 'useBackgroundHighlighting',
            type: 'checkbox',
            label: cd.s('sd-usebackgroundhighlighting'),
          },
          {
            name: 'highlightNewInterval',
            type: 'number',
            min: 0,
            max: 9999999,
            buttonStep: 5,
            label: cd.s('sd-highlightnewinterval'),
            help: cd.s('sd-highlightnewinterval-help'),
          },
          {
            name: 'countEditsAsNewComments',
            type: 'checkbox',
            label: cd.s('sd-counteditsasnewcomments'),
          },
          {
            name: 'improvePerformance',
            type: 'checkbox',
            label: cd.s('sd-improveperformance'),
            help: cd.s('sd-improveperformance-help'),
          },
        ],
      },
      {
        name: 'commentForm',
        label: cd.s('sd-page-commentform'),
        controls: [
          {
            name: 'autopreview',
            type: 'checkbox',
            label: cd.s('sd-autopreview'),
          },
          {
            name: 'watchOnReply',
            type: 'checkbox',
            label: cd.s('sd-watchonreply', mw.user),
          },
          {
            name: 'subscribeOnReply',
            type: 'checkbox',
            label: cd.s('sd-watchsectiononreply', mw.user),
            help: cd.s('sd-watchsectiononreply-help'),
          },
          {
            name: 'showToolbar',
            type: 'checkbox',
            label: cd.s('sd-showtoolbar'),
          },
          {
            name: 'alwaysExpandAdvanced',
            type: 'checkbox',
            label: cd.s('sd-alwaysexpandadvanced'),
          },
          {
            name: 'outdentLevel',
            type: 'number',
            min: 0,
            max: 999,
            label: wrapHtml(cd.sParse('sd-outdentlevel', outdentTemplateUrl), { targetBlank: true }),
            help: wrapHtml(cd.sParse('sd-outdentlevel-help') + noOutdentTemplateNote),
          },
          {
            name: 'autocompleteTypes',
            type: 'multicheckbox',
            label: cd.s('sd-autocompletetypes'),
            options: [
              {
                data: 'mentions',
                label: cd.s('sd-autocompletetypes-mentions'),
              },
              {
                data: 'commentLinks',
                label: cd.s('sd-autocompletetypes-commentlinks'),
              },
              {
                data: 'wikilinks',
                label: cd.s('sd-autocompletetypes-wikilinks'),
              },
              {
                data: 'templates',
                label: cd.s('sd-autocompletetypes-templates'),
              },
              {
                data: 'tags',
                label: cd.s('sd-autocompletetypes-tags'),
              },
            ],
            classes: ['cd-autocompleteTypesMultiselect'],
          },
          {
            name: 'useTemplateData',
            type: 'checkbox',
            label: cd.s('sd-usetemplatedata'),
            help: cd.s('sd-usetemplatedata-help'),
          },
          {
            name: 'insertButtons',
            type: 'multitag',
            placeholder: cd.s('sd-insertbuttons-multiselect-placeholder'),
            tagLimit: 100,
            label: cd.s('sd-insertbuttons'),
            help: wrapHtml(cd.sParse('sd-insertbuttons-help') + ' ' + cd.sParse('sd-localsetting')),
            dataToUi: (value) => (
              value.map((button) => Array.isArray(button) ? button.join(';') : button)
            ),
            uiToData: (value) => (
              value
                .map((value) => {
                  const textMasker = new TextMasker(value).mask(/\\[+;\\]/g);
                  let [, snippet, label] = textMasker.getText().match(/^(.*?)(?:;(.+))?$/) || [];
                  if (!snippet?.replace(/^ +$/, '')) return;

                  snippet = textMasker.unmaskText(snippet);
                  label &&= textMasker.unmaskText(label);
                  return [snippet, label].filter(defined);
                })
                .filter(defined)
            ),
          },
          {
            name: 'signaturePrefix',
            type: 'text',
            maxLength: 100,
            label: cd.s('sd-signatureprefix'),
            help: wrapHtml(cd.sParse('sd-signatureprefix-help') + ' ' + cd.sParse('sd-localsetting')),
          },
        ],
      },
      {
        name: 'timestamps',
        label: cd.s('sd-page-timestamps'),
        controls: [
          {
            name: 'useUiTime',
            type: 'checkbox',
            label: wrapHtml(
              cd.sParse('sd-useuitime', 'Special:Preferences#mw-prefsection-rendering-timeoffset'),
              { targetBlank: true }
            ),
          },
          {
            name: 'hideTimezone',
            type: 'checkbox',
            label: cd.s('sd-hidetimezone'),
          },
          {
            name: 'timestampFormat',
            type: 'radio',
            label: cd.s('sd-timestampformat'),
            options: [
              {
                data: 'default',
                label: cd.s('sd-timestampformat-radio-default', exampleDefault),
              },
              {
                data: 'improved',
                label: cd.s(
                  'sd-timestampformat-radio-improved',
                  exampleImproved1,
                  exampleImproved2
                ),
              },
              {
                data: 'relative',
                label: cd.s(
                  'sd-timestampformat-radio-relative',
                  exampleRelative1,
                  exampleRelative2
                ),
              },
            ],
            help: cd.s('sd-timestampformat-help'),
          },
        ],
      },
      {
        name: 'notifications',
        label: cd.s('sd-page-notifications'),
        controls: [
          {
            name: 'useTopicSubscription',
            type: 'checkbox',
            label: wrapHtml(cd.sParse('sd-usetopicsubscription', mw.user), { targetBlank: true }),
            help: wrapHtml(cd.sParse('sd-usetopicsubscription-help'), { targetBlank: true }),
          },
          {
            name: 'notifications',
            type: 'radio',
            label: cd.s('sd-notifications'),
            options: [
              {
                data: 'all',
                label: cd.s('sd-notifications-radio-all', mw.user),
              },
              {
                data: 'toMe',
                label: cd.s('sd-notifications-radio-tome'),
              },
              {
                data: 'none',
                label: cd.s('sd-notifications-radio-none'),
              },
            ],
            help: cd.s('sd-notifications-help'),
          },
          {
            name: 'desktopNotifications',
            type: 'radio',
            label: cd.s('sd-desktopnotifications'),
            options: [
              {
                data: 'all',
                label: cd.s('sd-desktopnotifications-radio-all', mw.user),
              },
              {
                data: 'toMe',
                label: cd.s('sd-desktopnotifications-radio-tome'),
              },
              {
                data: 'none',
                label: cd.s('sd-desktopnotifications-radio-none'),
              },
            ],
            help: cd.s('sd-desktopnotifications-help', cd.g.serverName),
          },
          {
            name: 'notifyCollapsedThreads',
            type: 'checkbox',
            label: cd.s('sd-notifycollapsedthreads'),
          },
        ],
      },
      {
        name: 'dataRemoval',
        label: cd.s('sd-page-dataremoval'),
        controls: [
          {
            name: 'removeData',
            type: 'button',
            label: cd.s('sd-removedata'),
            flags: ['destructive'],
            fieldLabel: cd.s('sd-removedata-description'),
            help: wrapHtml(cd.sParse('sd-removedata-help'), { targetBlank: true }),
          },
        ],
      },
    ];
  },

  /**
   * _For internal use._ Initialize user settings, returning a promise, or return an existing one.
   *
   * @returns {Promise.<undefined>}
   */
  init() {
    this.initPromise ||= (async () => {
      // We fill the settings after the modules are loaded so that the settings set via common.js
      // have less chance not to load.

      this.initDefaults();

      const options = {
        [cd.g.settingsOptionName]: mw.user.options.get(cd.g.settingsOptionName),
        [cd.g.localSettingsOptionName]: mw.user.options.get(cd.g.localSettingsOptionName),
      };

      const remoteSettings = await this.load({
        options,
        omitLocal: true,
      });

      this.set(Object.assign(
        {},
        this.scheme.default,

        // Settings in global variables like cdAllowEditOthersComments used before server-stored
        // settings were implemented and used for undocumented settings now.
        this.getSettingPropertiesOfObject(window, 'cd'),

        remoteSettings,
      ));

      // If the user has never changed the insert buttons configuration, it should change with the
      // default configuration change.
      if (
        !this.values['insertButtons-altered'] &&
        JSON.stringify(this.values.insertButtons) !== JSON.stringify(cd.config.defaultInsertButtons)
      ) {
        this.values.insertButtons = cd.config.defaultInsertButtons;
      }

      if (!areObjectsEqual(this.values, remoteSettings)) {
        this.save().catch((e) => {
          console.warn('Couldn\'t save the settings to the server.', e);
        });
      }

      // Undocumented settings and settings in variables `cd...` and `cdLocal...` override all other
      // and are not saved to the server.
      this.set(Object.assign(
        {},
        this.scheme.undocumented,
        this.getSettingPropertiesOfObject(window, 'cd', this.scheme.undocumented),
        this.getLocalOverrides(),
      ));
    })();

    return this.initPromise;
  },

  /**
   * Request the settings from the server, or extract the settings from the existing options
   * strings.
   *
   * @param {object} [options={}]
   * @param {object} [options.options] Object containing strings with the local and global settings.
   * @param {boolean} [options.omitLocal=false] Whether to omit variables set via `cdLocal...`
   *   variables (they shouldn't need to be saved to the server).
   * @param {boolean} [options.reuse=false] If `options` is not set, reuse the cached user info
   *   request.
   * @returns {Promise.<object>}
   */
  async load({
    options,
    omitLocal = false,
    reuse = false,
  } = {}) {
    if (!options?.[cd.g.settingsOptionName]) {
      ({ options } = await getUserInfo(reuse));
    }

    let globalSettings;
    try {
      globalSettings = JSON.parse(options[cd.g.settingsOptionName]) || {};
    } catch {
      globalSettings = {};
    }

    let localSettings;
    try {
      localSettings = JSON.parse(options[cd.g.localSettingsOptionName]) || {};
    } catch (e) {
      localSettings = {};
    }

    return Object.assign(
      {},
      this.getSettingPropertiesOfObject(globalSettings),
      this.getSettingPropertiesOfObject(localSettings),
      omitLocal ? this.getLocalOverrides() : {},
    );
  },

  /**
   * Get the properties of an object corresponding to settings with an optional prefix.
   *
   * @param {object} source
   * @param {string} [prefix]
   * @param {object} [defaults=this.scheme.default]
   * @returns {object}
   * @private
   */
  getSettingPropertiesOfObject(source, prefix, defaults = this.scheme.default) {
    return Object.keys(defaults).reduce((target, name) => {
      (this.scheme.aliases[name] || []).concat(name)
        .map((alias) => prefix ? prefix + ucFirst(alias) : alias)
        .filter((prop) => (
          source[prop] !== undefined &&
          (typeof source[prop] === typeof defaults[name] || defaults[name] === null)
        ))
        .forEach((prop) => {
          target[name] = source[prop];
        });
      return target;
    }, {});
  },

  /**
   * Get settings set in common.js that are meant to override native settings.
   *
   * @returns {object}
   * @private
   */
  getLocalOverrides() {
    return this.getSettingPropertiesOfObject(window, 'cdLocal');
  },

  /**
   * Change the value of a setting or a set of settings at once without saving to the server.
   *
   * @param {string|object} name
   * @param {string} value
   * @private
   */
  set(name, value) {
    this.values ||= {};
    Object.assign(this.values, typeof name === 'string' ? { [name]: value } : name);
  },

  /**
   * Get the value of a setting without loading from the server.
   *
   * @param {string} name
   * @returns {*}
   */
  get(name) {
    return name ? this.values[name] ?? null : this.values;
  },

  /**
   * Save the settings to the server. This function will split the settings into the global and
   * local ones and make two respective requests.
   *
   * @param {object} [settings=this.values] Settings to save.
   */
  async save(settings = this.values) {
    if (!cd.user.isRegistered()) return;

    if (cd.config.useGlobalPreferences) {
      const globalSettings = {};
      const localSettings = {};
      Object.keys(settings).forEach((key) => {
        if (this.scheme.local.includes(key)) {
          localSettings[key] = settings[key];
        } else {
          globalSettings[key] = settings[key];
        }
      });

      await Promise.all([
        saveLocalOption(cd.g.localSettingsOptionName, JSON.stringify(localSettings)),
        saveGlobalOption(cd.g.settingsOptionName, JSON.stringify(globalSettings)),
      ]);
    } else {
      await saveLocalOption(cd.g.localSettingsOptionName, JSON.stringify(settings));
    }
  },

  /**
   * Update a setting value, saving it to the server and changing it for the current session as
   * well. This should be done cautiously, because many settings only have effect on page reload.
   *
   * @param {string} key The key of the settings to save.
   * @param {*} value The value to set.
   * @returns {Promise.<undefined>}
   */
  async saveSettingOnTheFly(key, value) {
    this.set(key, value);
    const settings = await this.load();
    settings[key] = value;
    return this.save(settings);
  },

  /**
   * Show a settings dialog.
   *
   * @param {string} [initalPageName]
   */
  showDialog(initalPageName) {
    if ($('.cd-dialog-settings').length) return;

    const dialog = new (require('./SettingsDialog').default)(initalPageName);
    controller.getWindowManager('settings').addWindows([dialog]);
    controller.getWindowManager('settings').openWindow(dialog);

    cd.tests.settingsDialog = dialog;
  },

  /**
   * Show a popup asking the user if they want to enable the new comment formatting. Save the
   * settings after they make the choice.
   *
   * @returns {Promise.<boolean>} Did the user enable comment reformatting.
   */
  async maybeSuggestEnableCommentReformatting() {
    if (this.get('reformatComments') !== null) {
      return false;
    }

    const { reformatComments } = await this.load({ reuse: true });
    if (definedAndNotNull(reformatComments)) {
      return false;
    }

    const action = await showConfirmDialog(
      $('<div>')
        .append(
          $('<img>')
            .attr('width', 626)
            .attr('height', 67)
            .attr('src', '//upload.wikimedia.org/wikipedia/commons/0/08/Convenient_Discussions_comment_-_old_format.png')
            .addClass('cd-rcnotice-img'),
          $('<div>')
            .addClass('cd-rcnotice-img cd-rcnotice-arrow cd-icon')
            .append(
              createSvg(30, 30, 20, 20).html(
                `<path d="M16.58 8.59L11 14.17L11 2L9 2L9 14.17L3.41 8.59L2 10L10 18L18 10L16.58 8.59Z" />`
              )
            ),
          $('<img>')
            .attr('width', 626)
            .attr('height', 118)
            .attr('src', '//upload.wikimedia.org/wikipedia/commons/d/da/Convenient_Discussions_comment_-_new_format.png')
            .addClass('cd-rcnotice-img'),
          $('<div>')
            .addClass('cd-rcnotice-text')
            .append(
              wrapHtml(cd.sParse('rc-suggestion'), {
                callbacks: {
                  'cd-notification-settings': () => {
                    this.showDialog();
                  },
                },
              }).children()
            ),
        )
        .children(),
      {
        size: 'large',
        actions: [
          {
            label: cd.s('rc-suggestion-yes'),
            action: 'accept',
            flags: 'primary',
          },
          {
            label: cd.s('rc-suggestion-no'),
            action: 'reject',
          },
        ],
      }
    );

    // Escape key press
    if (!action) {
      return false;
    }

    try {
      const reformatComments = action === 'accept';
      await this.saveSettingOnTheFly('reformatComments', reformatComments);
      return reformatComments;
    } catch (e) {
      mw.notify(cd.s('error-settings-save'), { type: 'error' });
      console.warn(e);
    }
  },

  /**
   * Show a popup asking the user if they want to receive desktop notifications, or ask for a
   * permission if it has not been granted but the user has desktop notifications enabled (for
   * example, if they are using a browser different from where they have previously used). Save the
   * settings after they make the choice.
   */
  async maybeConfirmDesktopNotifications() {
    if (typeof Notification === 'undefined') return;

    if (this.get('desktopNotifications') === 'unknown' && Notification.permission !== 'denied') {
      // Avoid using the setting kept in mw.user.options, as it may be outdated. Also don't reuse
      // the previous settings request, as the settings might be changed in
      // this.maybeSuggestEnableCommentReformatting().
      const { desktopNotifications } = await this.load();
      if (['unknown', undefined].includes(desktopNotifications)) {
        const action = await showConfirmDialog(cd.s('dn-confirm'), {
          size: 'medium',
          actions: [
            {
              label: cd.s('dn-confirm-yes'),
              action: 'accept',
              flags: 'primary',
            },
            {
              label: cd.s('dn-confirm-no'),
              action: 'reject',
            },
          ],
        });
        let promise;
        if (action === 'accept') {
          if (Notification.permission === 'default') {
            OO.ui.alert(cd.s('dn-grantpermission'));
            Notification.requestPermission((permission) => {
              if (permission === 'granted') {
                promise = this.saveSettingOnTheFly('desktopNotifications', 'all');
              } else if (permission === 'denied') {
                promise = this.saveSettingOnTheFly('desktopNotifications', 'none');
              }
            });
          } else if (Notification.permission === 'granted') {
            promise = this.saveSettingOnTheFly('desktopNotifications', 'all');
          }
        } else if (action === 'reject') {
          promise = this.saveSettingOnTheFly('desktopNotifications', 'none');
        }
        if (promise) {
          try {
            await promise;
          } catch (e) {
            mw.notify(cd.s('error-settings-save'), { type: 'error' })
            console.warn(e);
          }
        }
      }
    }

    if (
      !['unknown', 'none'].includes(this.get('desktopNotifications')) &&
      Notification.permission === 'default'
    ) {
      await OO.ui.alert(cd.s('dn-grantpermission-again'), { title: cd.s('script-name') });
      Notification.requestPermission();
    }
  },

  /**
   * Add a settings link to the page footer.
   */
  addLinkToFooter() {
    getFooter().append(
      $('<li>').append(
        $('<a>')
          .text(cd.s('footer-settings'))
          .on('click', () => {
            this.showDialog();
          })
      )
    );
  },
};