/**
* Singleton used to obtain instances of the `User` class while avoiding creating duplicates.
*
* @module userRegistry
*/
import StorageItem from './StorageItem';
import cd from './cd';
import controller from './controller';
import { handleApiReject } from './utils-api';
import { ucFirst, underlinesToSpaces } from './utils-general';
/**
* Class representing a user. Is made similar to
* {@link https://doc.wikimedia.org/mediawiki-core/master/js/mw.user.html mw.user} so that it is
* possible to pass it to
* {@link https://doc.wikimedia.org/mediawiki-core/master/js/mw.html#.msg mw.msg()} and have
* `{{gender:}}` replaced.
*
* To create an instance, use {@link module:userRegistry.get} (the constructor is only exported for
* means of code completion).
*/
export class User {
options = new mw.Map();
/**
* Create a user object.
*
* @param {string} name
* @param {object} options
*/
constructor(name, options = {}) {
this.name = name;
this.muted = false;
Object.keys(options).forEach((name) => {
this.options.set(name, options[name]);
});
}
/**
* Is the user registered (not an IP user).
* {@link https://www.mediawiki.org/wiki/Help:Temporary_accounts Temporary accounts} are
* considered registered users.
*
* @type {boolean}
*/
isRegistered() {
if (this.name === '<unregistered>') {
return false;
}
this.registered ??= !mw.util.isIPAddress(this.name);
return this.registered;
}
/**
* Get the user name.
*
* @returns {string}
*/
getName() {
return this.name;
}
/**
* Set a gender for the user.
*
* @param {'male'|'female'|'unknown'} value
*/
setGender(value) {
this.options.set('gender', value);
}
/**
* User's gender (must be obtained using {@link module:utilsApi.loadUserGenders}).
*
* @returns {'male'|'female'|'unknown'}
*/
getGender() {
return this.options.get('gender');
}
/**
* Set the user's rights.
*
* @param {string[]} rights
*/
setRights(rights) {
this.rights = rights;
}
/**
* Get the user's rights (must be obtained using {@link module:utilsApi.getUserInfo}).
*
* @type {?(string[])}
*/
getRights() {
return this.rights?.slice() || null;
}
/**
* Get the preferred namespace alias, based on:
* 1. the `genderNeutralUserNamespaceAlias` CD config value (first choice);
* 2. the `userNamespacesByGender` CD config value, if the gender is known (second choice);
* 3. the `wgFormattedNamespaces` MediaWiki config value (third choice).
*
* @returns {string}
*/
getNamespaceAlias() {
return (
cd.config.genderNeutralUserNamespaceAlias ||
cd.config.userNamespacesByGender?.[this.getGender()] ||
mw.config.get('wgFormattedNamespaces')[2]
);
}
/**
* Get the user's global ID according to the database if it was set before.
*
* @returns {number}
*/
getGlobalId() {
return this.globalId;
}
/**
* Set the user's global ID according to the database.
*
* @param {number} value
*/
setGlobalId(value) {
this.globalId = Number(value);
}
/**
* Check if the user is muted.
*
* @returns {boolean}
*/
isMuted() {
return this.muted;
}
/**
* Set if the user is muted.
*
* @param {boolean} value
*/
setMuted(value) {
this.muted = Boolean(value);
}
}
export default {
/**
* Collection of users.
*
* @type {object}
* @private
*/
items: {},
/**
* Get a user object for a user with the specified name (either a new one or already existing).
*
* @param {string} name
* @returns {User}
*/
get(name) {
if (name.includes('#')) {
name = name.slice(0, name.indexOf('#'));
}
name = (
mw.util.isIPv6Address(name) ?
name.toUpperCase() :
underlinesToSpaces(ucFirst(name))
).trim();
this.items[name] ||= new User(
name,
name === cd.g.userName ? { gender: mw.user.options.get('gender') } : {}
);
return this.items[name];
},
/**
* Get a user object for the current user.
*
* @returns {User}
*/
getCurrent() {
return this.get(cd.g.userName);
},
/**
* Make an API request and assign the muted status to respective user objects.
*
* @fires mutedUsers
*/
loadMuted() {
const userIdList = mw.user.options.get('echo-notifications-blacklist');
if (!userIdList || !cd.g.useGlobalPreferences) return;
const userIds = userIdList.split('\n');
const mutedUsersStorage = new StorageItem('mutedUsers');
const mutedUsersData = mutedUsersStorage.getAll();
if (
!mutedUsersData.users ||
userIds.some((id) => !mutedUsersData.users[id]) ||
// Users can be renamed, so we can cache for a week max.
// FIXME: Remove `([keep] || mutedUsersData.saveUnixTime)` after June 2024
(mutedUsersData.saveTime || mutedUsersData.saveUnixTime) < Date.now() - 7 * cd.g.msInDay
) {
this.getUsersByGlobalId(userIds).then(
(users) => {
users.forEach((user) => {
user.setMuted(true);
});
mutedUsersStorage
.set('mutedUsers', {
users: Object.assign({}, ...users.map((user) => ({
[user.getGlobalId()]: user.getName(),
}), {})),
saveTime: Date.now(),
})
.save();
/**
* The list of muted users has been obtained from the server or local storage.
*
* @event mutedUsers
* @param {User[]} users
* @global
*/
mw.hook('convenientDiscussions.mutedUsers').fire(users);
},
(e) => {
console.error('Couldn\'t load the names of the muted users.', e);
}
);
} else {
const users = Object.entries(mutedUsersData.users).map(([, name]) => this.get(name));
users.forEach((user) => user.setMuted(true));
mw.hook('convenientDiscussions.mutedUsers').fire(users);
}
},
/**
* Given a list of user IDs, return a list of users.
*
* @param {number[]|string[]} userIds List of user IDs.
* @returns {Promise.<import('./userRegistry').User[]>}
*/
async getUsersByGlobalId(userIds) {
const requests = userIds.map((id) => (
controller.getApi().post({
action: 'query',
meta: 'globaluserinfo',
guiid: id,
}).catch(handleApiReject)
));
return (await Promise.all(requests)).map((resp) => {
const userInfo = resp.query.globaluserinfo;
const user = this.get(userInfo.name);
user.setGlobalId(userInfo.id);
return user;
});
},
};