Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
* BlogLink/code.js
* @file Adds links to user's blog and activity pages to global dropdown nav
* @author Eizen <dev.wikia.com/wiki/User_talk:Eizen>
* @license CC-BY-SA 3.0
* @external "mediawiki.api"
* @external "mediawiki.util"
*/
/* jshint -W030, undef: true, unused: true, eqnull: true, laxbreak: true */
;(function (module, window, $, mw) {
"use strict";
// Prevent double loads and respect prior double load check formatting
if (!window || !$ || !mw || module.isLoaded || window.isBlogLinkLoaded) {
return;
}
module.isLoaded = true;
// Protected pseudo-enums
Object.defineProperties(this, {
/**
* @description This pseudo-enum contains a pair of <code>string</code>
* <code>object</code> properties containing the names of various element
* selectors targeted by the script's various methods or applied to the new
* user dropdown tabs added by the script. The <code>NAMES</code> property
* contains the names of selectors added to elements generated by the script
* itself, while the <code>TARGETS</code> property contains jQuery-friendly
* formatted selectors that are targeted for the retrieval of their text
* content or used as parent nodes to which is added script-constructed
* child HTML.
*
* @readonly
* @enum {object}
*/
Selectors: {
enumerable: true,
writable: false,
configurable: false,
value: Object.freeze({
NAMES: Object.freeze({
CLASS_DROPDOWN_LI: "user-panel__list-item",
DIR_DROPDOWN_LI: "auto",
ID_DROPDOWN: "bl-"
}),
TARGETS: Object.freeze({
NAVIGATION_PANEL: "#user-panel__content .navigation-panel",
NAVIGATION_CONTRIBS: "a[data-label='account.contributions']",
DEFAULT_PLACEMENT: "a[data-label='account.preferences']"
}),
}),
},
/**
* @description The <code>Config</code> pseudo-enum is used primarily by
* <code>this.validateConfig</code> to ensure that the user's input config
* (if applicable) is well-formed and properly defined prior to its usage
* by the script. If the user has chosen not to include certain properties
* in the config object, the default values established in this enum are
* applied instead as default values. The enum contains two total data
* <code>object</code>s establishing both the formal name of the property as
* it exists in the config object and its associated default value.
*
* @readonly
* @enum {object}
*/
Config: {
enumerable: true,
writable: false,
configurable: false,
value: Object.freeze({
SHOW_CONTRIBS: Object.freeze({
NAME: "contribs",
DEFAULT: true,
}),
SHOW_ACTIVITY: Object.freeze({
NAME: "activity",
DEFAULT: true,
}),
}),
},
/**
* @description The <code>Dependencies</code> pseudo-enum contains a pair of
* <code>string</code> arrays as properties. The <code>GLOBALS</code> array
* contains the various <code>wg</code> globals that are fetched and cached
* via a <code>mw.config.get</code> invocation later in the application,
* while the <code>MODULES</code> array contains the names of ResourceLoader
* modules that are loaded via <code>mw.config.get</code> at the start of
* the program's execution.
*
* @readonly
* @enum {object}
*/
Dependencies: {
enumerable: true,
writable: false,
configurable: false,
value: Object.freeze({
GLOBALS: Object.freeze([
"wgUserLanguage",
"wgUserName"
]),
MODULES: Object.freeze([
"mediawiki.api",
"mediawiki.util"
]),
})
},
/**
* @description The <code>Tabs</code> pseudo-enum contains a pair of
* <code>string</code> arrays as properties that refer to the two possible
* nav tabs that can be added to the dropdown, namely "blog" and "user
* activity." Each object has a message property with a value corresponding
* to the internal name of the system message referring to that message's
* text and an address property that indicates where the associated link
* should point.
*
* @readonly
* @enum {object}
*/
Tabs: {
enumerable: true,
writable: false,
configurable: false,
value: Object.freeze({
BLOG: Object.freeze({
MESSAGE: "userprofile-userprofilenavigation-link-user-blog",
ADDRESS: "User_blog:$1"
}),
ACTIVITY: Object.freeze({
MESSAGE: "userprofile-userprofilenavigation-link-activity",
ADDRESS: "Special:UserProfileActivity/$1"
}),
})
},
/**
* @description The <code>Utility</code> pseudo-enum houses assorted
* constants of various data types used through the program for various
* purposes. It contains the <code>setInterval</code> at which the script
* scans the page for the user nav dropdown, a <code>boolean</code> flag for
* debug mode, and several <code>string</code> related to the script name
* and the related <code>mw.hook</code> event name.
*
* @readonly
* @enum {number|boolean|string}
*/
Utility: {
enumerable: true,
writable: false,
configurable: false,
value: Object.freeze({
CHECK_RATE: 300,
DEBUG: false,
SCRIPT: "BlogLink",
HOOK_NAME: "dev.blogLink",
LS_PREFIX: "BlogLink-cache-messages-",
}),
},
});
/**
* @description As the name implies, the <code>assembleUserTab</code> method
* is used to build the custom user tabs linking to the user's blog list (and
* user activity page, if applicable). These are attached to the nav dropdown
* once the user clicks the profile circle icon (everything is lazy-loaded
* now, apparently).
*
* @param {string} paramText - <code>string</code> message text to display
* @param {string} paramIDName - <code>string</code> to use in forming IDs
* @param {string} paramAddress - The <code>href</code> value for link
* @returns {string} - Formatted <code>string</code> HTML
*/
this.assembleUserTab = function (paramText, paramIDName, paramAddress) {
return mw.html.element("li", {
"class": this.Selectors.NAMES.CLASS_DROPDOWN_LI,
"id": this.Selectors.NAMES.ID_DROPDOWN + paramIDName + "-li",
"dir": this.Selectors.NAMES.DIR_DROPDOWN_LI
}, new mw.html.Raw(
mw.html.element("a", {
"id": this.Selectors.NAMES.ID_DROPDOWN + paramIDName + "-a",
"href": mw.util.getUrl(paramAddress.replace("$1",
this.info.globals.wgUserName)),
"title": paramText
}, paramText)
));
};
/**
* @description This helper function, based on MassEdit's assorted validation
* methods, is used to ensure that the user's inputted config has properties
* of the proper data type, i.e. <code>boolean</code> for the two flags
* denoting the user's preferences. If no property exists or if the wrong data
* type is detected, the default value specified in <code>this.Config</code>
* is applied instead.
*
* @param {object} paramConfig - User config <code>object</code> to validate
* @returns {object} config - Frozen well-formed config <code>object</code>
*/
this.validateConfig = function (paramConfig) {
// Declarations
var element, entry, fields, config;
// Definitions
config = {};
fields = this.Config;
// Set to default if user input doesn't exist or if wrong data type
for (element in fields) {
if (!fields.hasOwnProperty(element)) {
continue;
}
entry = fields[element];
// Define with default if no property or if input is of wrong data type
config[entry.NAME] = (!paramConfig.hasOwnProperty(entry.NAME) ||
typeof paramConfig[entry.NAME] !== typeof entry.DEFAULT)
? entry.DEFAULT
: paramConfig[entry.NAME];
}
return Object.freeze(config);
};
/**
* @description The <code>getMessage</code> method replaces the previous
* script version's usage of the I18n-js external dependency in favor of
* simply retrieving the latest versions/translations of the system message
* via API query. This is done ia the invocation of
* <code>mw.Api.prototype.getMessages</code>. For the purposes of simplicity,
* the method itself sorts through the returned data <code>object</code> for
* the desired "Blog" and "Activity" text and returns those
* <code>string</code>s alone as an array wrapped in a new
* <code>$.Deferred</code> promise.
*
* @param {Array<string>} paramMessages - <code>mw.message</code> name array
* @returns {object} - Resolved/rejected <code>$.Deferred</code>
*/
this.getMessages = function (paramMessages) {
// Declarations
var message, messages;
return new mw.Api().getMessages(paramMessages, {
amlang: this.info.globals.wgUserLanguage,
}).then(function (paramData) {
for (message in paramData) {
if (paramData.hasOwnProperty(message)) {
(messages = messages || []).push(paramData[message]);
}
}
return messages;
}.bind(this), window.console.error.bind(null, this.Utility.SCRIPT));
};
/**
* @description This handler, borrowed from MassEdit, is used for returning
* message data from storage and adding new data to storage for reuse.
* <code>localStorage</code> is accessed safely via <code>mw.storage</code>
* and placed within a <code>try...catch</code> block to handle any additional
* thrown errors. A local object stored in <code>this.info</code> is used as
* a fallback in the event of an error being thrown.
*
* @see <a href="https://git.io/JfrsN">Wikia's jquery.store.js (pre-UCP)</a>
* @param {string} paramKey - Name of requested message
* @param {string} paramValue- Content of message when setting (optional)
* @returns {string|null} - Returns message content or <code>null</code>
*/
this.queryStorage = function (paramKey, paramValue) {
// Declarations
var isSetting, lsKey, messages;
// Sanitize parameter
paramKey = paramKey.toLowerCase();
// Handler can be used for both getting and setting, so check for which
isSetting = (Array.prototype.slice.call(arguments).length == 2 &&
paramValue != null);
// Handle i18n for user language preference and uselang URL parameter
lsKey = this.Utility.LS_PREFIX + this.info.globals.wgUserLanguage;
// Get message data from localStorage
try {
messages = this.info.messages = JSON.parse(mw.storage.get(lsKey)) || {};
} catch (paramError) {
if (this.Utility.DEBUG) {
window.console.error(paramError);
}
// Use fallback if localStorage throws
messages = this.info.messages = this.info.messages || {};
}
// Return string HTML of requested message or explicit null
if (!isSetting) {
return (messages.hasOwnProperty(paramKey)) ? messages[paramKey] : null;
}
if (!messages.hasOwnProperty(paramKey)) {
messages[paramKey] = paramValue;
// Add to localStorage
try {
mw.storage.set(lsKey, JSON.stringify(messages));
} catch (paramError) {
if (this.Utility.DEBUG) {
window.console.error(paramError);
}
}
// Make sure new messages are added to localStorage
if (this.Utility.DEBUG) {
try {
window.console.log("info.messages: ", this.info.messages);
window.console.log("localStorage: ",
JSON.parse(window.localStorage.getItem(lsKey)));
} catch (paramError) {
window.console.error(paramError);
}
}
}
return messages[paramKey];
};
/**
* @description The <code>main</code> method is called once the script
* initialization process handled by <code>init</code> has completed. This
* method is used to load the latest version/translation of the requisite
* <code>mw.message</code>s from the API, wait until the dropdown has loaded,
* then assemble and add the custom blog/activity tab/navigation links to the
* target element. <code>$.when</code> is used to concurrently coordinate the
* loading of the message texts with the loading of the dropdown, the latter
* of which makes use of <code>setInterval</code> and a helper
* <code>$.Deferred</code> to ensure that subsequent program flow does not
* occur until the dropdown is loaded.
*
* @returns {void}
*/
this.main = function () {
// Declarations
var i, $helper, targets, target, tabs, tab, messages, message, addresses,
$getEssentials, interval, needsMessages;
// Definitions
$helper = new $.Deferred();
targets = this.Selectors.TARGETS;
target = targets.NAVIGATION_PANEL;
needsMessages = false;
i = 0;
if (this.Utility.DEBUG) {
window.console.log("target:", target);
}
for (tab in this.Tabs) {
if (!this.Tabs.hasOwnProperty(tab)) {
continue;
}
// Translate enum member objects to arrays
(tabs = tabs || []).push(tab.toLowerCase());
(messages = messages || []).push(this.Tabs[tab].MESSAGE);
(addresses = addresses || []).push(this.Tabs[tab].ADDRESS);
// Get cached localStorage data for tab entry
message = this.queryStorage(tabs[i]);
if (this.Utility.DEBUG) {
window.console.log("queryStorage(" + tabs[i] + "): ", message);
}
// If no localStorage data, set flag; otherwise add to messages arrays
if (message == null) {
needsMessages = true;
} else {
messages[i] = message;
}
i++;
}
// Make initial call to progress before interval in case dropdown exists
$getEssentials = $.when(
(needsMessages
? this.getMessages(messages)
: new $.Deferred().resolve(messages).promise()),
$helper.notify().promise()
);
// Continually check for presence of dropdown via setInterval
interval = window.setInterval($helper.notify, this.Utility.CHECK_RATE);
/**
* @description The helper <code>$.Deferred</code> is pinged via the use of
* <code>$.Deferred.notify<code> every time the script needs to check if
* the targeted dropdown exists. Given that the new dropdown is lazy-loaded,
* the callback is pinged every 200 ms by <code>setInterval</code> until the
* dropdown joins the party. Once it exists, <code>$helper</code> is
* resolved and execution continues to the <code>$.when</code> handler.
*/
$helper.progress(function () {
if (this.Utility.DEBUG) {
window.console.log("$helper.progress");
}
// Check for target, or check if interval (hence UCP)
if (!$(target).length) {
return;
} else if (interval) {
window.clearInterval(interval);
}
// Resolve helper $.Deferred once masthead is found
$helper.resolve();
}.bind(this));
/**
* @description Once <code>$getEssentials</code> resolves or rejects, the
* associated <code>then</code> handlers are invoked accordingly. If the
* <code>$.when</code> <code>$.Deferred</code> is resolved, meaning that the
* dropdown has been loaded and the latest translation of the system message
* has been successfully retrieved, the Blog and Activity options are
* constructed via <code>this.assembleTab</code> and appended to the target.
*/
$getEssentials.then(function (paramMessages) {
if (this.Utility.DEBUG) {
window.console.log("paramMessages", paramMessages);
}
// Place BlogLink after contribs
$($(targets.NAVIGATION_CONTRIBS).length
? targets.NAVIGATION_CONTRIBS
: targets.DEFAULT_PLACEMENT
).parent().after(
this.assembleUserTab(paramMessages[0], tabs[0], addresses[0])
);
// Place Activity tab before contribs if user-selected
if (this.info.config.activity) {
$(targets.DEFAULT_PLACEMENT).parent().after(
this.assembleUserTab(paramMessages[1], tabs[1], addresses[1])
);
}
// Hide contributions tab if option selected in user config
if (!this.info.config.contribs) {
$(target + " " + targets.NAVIGATION_CONTRIBS).parent().remove();
}
// Cache messages in localStorage
if (needsMessages) {
for (i = 0; i < paramMessages.length; i++) {
this.queryStorage(tabs[i], paramMessages[i]);
}
}
}.bind(this), window.console.error.bind(null, this.Utility.SCRIPT));
};
/**
* @description The <code>init</code> method is called once the requisite
* ResourceLoader modules have been loaded and is tasked with setting up the
* script in preparation for the creation and insertion of the custom tabs by
* <code>this.main</code>. The method performs the usual checks for duplicate
* script loads and fetching/caching of required globals. The script exposes a
* method called <code>observeScript</code> that displays the content of
* <code>this</code>, the internal context object containing all the shared
* state variables and assorted methods.
*
* @returns {void}
*/
this.init = function () {
// Object for storage of informational data
this.info = {};
// Validate any user config
this.info.config = this.validateConfig(window.blogLinkConfig || {});
if (this.Utility.DEBUG) {
window.console.log("this.info.config", this.info.config);
}
// Fetch and cache wg globals
this.info.globals = Object.freeze(mw.config.get(this.Dependencies.Globals));
// Expose public methods for external debugging
Object.defineProperty(module, "exports", {
enumerable: true,
writable: false,
configurable: false,
value: Object.freeze({
observeScript: window.console.dir.bind(this, this),
})
});
// Dispatch hook with window.dev.blogLink once init is complete
mw.hook(this.Utility.HOOK_NAME).fire(module).add(this.main.bind(this));
};
// Coordinate loading of all relevant dependencies
$.when(mw.loader.using(this.Dependencies.MODULES), $.ready)
.done(this.init.bind(this))
.fail(window.console.error.bind(null, this.Utility.SCRIPT));
}.call(Object.create(null), (this.dev = this.dev || {}).blogLink =
this.dev.blogLink || {}, this, this.jQuery, this.mediaWiki));