Jump to content

User:Guycn2/sandbox3.js

From Meta, a Wikimedia project coordination wiki

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.
/*


== User Info Popup ==

Adds an "i" (info) icon at the top of user-related pages
(e.g. user pages, user talk pages, "Contributions" pages, etc.)

The color of the "i" icon represents the amount of time passed
since the user last edited:
* Green – user last edited less than 20 minutes ago
* Orange – user last edited more than 20 minutes ago, but less than 3 months ago
* Red – user last edited more than 3 months ago

Hover over the "i" icon to quickly view useful information about the relevant user:
* Registration date
* Number of edits
* Time elapsed since last edit
* User groups (rights), incl. global ones
* Latest block time (incl. range and global blocks, when applicable)
* Gender (if disclosed)

See full documentation at:
[[User:Guycn2/UserInfoPopup]]

See also:
* [[User:Guycn2/UserInfoPopup.css]] – for the corresponding style sheet

Skins supported:
Vector (both 2022 and 2010), Monobook, Timeless, and Minerva.
Also fully supported on the mobile interface.

Dependencies:
* mediawiki.api
* mediawiki.language.months
* mediawiki.user
* mediawiki.util
* user.options
* oojs-ui-core


Written by: [[User:Guycn2]]


*/

/**
 * @file User Info Popup - A MediaWiki user script that displays an interactive
 *       popup with detailed user information on user-related pages.
 * 
 * @author Guycn2
 * @version 1.1
 * @license CC-BY-SA-4.0
 * @requires mediawiki.api
 * @requires mediawiki.language.months
 * @requires mediawiki.user
 * @requires mediawiki.util
 * @requires user.options
 * @requires oojs-ui-core
 * @see {@link https://en.wikipedia.org/wiki/User:Guycn2/UserInfoPopup Documentation}
 */

