User:Guycn2/UserInfoPopup.js

/*

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



( 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;	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;	}	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 = {		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;	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 שנים' ] }		};		if (			messages[ scriptData.lang ] &&			messages[ scriptData.lang ][ key ]		) { return messages[ scriptData.lang ][ key ]; } else { return messages.en[ key ]; }	}	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; }	function createInfoIcon { const $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.$indicator = $( ' ' ) .addClass( 'mw-indicator' ) .attr( { id: 'mw-indicator-user-info-popup-indicator', tabindex: '0' } ) .append( $img ); }	function addInfoIconToPage { const $throbberImg = $( ' ' ).attr( {			alt: i18n( 'fetchingData' ),			id: 'user-info-popup-throbber',			src: 'https://upload.wikimedia.org/wikipedia/commons/f/f8/Ajax-loader(2).gif'		} ); const $placeholderText = $( ' ' ) .attr( 'id', 'user-info-popup-placeholder-text' ) .text( i18n( 'fetchingData' ) ); scriptData.$popupPlaceholder = $( ' ' ) .attr( 'id', 'user-info-popup-placeholder' ) .append( $throbberImg, $placeholderText ); scriptData.popup = new OO.ui.PopupWidget( {			$content: scriptData.$popupPlaceholder,			align: 'backwards',			autoFlip: false,			id: 'user-info-popup-popup',			hideWhenOutOfView: false,			padded: true,			position: 'below',			width: 225		} ); scriptData.$indicator.append( scriptData.popup.$element ); if (			scriptData.skin === 'vector-2022' &&			$( '.vector-page-toolbar-container:has( #ca-nstab-user )' ).length		) { scriptData.$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.$indicator .css( 'float', $( 'body.rtl' ).length ? 'left' : 'right' ) .appendTo( '.header-container' ); } else { $indicatorsContainer.prepend( scriptData.$indicator ); }		}	}	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.$indicator.on( 'mouseenter focusin keydown', e => {			if ( e.type === 'keydown' ) {				if ( ![ 'Enter', ' ' ].includes( e.key ) ) {					return;				}				if ( e.key === ' ' ) {					e.preventDefault;				}			}			clearTimeout( scriptData.mouseLeaveTimeout );			scriptData.mouseEnterTimeout = setTimeout( openPopup, 200 );		} ); scriptData.$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', e => {			if ( e.key === 'Escape' ) {				closePopup;			}		} ); $( document ).on( 'click', closePopup ); $( '.oo-ui-fieldsetLayout-header, .ext-discussiontools-init-section-bar' ) .on( 'click', closePopup ); scriptData.$indicator.on( 'click', e => e.stopPropagation ); }	function adaptPopupPosition { const innerBody = document.querySelector( '.mw-page-container' ); const innerBodyRect = innerBody.getBoundingClientRect; const indicator = scriptData.$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; }`		); }	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; }		}	}	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;				}			} ); }	}	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 ); }	}	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; }	}	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; }	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 }`; }	}	function fillPopupContent { const $container = $( ' ' ).attr( 'id', 'user-info-popup-content' ); const $header = $( ' ' ).attr( 'id', 'user-info-popup-header' ); $header.append(			$( ' ' )			.attr( 'id', 'user-info-popup-username' )			.text( mw.util.prettifyIP( username ) )		); const $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' ),			`${ scriptData.editCount }`		); 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' ),			`${ lastEditedText }`		); 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 = `${ i18n( 'noGroups' ) }`; }			if ( scriptData.localGroups && !scriptData.globalGroups ) { groupsHtml = `${ scriptData.localGroups }`; }			if ( !scriptData.localGroups && scriptData.globalGroups ) { groupsHtml = `${ scriptData.globalGroups }`; }			if ( scriptData.localGroups && scriptData.globalGroups ) { groupsHtml = `${ scriptData.localGroups }, ${ scriptData.globalGroups }`; }			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' ),			`${ 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' }			};			$( ' ' ).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.$popupPlaceholder.replaceWith( $container ); }	function addListItem( $ul, property, value ) { const $li = $( '<li>' ); const $property = $( ' ' ) .addClass( 'user-info-popup-property' ) .text( property ); const $value = $( ' ' ) .addClass( 'user-info-popup-value' ) .html( value ); $li.append( $property, ' ', $value ).appendTo( $ul ); }	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 ); }		}	} } );