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:

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

) {

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

if ( $navBtns.length ) {

scriptData.$indicator.insertAfter( $navBtns );

} else {

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 = $( '