( async () => {

	'use strict';

	const username = mw.config.get( 'wgRelevantUserName' );

	if ( !username || mw.config.get( 'userInfoPopupLoaded' ) ) {
		return;
	}

	mw.config.set( 'userInfoPopupLoaded', true );
	await mw.loader.using( [ 'mediawiki.api', 'mediawiki.util' ] );
	const isAnon = mw.util.isIPAddress( username );
	const api = new mw.Api( { userAgent: 'UserInfoPopup/1.1' } );

	/**
	 * Checks whether the relevant user exists by querying the MediaWiki API.
	 * For anonymous users (IP addresses), existence is assumed.
	 *
	 * @function checkIfUserExists
	 * @async
	 * @returns {Promise<boolean>} True if the user exists or is an IP address,
	 *          false otherwise.
	 */
	async function checkIfUserExists() {
		if ( isAnon ) {
			return true;
		}

		const data = await api.get( { list: 'users', ususers: username } );

		if ( data.query.users[ 0 ].userid ) {
			return true;
		}

		return false;
	} // checkIfUserExists END

	if ( !( await checkIfUserExists() ) ) {
		return;
	}

	mw.loader.load(
		'https://en.wikipedia.org/w/index.php?title=User:Guycn2/UserInfoPopup.css&action=raw&ctype=text/css',
		'text/css'
	);

	const scriptData = {
		$elements: {},
		lang: mw.config.get( 'wgUserLanguage' ),
		skin: mw.config.get( 'skin' ),
		secsFromLastEdit: await calcSecsFromLastEdit()
	};

	createInfoIcon();
	await $.when( mw.loader.using( 'oojs-ui-core' ), $.ready );
	addInfoIconToPage();
	attachEventListeners();

	/**
	 * Retrieves the localized message string for the given key. Falls back to English
	 * if the translation is not available in the user's language.
	 *
	 * @function i18n
	 * @param {string} key - The message key to look up in the translations object.
	 * @returns {string|string[]} The localized message string, or an array of strings
	 *          for pluralized messages.
	 */
	function i18n( key ) {
		const messages = {
			en: {
				infoIconAlt: 'Info icon',
				femaleSymbolAlt: 'Female',
				maleSymbolAlt: 'Male',
				fetchingData: 'Fetching data…',
				regUnknown: 'Unknown',
				joined: 'Joined:',
				editCount: 'Edits:',
				lastEdited: 'Last edited:',
				lastEditedNever: 'Never',
				lastEditedUnknown: 'Unknown',
				groups: 'Groups:',
				noGroups: 'None',
				lastBlocked: 'Last blocked:',
				neverBlocked: 'Never',
				partiallyBlocked: 'Currently blocked (partially)',
				fullyBlocked: 'Currently blocked',
				rangeBlockedPartially: 'Currently range-blocked (partially)',
				rangeBlockedFully: 'Currently range-blocked',
				globallyBlocked: 'Currently blocked globally',
				globallyLocked: 'Currently locked globally',
				ago: '$1 ago',
				seconds: [ '1 second', '$1 seconds' ],
				minutes: [ '1 minute', '$1 minutes' ],
				hours: [ '1 hour', '$1 hours' ],
				days: [ '1 day', '$1 days' ],
				weeks: [ '1 week', '$1 weeks' ],
				months: [ '1 month', '$1 months' ],
				years: [ '1 year', '$1 years' ]
			},

			he: {
				infoIconAlt: 'צלמית מידע',
				femaleSymbolAlt: 'נקבה',
				maleSymbolAlt: 'זכר',
				fetchingData: 'המידע בטעינה…',
				regUnknown: 'לא ידוע',
				joined: 'הרשמה:',
				editCount: 'עריכות:',
				lastEdited: 'עריכה אחרונה:',
				lastEditedNever: 'אין',
				lastEditedUnknown: 'לא ידוע',
				groups: 'קבוצות:',
				noGroups: 'ללא',
				lastBlocked: 'חסימה אחרונה:',
				neverBlocked: 'אין',
				partiallyBlocked: 'חסימה פעילה כעת (חלקית)',
				fullyBlocked: 'חסימה פעילה כעת',
				rangeBlockedPartially: 'חסימת טווח פעילה כעת (חלקית)',
				rangeBlockedFully: 'חסימת טווח פעילה כעת',
				globallyBlocked: 'חסימה גלובלית פעילה כעת',
				globallyLocked: 'נעילה גלובלית פעילה כעת',
				ago: 'לפני $1',
				seconds: [ 'שנייה', '$1 שניות' ],
				minutes: [ 'דקה', '$1 דקות' ],
				hours: [ 'שעה', 'שעתיים', '$1 שעות' ],
				days: [ 'יום', 'יומיים', '$1 ימים' ],
				weeks: [ 'שבוע', 'שבועיים', '$1 שבועות' ],
				months: [ 'חודש', 'חודשיים', '$1 חודשים' ],
				years: [ 'שנה', 'שנתיים', '$1 שנים' ]
			}
		};

		return messages[ scriptData.lang ]?.[ key ] || messages.en[ key ];
	} // i18n END

	/**
	 * Calculates the number of seconds elapsed since the user's last edit by
	 * querying the user contributions API endpoint.
	 *
	 * @function calcSecsFromLastEdit
	 * @async
	 * @returns {Promise<number|null>} The number of seconds since the user's last edit,
	 *          or null if the user has never edited.
	 */
	async function calcSecsFromLastEdit() {
		const params = {
			list: 'usercontribs',
			ucuser: username,
			ucprop: 'timestamp',
			uclimit: 1
		};

		const data = await api.get( params );

		if ( data.query.usercontribs.length === 0 ) {
			return null;
		}

		const lastEditTime =
			new Date( data.query.usercontribs[ 0 ].timestamp ).getTime();

		return ( mw.now() - lastEditTime ) / 1000;
	} // calcSecsFromLastEdit END

	/**
	 * Creates the info icon element with appropriate color based on the user's
	 * last edit time. The icon color indicates activity level: green for recent
	 * activity (under 20 minutes), orange for moderate activity (under 3 months),
	 * red for inactive users (over 3 months), and grey for users who never edited.
	 *
	 * @function createInfoIcon
	 */
	function createInfoIcon() {
		const $img = $( '<img>' )
			.addClass( 'user-info-popup-icon' )
			.attr( {
				alt: i18n( 'infoIconAlt' ),
				width: '20.3',
				height: '20.3'
			} );

		if ( scriptData.secsFromLastEdit === null ) {
			$img
				.addClass( 'user-info-popup-grey-icon' )
				.attr(
					'src',
					'https://upload.wikimedia.org/wikipedia/commons/d/df/Information_grey.svg'
				);
		} else if ( scriptData.secsFromLastEdit < 60 * 20 ) {
			$img
				.addClass( 'user-info-popup-green-icon' )
				.attr(
					'src',
					'https://upload.wikimedia.org/wikipedia/commons/7/7d/Information_green.svg'
				);
		} else if ( scriptData.secsFromLastEdit < 60 * 60 * 24 * 30 * 3 ) {
			$img
				.addClass( 'user-info-popup-orange-icon' )
				.attr(
					'src',
					'https://upload.wikimedia.org/wikipedia/commons/f/f0/Information_orange.svg'
				);
		} else {
			$img
				.addClass( 'user-info-popup-red-icon' )
				.attr(
					'src',
					'https://upload.wikimedia.org/wikipedia/commons/5/55/Information_red.svg'
				);
		}

		scriptData.$elements.indicator = $( '<div>' )
			.addClass( 'mw-indicator' )
			.attr( { id: 'mw-indicator-user-info-popup-indicator', tabindex: '0' } )
			.append( $img );
	} // createInfoIcon END

	/**
	 * Adds the info icon indicator and its associated popup widget to the page.
	 * Handles placement differences across various MediaWiki skins including
	 * Vector 2022, Vector 2010, Monobook, Timeless, and Minerva.
	 *
	 * @function addInfoIconToPage
	 */
	function addInfoIconToPage() {
		const $throbberImg = $( '<img>' ).attr( {
			alt: i18n( 'fetchingData' ),
			id: 'user-info-popup-throbber',
			src: 'https://upload.wikimedia.org/wikipedia/commons/f/f8/Ajax-loader(2).gif'
		} );

		const $placeholderText = $( '<p>' )
			.attr( 'id', 'user-info-popup-placeholder-text' )
			.text( i18n( 'fetchingData' ) );

		scriptData.$elements.popupPlaceholder = $( '<div>' )
			.attr( 'id', 'user-info-popup-placeholder' )
			.append( $throbberImg, $placeholderText );

		scriptData.popup = new OO.ui.PopupWidget( {
			$content: scriptData.$elements.popupPlaceholder,
			align: 'backwards',
			autoFlip: false,
			id: 'user-info-popup-popup',
			hideWhenOutOfView: false,
			padded: true,
			position: 'below',
			width: 225
		} );

		scriptData.$elements.indicator.append( scriptData.popup.$element );

		if (
			scriptData.skin === 'vector-2022' &&
			$( '.vector-page-toolbar-container:has( #ca-nstab-user )' ).length
		) {

			const $navBtns = $( '#right-navigation > nav:first-of-type' );

			if ( $navBtns.length ) {
				scriptData.$elements.indicator.insertAfter( $navBtns );
			} else {
				scriptData.$elements.indicator.insertBefore(
					'.vector-page-tools-landmark:has( #vector-page-tools-dropdown )'
				);
			}

		} else {
			const $indicatorsContainer = $( '.mw-indicators' );

			if (
				!window.matchMedia( '( orientation: portrait )' ).matches ||
				scriptData.skin === 'vector-2022' ||
				scriptData.skin === 'vector' ||
				(
					scriptData.skin === 'monobook' &&
					!$( '#sidebar-toggle:visible' ).length
				)
			) {
				scriptData.popup.setAlignment( 'forwards' );
				scriptData.popup.setPosition( 'before' );

				if ( $indicatorsContainer.children( '.mw-indicator' ).length >= 6 ) {
					scriptData.popup.setAutoFlip( true );
				}
			}

			if ( scriptData.skin === 'minerva' ) {
				scriptData.$elements.indicator
					.css( 'float', $( 'body.rtl' ).length ? 'left' : 'right' )
					.appendTo( '.header-container' );
			} else {
				$indicatorsContainer.prepend( scriptData.$elements.indicator );
			}
		}
	} // addInfoIconToPage END

	/**
	 * Attaches all necessary event listeners for the popup functionality. Handles
	 * mouse enter/leave events, keyboard navigation (Enter, Space, Escape), focus
	 * management, and click events for opening and closing the popup.
	 *
	 * @function attachEventListeners
	 */
	function attachEventListeners() {
		scriptData.popup.on( 'ready', () => {
			// Prevent mobile browsers from occasionally jumping
			// to the top of the page when tapping the "i" icon.
			window.scrollTo( scriptData.posX, scriptData.posY );

			if (
				document.documentElement.clientWidth < 600 &&
				scriptData.skin === 'vector-2022' &&
				scriptData.popup.$element.hasClass( 'oo-ui-popupWidget-anchored-top' )
			) {
				adaptPopupPosition();
			}

			scriptData.popup.$element.hide().fadeIn();
		} );

		scriptData.$elements.indicator.on( 'mouseenter focusin keydown', evt => {
			if ( evt.type === 'keydown' ) {
				if ( ![ 'Enter', ' ' ].includes( evt.key ) ) {
					return;
				}

				if ( evt.key === ' ' ) {
					evt.preventDefault();
				}
			}

			clearTimeout( scriptData.mouseLeaveTimeout );
			scriptData.mouseEnterTimeout = setTimeout( openPopup, 200 );
		} );

		scriptData.$elements.indicator.on( 'mouseleave focusout', () => {
			if (
				document.activeElement.id === 'mw-indicator-user-info-popup-indicator' ||
				document.activeElement.parentElement.classList.contains(
					'user-info-popup-value'
				)
			) {
				return;
			}

			clearTimeout( scriptData.mouseEnterTimeout );
			scriptData.mouseLeaveTimeout = setTimeout( closePopup, 2500 );
		} );

		$( document ).on( 'keydown', evt => {
			if ( evt.key === 'Escape' ) {
				closePopup();
			}
		} );

		$( document ).on( 'click', closePopup );

		$( '.oo-ui-fieldsetLayout-header, .ext-discussiontools-init-section-bar' )
			.on( 'click', closePopup );

		scriptData.$elements.indicator.on( 'click', evt => evt.stopPropagation() );
	} // attachEventListeners END

	/**
	 * Adjusts the popup position for narrow viewports in Vector 2022 skin.
	 * Calculates the correct horizontal offset relative to the page container
	 * and applies it via a dynamically injected CSS rule.
	 *
	 * @function adaptPopupPosition
	 */
	function adaptPopupPosition() {
		const innerBody = document.querySelector( '.mw-page-container' );
		const innerBodyRect = innerBody.getBoundingClientRect();
		const indicator = scriptData.$elements.indicator[ 0 ];
		const indicatorRect = indicator.getBoundingClientRect();
		const dir = $( 'body.rtl' ).length ? 'left' : 'right';

		const pos =
			Math.abs( indicatorRect[ dir ] - innerBodyRect[ dir ] ) -
			indicator.offsetWidth / 2;

		scriptData.popupCss = mw.util.addCSS(
			`#user-info-popup-popup { ${ dir }: ${ pos }px !important; }`
		);
	} // adaptPopupPosition END

	/**
	 * Opens the popup widget and initiates data fetching if not already done.
	 * Stores the current scroll position to prevent unwanted scrolling on mobile
	 * devices when the popup opens.
	 *
	 * @function openPopup
	 */
	function openPopup() {
		if ( !scriptData.popup.isVisible() ) {
			// posX and posY are used to prevent mobile browsers from 
			// occasionally jumping to the top of the page when tapping
			// the "i" icon. See the popup's "ready" event listener above.
			scriptData.posX = window.scrollX;
			scriptData.posY = window.scrollY;

			scriptData.popup.toggle( true );

			if ( !scriptData.dataFetched ) {
				getUserData().then( fillPopupContent );
				scriptData.dataFetched = true;
			}
		}
	} // openPopup END

	/**
	 * Closes the popup widget with a fade-out animation. Clears any pending
	 * timeout for auto-closing and disables any custom CSS positioning rules
	 * that were applied for narrow viewports.
	 *
	 * @function closePopup
	 */
	function closePopup() {
		clearTimeout( scriptData.mouseLeaveTimeout );

		if ( scriptData.popup.isVisible() ) {
			scriptData.popup.$element.fadeOut( () => {
				scriptData.popup.toggle( false );
				scriptData.popup.$element.show();

				if ( scriptData.popupCss ) {
					scriptData.popupCss.disabled = true;
				}
			} );
		}
	} // closePopup END

	/**
	 * Fetches comprehensive user data from the MediaWiki API. For registered users,
	 * retrieves registration date, edit count, gender, group memberships, and block
	 * status. For anonymous users (IPs), retrieves edit count and block information
	 * including range blocks and global blocks.
	 *
	 * @function getUserData
	 * @async
	 * @returns {Promise<void>} Resolves when all user data has been fetched and
	 *          stored in scriptData.
	 */
	async function getUserData() {
		let params;

		if ( isAnon ) {
			params = {
				list: 'blocks|globalblocks|logevents|usercontribs',
				bkip: username,
				bkprop: 'flags|user',
				bklimit: 2,
				bgip: username,
				bgprop: 'address',
				bglimit: 1,
				leaction: 'block/block',
				letitle: `User:${ username }`,
				leprop: 'timestamp',
				lelimit: 1,
				ucuser: username,
				ucprop: '',
				uclimit: 'max'
			};
		} else {
			params = {
				list: 'blocks|logevents|usercontribs|users',
				meta: 'globaluserinfo',
				bkusers: username,
				bkprop: 'flags',
				bklimit: 1,
				leaction: 'block/block',
				letitle: `User:${ username }`,
				leprop: 'timestamp',
				lelimit: 1,
				ucuser: username,
				ucdir: 'newer',
				ucprop: 'timestamp',
				uclimit: 1,
				ususers: username,
				usprop: 'editcount|gender|groupmemberships|registration',
				guiuser: username,
				guiprop: 'groups'
			};
		}

		const data = await api.get( params );

		if ( isAnon ) {
			const editCount = data.query.usercontribs.length;

			scriptData.editCount = await renderAnonEditCount( editCount );

			scriptData.isGloballyBlocked = data.query.globalblocks.length;

			if ( scriptData.isGloballyBlocked ) {
				scriptData.globalBlockTarget = data.query.globalblocks[ 0 ].address;
			}
		} else {
			scriptData.gender = data.query.users[ 0 ].gender;

			if ( data.query.users[ 0 ].registration ) {
				scriptData.regDate =
					await formatDate( data.query.users[ 0 ].registration, true );
			} else if ( data.query.usercontribs[ 0 ] ) {
				scriptData.regDate =
					await formatDate( data.query.usercontribs[ 0 ].timestamp, true );
			} else {
				scriptData.regDate = i18n( 'regUnknown' );
			}

			scriptData.editCount = data.query.users[ 0 ].editcount.toLocaleString();

			const localGroups =
				data.query.users[ 0 ].groupmemberships.map( item => item.group );

			scriptData.localGroups = await renderGroups( localGroups );

			if ( data.query.globaluserinfo.groups ) {
				const globalGroups = data.query.globaluserinfo.groups.filter(
					item => !localGroups.includes( item )
				);

				scriptData.globalGroups = await renderGroups( globalGroups );

				scriptData.isLocked = data.query.globaluserinfo.locked === '';
			}
		}

		const blocks = data.query.blocks;

		scriptData.isBlocked = blocks.length;

		if ( scriptData.isBlocked ) {
			if ( isAnon && blocks[ 0 ].user !== username && blocks[ 1 ] ) {
				blocks.shift();
			}

			scriptData.isPartiallyBlocked = blocks[ 0 ].partial === '';

			scriptData.isRangeBlocked = isAnon && blocks[ 0 ].user !== username;

			if ( scriptData.isRangeBlocked ) {
				scriptData.rangeBlockTarget = blocks[ 0 ].user;
			}
		} else if ( data.query.logevents.length ) {
			scriptData.lastBlockDate =
				await formatDate( data.query.logevents[ 0 ].timestamp, false );
		}
	} // getUserData END

	/**
	 * Formats the edit count for anonymous users. Appends a plus sign if the count
	 * has reached the API limit (500 for regular users, 5000 for users with the
	 * apihighlimits right) to indicate there may be more edits.
	 *
	 * @function renderAnonEditCount
	 * @async
	 * @param {number} editCount - The raw edit count returned from the API.
	 * @returns {Promise<string>} The formatted edit count string with locale-specific
	 *          number formatting and optional plus sign.
	 */
	async function renderAnonEditCount( editCount ) {
		if ( editCount < 500 ) {
			return editCount.toLocaleString();
		}

		await mw.loader.using( 'mediawiki.user' );
		const rights = await mw.user.getRights();
		const maxAnonEditCount = rights.includes( 'apihighlimits' ) ? 5000 : 500;

		if ( editCount === maxAnonEditCount ) {
			return `${ editCount.toLocaleString() }+`;
		} else {
			return editCount.toLocaleString();
		}
	} // renderAnonEditCount END

	/**
	 * Converts an array of user group identifiers into their localized display names
	 * by parsing MediaWiki system messages. Uses the wiki's parse API to resolve
	 * the {{int:group-*}} messages in the user's language.
	 *
	 * @function renderGroups
	 * @async
	 * @param {string[]} groups - Array of group identifiers (e.g., 'sysop', 'bot').
	 * @returns {Promise<string>} Comma-separated list of localized group names,
	 *          or an empty string if the groups array is empty.
	 */
	async function renderGroups( groups ) {
		if ( groups.length === 0 ) {
			return '';
		}

		let sysMsgGroups = '';

		groups.forEach( ( group, index ) => {
			sysMsgGroups += `{${ '{' }int:group-${ group }}}`;

			if ( index < groups.length - 1 ) {
				sysMsgGroups += ', ';
			}
		} );

		const params = {
			action: 'parse',
			uselang: scriptData.lang,
			text: sysMsgGroups,
			prop: 'text',
			contentmodel: 'wikitext',
			disablelimitreport: true
		};

		const data = await api.get( params );

		return $( data.parse.text[ '*' ] ).find( 'p' ).text().trim();
	} // renderGroups END

	/**
	 * Formats an ISO timestamp into a human-readable date string using the wiki's
	 * localized month names. Respects the user's date format preference (MDY vs DMY)
	 * when including the day.
	 *
	 * @function formatDate
	 * @async
	 * @param {string} timestamp - An ISO 8601 timestamp string.
	 * @param {boolean} includeDay - Whether to include the day of the month in
	 *        the output. If false, only month and year are returned.
	 * @returns {Promise<string>} The formatted date string (e.g., "January 15, 2024"
	 *          or "January 2024").
	 */
	async function formatDate( timestamp, includeDay ) {
		await mw.loader.using( 'mediawiki.language.months' );

		const date = new Date( timestamp );
		const monthName = mw.language.months.names[ date.getMonth() ];
		const monthNameGen = mw.language.months.genitive[ date.getMonth() ];
		const year = date.getFullYear();

		if ( includeDay ) {
			const day = date.getDate();

			await mw.loader.using( 'user.options' );

			if ( mw.user.options.get( 'date' ) === 'mdy' ) {
				return `${ monthName } ${ day }, ${ year }`;
			} else {
				return `${ day } ${ monthNameGen } ${ year }`;
			}
		} else {
			return `${ monthName } ${ year }`;
		}
	} // formatDate END

	/**
	 * Builds and renders the popup content with all user information. Creates the
	 * HTML structure including username header, list of user properties (registration
	 * date, edit count, last edit time, groups, block status), and gender symbol
	 * if applicable. Replaces the loading placeholder with the final content.
	 *
	 * @function fillPopupContent
	 */
	function fillPopupContent() {
		const $container = $( '<aside>' ).attr( 'id', 'user-info-popup-content' );
		const $header = $( '<header>' ).attr( 'id', 'user-info-popup-header' );

		$header.append(
			$( '<bdi>' )
				.attr( 'id', 'user-info-popup-username' )
				.text( mw.util.prettifyIP( username ) )
		);

		const $ul = $( '<ul>' ).attr( 'id', 'user-info-popup-list' );

		$container.append( $header, $ul );

		if ( !isAnon ) {
			addListItem( $ul, i18n( 'joined' ), scriptData.regDate );
		}

		const editCounterUrl =
			`https://xtools.wmcloud.org/ec/` +
			`${ mw.config.get( 'wgServerName' ) }/` +
			`${ encodeURIComponent( username ) }`;

		addListItem(
			$ul,
			i18n( 'editCount' ),
			`<a target="_blank" href="${ editCounterUrl }">` +
			`${ scriptData.editCount }` +
			`</a>`
		);

		const contribsUrl = mw.util.getUrl( `Special:Contributions/${ username }` );

		let lastEditedText;

		if ( scriptData.editCount === ( 0 ).toLocaleString() ) {
			lastEditedText = i18n( 'lastEditedNever' );
		} else if ( scriptData.secsFromLastEdit === null ) {
			lastEditedText = i18n( 'lastEditedUnknown' );
		} else {
			lastEditedText = i18n( 'ago' ).replace( '$1', calcTimeFromLastEdit() );
		}

		addListItem(
			$ul,
			i18n( 'lastEdited' ),
			`<a href="${ contribsUrl }">${ lastEditedText }</a>`
		);

		if ( !isAnon ) {
			const localGroupsUrl = mw.util.getUrl( `Special:UserRights/${ username }` );

			const globalGroupsUrl =
				mw.util.getUrl( `m:Special:GlobalUserRights/${ username }` );

			let groupsHtml;

			if ( !scriptData.localGroups && !scriptData.globalGroups ) {
				groupsHtml =
					`<a href="${ localGroupsUrl }">${ i18n( 'noGroups' ) }</a>`;
			} else if ( scriptData.localGroups && !scriptData.globalGroups ) {
				groupsHtml =
					`<a href="${ localGroupsUrl }">${ scriptData.localGroups }</a>`;
			} else if ( !scriptData.localGroups && scriptData.globalGroups ) {
				groupsHtml =
					`<a href="${ globalGroupsUrl }">${ scriptData.globalGroups }</a>`;
			} else if ( scriptData.localGroups && scriptData.globalGroups ) {
				groupsHtml =
					`<a href="${ localGroupsUrl }">${ scriptData.localGroups }</a>,
					<a href="${ globalGroupsUrl }">${ scriptData.globalGroups }</a>`;
			}

			addListItem( $ul, i18n( 'groups' ), groupsHtml );
		}

		let lastBlockText;

		let blockLogUrl = mw.util.getUrl( 'Special:Log', {
			type: 'block',
			page: `User:${ username }`
		} );

		if ( scriptData.isGloballyBlocked ) {
			lastBlockText = i18n( 'globallyBlocked' );

			blockLogUrl = mw.util.getUrl( 'm:Special:Log', {
				type: 'gblblock',
				page: `User:${ scriptData.globalBlockTarget }`
			} );
		} else if ( scriptData.isLocked ) {
			lastBlockText = i18n( 'globallyLocked' );

			blockLogUrl = mw.util.getUrl( 'm:Special:Log', {
				type: 'globalauth',
				page: `User:${ username }@global`
			} );
		} else if ( scriptData.isBlocked ) {
			if ( scriptData.isRangeBlocked ) {
				if ( scriptData.isPartiallyBlocked ) {
					lastBlockText = i18n( 'rangeBlockedPartially' );
				} else {
					lastBlockText = i18n( 'rangeBlockedFully' );
				}

				blockLogUrl = mw.util.getUrl( 'Special:Log', {
					type: 'block',
					page: `User:${ scriptData.rangeBlockTarget }`
				} );
			} else {
				if ( scriptData.isPartiallyBlocked ) {
					lastBlockText = i18n( 'partiallyBlocked' );
				} else {
					lastBlockText = i18n( 'fullyBlocked' );
				}
			}
		} else {
			lastBlockText = scriptData.lastBlockDate || i18n( 'neverBlocked' );
		}

		addListItem(
			$ul,
			i18n( 'lastBlocked' ),
			`<a href="${ blockLogUrl }">${ lastBlockText }</a>`
		);

		if ( !isAnon && scriptData.gender !== 'unknown' ) {
			const images = {
				female: {
					alt: i18n( 'femaleSymbolAlt' ),
					path: 'https://upload.wikimedia.org/wikipedia/commons/archive/1/1d/20240712201036!Venus_symbol_(light_pink).svg'
				},
				male: {
					alt: i18n( 'maleSymbolAlt' ),
					path: 'https://upload.wikimedia.org/wikipedia/commons/archive/b/b4/20240624040032!Mars_symbol_(bold_light_blue).svg'
				}
			};

			$( '<img>' ).attr( {
				alt: images[ scriptData.gender ].alt,
				id: 'user-info-popup-gender-symbol',
				src: images[ scriptData.gender ].path,
				width: '16.6',
				height: '16.6'
			} ).appendTo( $header );
		}

		scriptData.$elements.popupPlaceholder.replaceWith( $container );
	} // fillPopupContent END

	/**
	 * Creates and appends a list item to the popup's unordered list. Each item
	 * consists of a property label and its corresponding value.
	 *
	 * @function addListItem
	 * @param {jQuery} $ul - The jQuery-wrapped unordered list element to append to.
	 * @param {string} property - The label text for the property (e.g., "Joined:").
	 * @param {string} value - The HTML content for the value, which may contain links.
	 */
	function addListItem( $ul, property, value ) {
		const $li = $( '<li>' );

		const $property = $( '<span>' )
			.addClass( 'user-info-popup-property' )
			.text( property );

		const $value = $( '<span>' )
			.addClass( 'user-info-popup-value' )
			.html( value );

		$li.append( $property, ' ', $value ).appendTo( $ul );
	} // addListItem END

	/**
	 * Converts the seconds since last edit into a human-readable relative time string.
	 * Selects the most appropriate time unit (seconds, minutes, hours, days, weeks,
	 * months, or years) and applies proper pluralization based on the user's language.
	 *
	 * @function calcTimeFromLastEdit
	 * @returns {string} A localized relative time string (e.g., "5 minutes", "2 days",
	 *          "1 year").
	 */
	function calcTimeFromLastEdit() {
		const secs = scriptData.secsFromLastEdit;
		const days = secs / 60 / 60 / 24;

		if ( secs < 60 ) {
			let fullSecs = Math.floor( secs );

			if ( fullSecs < 1 ) {
				fullSecs = 1;
			}

			const secsArrLength = i18n( 'seconds' ).length;

			if ( fullSecs < secsArrLength ) {
				return i18n( 'seconds' )[ fullSecs - 1 ];
			} else {
				return i18n( 'seconds' )[ secsArrLength - 1 ].replace( '$1', fullSecs );
			}
		} else if ( secs < 60 * 60 ) {
			const fullMins = Math.floor( secs / 60 );
			const minsArrLength = i18n( 'minutes' ).length;

			if ( fullMins < minsArrLength ) {
				return i18n( 'minutes' )[ fullMins - 1 ];
			} else {
				return i18n( 'minutes' )[ minsArrLength - 1 ].replace( '$1', fullMins );
			}
		} else if ( secs < 60 * 60 * 24 ) {
			const fullHours = Math.floor( secs / 60 / 60 );
			const hoursArrLength = i18n( 'hours' ).length;

			if ( fullHours < hoursArrLength ) {
				return i18n( 'hours' )[ fullHours - 1 ];
			} else {
				return i18n( 'hours' )[ hoursArrLength - 1 ].replace( '$1', fullHours );
			}
		} else if ( days < 7 ) {
			const fullDays = Math.floor( days );
			const daysArrLength = i18n( 'days' ).length;

			if ( fullDays < daysArrLength ) {
				return i18n( 'days' )[ fullDays - 1 ];
			} else {
				return i18n( 'days' )[ daysArrLength - 1 ].replace( '$1', fullDays );
			}
		} else if ( days < 30 ) {
			const fullWeeks = Math.floor( days / 7 );
			const weeksArrLength = i18n( 'weeks' ).length;

			if ( fullWeeks < weeksArrLength ) {
				return i18n( 'weeks' )[ fullWeeks - 1 ];
			} else {
				return i18n( 'weeks' )[ weeksArrLength - 1 ].replace( '$1', fullWeeks );
			}
		} else if ( days < 365 ) {
			let fullMonths = Math.floor( days / 30 );

			if ( fullMonths === 12 ) {
				fullMonths = 11;
			}

			const monthsArrLength = i18n( 'months' ).length;

			if ( fullMonths < monthsArrLength ) {
				return i18n( 'months' )[ fullMonths - 1 ];
			} else {
				return i18n( 'months' )[ monthsArrLength - 1 ].replace( '$1', fullMonths );
			}
		} else {
			const fullYears = Math.floor( days / 365 );
			const yearsArrLength = i18n( 'years' ).length;

			if ( fullYears < yearsArrLength ) {
				return i18n( 'years' )[ fullYears - 1 ];
			} else {
				return i18n( 'years' )[ yearsArrLength - 1 ].replace( '$1', fullYears );
			}
		}
	} // calcTimeFromLastEdit END

} )();