Jack Phoenix has uploaded a new change for review. https://gerrit.wikimedia.org/r/184858
Change subject: [WIP] Allow setting the visibility of profile fields ...................................................................... [WIP] Allow setting the visibility of profile fields Users can set the visibility of profile fields (such as "Movies", "Snacks", "Websites", "Hometown", etc.) via Special:UpdateProfile. Allowed values are: * public -- essentially the current default; if a field has a value, it's shown to everyone * hidden -- shown only to the profile owner * friends -- self-explanatory * friends of friends (fof/foaf) -- again pretty self-explanatory Most of the code was written by Vedmaka <[email protected]>, I just cleaned it up a tad bit. This is a work in progress because despite that the code runs without fatals (well, if I managed to copy over all the necessary files and changes from my other branch), I'm not happy with the schema of the user_fields_privacy database table since its three fields do not have a prefix of any kind, which probably should be ufp_ in this case. Also, should the UserSecurityClass.php file have its own directory or should the file live in the UserProfile directory? Thoughts, suggestions and feedback are more than welcome! Change-Id: I52672f9fffa92ea48e110f9ebb419dd0fbdc1f6f --- M SocialProfile.php M SocialProfileHooks.php A UserProfile/ApiUserProfilePrivacy.php M UserProfile/SpecialUpdateProfile.php M UserProfile/UpdateProfile.js M UserProfile/UserProfile.css M UserProfile/UserProfile.php M UserProfile/UserProfilePage.php M UserProfile/i18n/en.json M UserProfile/i18n/fi.json M UserProfile/i18n/fr.json M UserProfile/i18n/ru.json A UserProfile/user_fields_privacy.postgres.sql A UserProfile/user_fields_privacy.sql A UserSecurity/UserSecurityClass.php A images/eye-bw.png A images/eye.png A images/slide-arrow.png A images/star.png 19 files changed, 681 insertions(+), 57 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/SocialProfile refs/changes/58/184858/1 diff --git a/SocialProfile.php b/SocialProfile.php index afec7d9..29a4327 100644 --- a/SocialProfile.php +++ b/SocialProfile.php @@ -63,6 +63,11 @@ $wgAutoloadClasses['TopUsersPoints'] = __DIR__ . '/UserStats/TopUsers.php'; $wgAutoloadClasses['wAvatar'] = __DIR__ . '/UserProfile/AvatarClass.php'; $wgAutoloadClasses['AvatarParserFunction'] = __DIR__ . '/UserProfile/AvatarParserFunction.php'; +$wgAutoloadClasses['SPUserSecurity'] = __DIR__ . '/UserSecurity/UserSecurityClass.php'; + +// API module +$wgAutoloadClasses['ApiUserProfilePrivacy'] = __DIR__ . '/UserProfile/ApiUserProfilePrivacy.php'; +$wgAPIModules['smpuserprivacy'] = 'ApiUserProfilePrivacy'; // New special pages $wgSpecialPages['AddRelationship'] = 'SpecialAddRelationship'; @@ -108,7 +113,7 @@ 'path' => __FILE__, 'name' => 'SocialProfile', 'author' => array( 'Aaron Wright', 'David Pean', 'Jack Phoenix' ), - 'version' => '1.7.1', + 'version' => '1.8', 'url' => 'https://www.mediawiki.org/wiki/Extension:SocialProfile', 'descriptionmsg' => 'socialprofile-desc', ); diff --git a/SocialProfileHooks.php b/SocialProfileHooks.php index 2177bca..83e7e3a 100644 --- a/SocialProfileHooks.php +++ b/SocialProfileHooks.php @@ -32,7 +32,7 @@ * @return Boolean */ public static function onLoadExtensionSchemaUpdates( $updater ) { - $dir = dirname( __FILE__ ); + $dir = __DIR__; $dbExt = ''; if ( $updater->getDB()->getType() == 'postgres' ) { @@ -40,6 +40,7 @@ } $updater->addExtensionUpdate( array( 'addTable', 'user_board', "$dir/UserBoard/user_board$dbExt.sql", true ) ); + $updater->addExtensionUpdate( array( 'addTable', 'user_fields_privacy', "$dir/UserProfile/user_fields_privacy$dbExt.sql", true ) ); $updater->addExtensionUpdate( array( 'addTable', 'user_profile', "$dir/UserProfile/user_profile$dbExt.sql", true ) ); $updater->addExtensionUpdate( array( 'addTable', 'user_stats', "$dir/UserStats/user_stats$dbExt.sql", true ) ); $updater->addExtensionUpdate( array( 'addTable', 'user_relationship', "$dir/UserRelationship/user_relationship$dbExt.sql", true ) ); diff --git a/UserProfile/ApiUserProfilePrivacy.php b/UserProfile/ApiUserProfilePrivacy.php new file mode 100644 index 0000000..c02330d --- /dev/null +++ b/UserProfile/ApiUserProfilePrivacy.php @@ -0,0 +1,101 @@ +<?php +/** + * API module for setting the visibility ("privacy") of a profile field + * + * @file + * @ingroup Extensions + * @author Vedmaka <[email protected]> + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later + */ + +class ApiUserProfilePrivacy extends ApiBase { + + /** + * Constructor + */ + public function __construct( $query, $moduleName ) { + parent::__construct( $query, $moduleName ); + } + + /** + * Main entry point + */ + public function execute() { + $params = $this->extractRequestParams(); + $method = $params['method']; + $fieldKey = $params['field_key']; + $privacy = $params['privacy']; + $tuid = $params['tuid']; + + // Search content: for example let's search + if ( strlen( $fieldKey ) == 0 ) { + $this->dieUsage( 'No data provided', 'field_key' ); + } + + if ( !$tuid ) { + $tuid = $this->getUser()->getId(); + } + $data = array(); + + switch ( $method ) { + case 'get': + $data['privacy'] = SPUserSecurity::getPrivacy( $tuid, $fieldKey ); + break; + + case 'set': + if ( !$privacy || !in_array( $privacy, array( 'public', 'hidden', 'friends', 'foaf' ) ) ) { + $this->dieUsage( 'The supplied argument for the "privacy" parameter is invalid (no such parameter/missing parameter)', 'privacy' ); + } + + SPUserSecurity::setPrivacy( $tuid, $fieldKey, $privacy ); + + $data['replace'] = SPUserSecurity::renderEye( $fieldKey, $tuid ); + + break; + } + + // Output + $result = $this->getResult(); + + $result->addValue( null, $this->getModuleName(), $data ); + } + + /** + * @return array + */ + protected function getAllowedParams() { + return array( + 'method' => array( + ApiBase::PARAM_TYPE => 'string', + ), + 'field_key' => array( + ApiBase::PARAM_TYPE => 'string', + ), + 'privacy' => array( + ApiBase::PARAM_TYPE => 'string', + ), + 'tuid' => array( + ApiBase::PARAM_TYPE => 'integer', + ) + ); + } + + /** + * @return array Human-readable descriptions for all parameters that this module accepts + */ + protected function getParamDescription() { + return array( + 'method' => 'Action (either "get" or "set")', + 'field_key' => 'Target field key, such as up_movies for the "Movies" field', + 'privacy' => 'New privacy value (one of the following: public, hidden, friends, foaf)', + 'tuid' => 'Target user (ID)' + ); + } + + /** + * @return string Human-readable description for this API module, shown on api.php + */ + protected function getDescription() { + return 'API module for setting the visibility ("privacy") of a profile field'; + } +} \ No newline at end of file diff --git a/UserProfile/SpecialUpdateProfile.php b/UserProfile/SpecialUpdateProfile.php index 3ef038c..743daa1 100644 --- a/UserProfile/SpecialUpdateProfile.php +++ b/UserProfile/SpecialUpdateProfile.php @@ -508,7 +508,7 @@ <p class="profile-update-title">' . $this->msg( 'user-profile-personal-info' )->plain() . '</p> <p class="profile-update-unit-left">' . $this->msg( 'user-profile-personal-name' )->plain() . '</p> <p class="profile-update-unit"><input type="text" size="25" name="real_name" id="real_name" value="' . $real_name . '"/></p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_real_name' ) . '</div> <p class="profile-update-unit-left">' . $this->msg( 'user-profile-personal-email' )->plain() . '</p> <p class="profile-update-unit"><input type="text" size="25" name="email" id="email" value="' . $email . '"/>'; if ( !$user->mEmailAuthenticated ) { @@ -516,7 +516,7 @@ $form .= " <a href=\"{$confirm->getFullURL()}\">" . $this->msg( 'user-profile-personal-confirmemail' )->plain() . '</a>'; } $form .= '</p> - <div class="cleared"></div>'; + <div class="cleared">' . $this->renderEye( 'up_email' ) . '</div>'; if ( !$user->mEmailAuthenticated ) { $form .= '<p class="profile-update-unit-left"></p> <p class="profile-update-unit-small">' . @@ -531,7 +531,7 @@ <p class="profile-update-title">' . $this->msg( 'user-profile-personal-location' )->plain() . '</p> <p class="profile-update-unit-left">' . $this->msg( 'user-profile-personal-city' )->plain() . '</p> <p class="profile-update-unit"><input type="text" size="25" name="location_city" id="location_city" value="' . ( isset( $location_city ) ? $location_city : '' ) . '" /></p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_location_city' ) . '</div> <p class="profile-update-unit-left" id="location_state_label">' . $this->msg( 'user-profile-personal-country' )->plain() . '</p>'; $form .= '<p class="profile-update-unit">'; $form .= '<span id="location_state_form">'; @@ -548,7 +548,7 @@ $form .= '</select>'; $form .= '</p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_location_country' ) . '</div> </div> <div class="cleared"></div>'; @@ -556,7 +556,7 @@ <p class="profile-update-title">' . $this->msg( 'user-profile-personal-hometown' )->plain() . '</p> <p class="profile-update-unit-left">' . $this->msg( 'user-profile-personal-city' )->plain() . '</p> <p class="profile-update-unit"><input type="text" size="25" name="hometown_city" id="hometown_city" value="' . ( isset( $hometown_city ) ? $hometown_city : '' ) . '" /></p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_hometown_city' ) . '</div> <p class="profile-update-unit-left" id="hometown_state_label">' . $this->msg( 'user-profile-personal-country' )->plain() . '</p> <p class="profile-update-unit">'; $form .= '<span id="hometown_state_form">'; @@ -573,7 +573,7 @@ $form .= '</select>'; $form .= '</p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_hometown_country' ) . '</div> </div> <div class="cleared"></div>'; @@ -586,7 +586,7 @@ ( $showYOB ? ' class="long-birthday"' : null ) . ' size="25" name="birthday" id="birthday" value="' . ( isset( $birthday ) ? $birthday : '' ) . '" /></p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_birthday' ) . '</div> </div><div class="cleared"></div>'; $form .= '<div class="profile-update" id="profile-update-personal-aboutme"> @@ -595,7 +595,7 @@ <p class="profile-update-unit"> <textarea name="about" id="about" rows="3" cols="75">' . ( isset( $about ) ? $about : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_about' ) . '</div> </div> <div class="cleared"></div> @@ -605,7 +605,7 @@ <p class="profile-update-unit"> <textarea name="occupation" id="occupation" rows="2" cols="75">' . ( isset( $occupation ) ? $occupation : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_occupation' ) . '</div> </div> <div class="cleared"></div> @@ -615,7 +615,7 @@ <p class="profile-update-unit"> <textarea name="schools" id="schools" rows="2" cols="75">' . ( isset( $schools ) ? $schools : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_schools' ) . '</div> </div> <div class="cleared"></div> @@ -625,7 +625,7 @@ <p class="profile-update-unit"> <textarea name="places" id="places" rows="3" cols="75">' . ( isset( $places ) ? $places : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_places_lived' ) . '</div> </div> <div class="cleared"></div> @@ -635,7 +635,7 @@ <p class="profile-update-unit"> <textarea name="websites" id="websites" rows="2" cols="75">' . ( isset( $websites ) ? $websites : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_websites' ) . '</div> </div> <div class="cleared"></div>'; @@ -684,39 +684,39 @@ $form = UserProfile::getEditProfileNav( $this->msg( 'user-profile-section-interests' )->plain() ); $form .= '<form action="" method="post" enctype="multipart/form-data" name="profile"> - <div class="profile-info clearfix"> + <div class="profile-info profile-info-other-info clearfix"> <div class="profile-update"> <p class="profile-update-title">' . $this->msg( 'user-profile-interests-entertainment' )->plain() . '</p> <p class="profile-update-unit-left">' . $this->msg( 'user-profile-interests-movies' )->plain() . '</p> <p class="profile-update-unit"> <textarea name="movies" id="movies" rows="3" cols="75">' . ( isset( $movies ) ? $movies : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_movies' ) . '</div> <p class="profile-update-unit-left">' . $this->msg( 'user-profile-interests-tv' )->plain() . '</p> <p class="profile-update-unit"> <textarea name="tv" id="tv" rows="3" cols="75">' . ( isset( $tv ) ? $tv : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_tv' ) . '</div> <p class="profile-update-unit-left">' . $this->msg( 'user-profile-interests-music' )->plain() . '</p> <p class="profile-update-unit"> <textarea name="music" id="music" rows="3" cols="75">' . ( isset( $music ) ? $music : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_music' ) . '</div> <p class="profile-update-unit-left">' . $this->msg( 'user-profile-interests-books' )->plain() . '</p> <p class="profile-update-unit"> <textarea name="books" id="books" rows="3" cols="75">' . ( isset( $books ) ? $books : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_books' ) . '</div> <p class="profile-update-unit-left">' . $this->msg( 'user-profile-interests-magazines' )->plain() . '</p> <p class="profile-update-unit"> <textarea name="magazines" id="magazines" rows="3" cols="75">' . ( isset( $magazines ) ? $magazines : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_magazines' ) . '</div> <p class="profile-update-unit-left">' . $this->msg( 'user-profile-interests-videogames' )->plain() . '</p> <p class="profile-update-unit"> <textarea name="videogames" id="videogames" rows="3" cols="75">' . ( isset( $videogames ) ? $videogames : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_video_games' ) . '</div> </div> <div class="profile-info clearfix"> <p class="profile-update-title">' . $this->msg( 'user-profile-interests-eats' )->plain() . '</p> @@ -724,12 +724,12 @@ <p class="profile-update-unit"> <textarea name="snacks" id="snacks" rows="3" cols="75">' . ( isset( $snacks ) ? $snacks : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_snacks' ) . '</div> <p class="profile-update-unit-left">' . $this->msg( 'user-profile-interests-drinks' )->plain() . '</p> <p class="profile-update-unit"> <textarea name="drinks" id="drinks" rows="3" cols="75">' . ( isset( $drinks ) ? $drinks : '' ) . '</textarea> </p> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_drinks' ) . '</div> </div> <input type="button" class="site-button" value="' . $this->msg( 'user-profile-update-button' )->plain() . '" size="20" onclick="document.profile.submit()" /> </div> @@ -835,7 +835,7 @@ $form = '<h1>' . $this->msg( 'user-profile-tidbits-title' ) . '</h1>'; $form .= UserProfile::getEditProfileNav( $this->msg( 'user-profile-section-custom' )->plain() ); $form .= '<form action="" method="post" enctype="multipart/form-data" name="profile"> - <div class="profile-info clearfix"> + <div class="profile-info profile-info-custom-info clearfix"> <div class="profile-update"> <p class="profile-update-title">' . $this->msg( 'user-profile-tidbits-title' )->inContentLanguage()->parse() . '</p> <div id="profile-update-custom1"> @@ -844,28 +844,28 @@ <textarea name="custom1" id="fav_moment" rows="3" cols="75">' . ( isset( $custom1 ) ? $custom1 : '' ) . '</textarea> </p> </div> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_custom_1' ) . '</div> <div id="profile-update-custom2"> <p class="profile-update-unit-left">' . $this->msg( 'custom-info-field2' )->inContentLanguage()->parse() . '</p> <p class="profile-update-unit"> <textarea name="custom2" id="least_moment" rows="3" cols="75">' . ( isset( $custom2 ) ? $custom2 : '' ) . '</textarea> </p> </div> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_custom_2' ) . '</div> <div id="profile-update-custom3"> <p class="profile-update-unit-left">' . $this->msg( 'custom-info-field3' )->inContentLanguage()->parse() . '</p> <p class="profile-update-unit"> <textarea name="custom3" id="fav_athlete" rows="3" cols="75">' . ( isset( $custom3 ) ? $custom3 : '' ) . '</textarea> </p> </div> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_custom_3' ) . '</div> <div id="profile-update-custom4"> <p class="profile-update-unit-left">' . $this->msg( 'custom-info-field4' )->inContentLanguage()->parse() . '</p> <p class="profile-update-unit"> <textarea name="custom4" id="least_fav_athlete" rows="3" cols="75">' . ( isset( $custom4 ) ? $custom4 : '' ) . '</textarea> </p> </div> - <div class="cleared"></div> + <div class="cleared">' . $this->renderEye( 'up_custom_4' ) . '</div> </div> <input type="button" class="site-button" value="' . $this->msg( 'user-profile-update-button' )->plain() . '" size="20" onclick="document.profile.submit()" /> </div> @@ -873,4 +873,14 @@ return $form; } + + /** + * Renders fields privacy button by field code + * + * @param string $field_code Internal field code, such as up_movies for the "Movies" field + * @param int $uid User ID + */ + function renderEye( $field_code, $uid = null ) { + return SPUserSecurity::renderEye( $field_code, $uid ); + } } diff --git a/UserProfile/UpdateProfile.js b/UserProfile/UpdateProfile.js index 6265863..8769bd1 100644 --- a/UserProfile/UpdateProfile.js +++ b/UserProfile/UpdateProfile.js @@ -50,4 +50,72 @@ dateFormat: jQuery( '#birthday' ).hasClass( 'long-birthday' ) ? 'mm/dd/yy' : 'mm/dd' }); }); -}); \ No newline at end of file +}); + +$( function() { + $( '.eye-container' ).on( { + 'mouseenter': function() { + if ( $( this ).css( 'position' ) != 'absolute' ) { + var offset = $( this ).offset(); + + $( this ).attr( 'link', $( this ).parent() ); + + $( 'body' ).append( $( this ) ); + + $( this ).css( { + position: 'absolute', + top: offset.top + 'px', + left: offset.left + 'px' + } ); + } + + $( this ).css( {zIndex: 1000} ); + + $( this ).animate( {height: 100}, 100 ); + }, + 'mouseleave': function() { + $( this ).animate( {height: 20}, 100 ); + $( this ).css( {zIndex: 10} ); + } + } ); + + $( '.eye-container > .menu > .item' ).on( 'click', function() { + $( this ).parent().parent().css( {height: 20} ); + + var field_key = $( this ).parent().parent().attr( 'fieldkey' ); + var priv = $( this ).attr( 'action' ); + var this_element = $( this ).parent().parent(); + + $( this_element ).css( { + opacity: 0.3, + backgroundImage: 'none', + backgroundColor: 'lightgray' + } ); + + $( this_element ).find( 'div.title' ).html( '...' ); + + $.ajax( { + type: 'GET', + url: mw.util.wikiScript( 'api' ), + data: { + action: 'smpuserprivacy', + format: 'json', + method: 'set', + 'field_key': field_key, + privacy: encodeURIComponent( priv ) + } + } ).done( function( data ) { + var offset = $( this_element ).offset(); + $( this_element ).remove(); + var newEl = $( data.smpuserprivacy.replace ); + + $( newEl ).css( { + position: 'absolute', + top: offset.top + 'px', + left: offset.left + 'px' + } ); + + $( 'body' ).append( $( newEl ) ); + } ); + } ); +} ); \ No newline at end of file diff --git a/UserProfile/UserProfile.css b/UserProfile/UserProfile.css index b675565..4591c36 100644 --- a/UserProfile/UserProfile.css +++ b/UserProfile/UserProfile.css @@ -659,4 +659,66 @@ /* The text "Message type" on the left side of the message type selector on profile page */ .profile-board-message-type { color: #797979; -} \ No newline at end of file +} + +/* Privacy */ +.mw-special-UpdateProfile div#mw-content-text .cleared { + height: 20px; +} + +.eye-container { + width: 130px; + height: 20px; + /* @embed */ + background: #a3c2d5 url(../images/eye.png) 0 0 no-repeat; + border-radius: 4px; + opacity: 0.5; + overflow: hidden; + position: relative; + top: -25px; + cursor: pointer; + box-shadow: 2px 2px 3px -1px darkgray; +} + +.eye-container:hover { + opacity: 1.0; +} + +.eye-container .title { + font-size: 12px; + font-weight: bold; + color: white; + padding: 0px 0 0 25px; +} + +.eye-container .menu { + padding: 10px 0 10px 0; +} + +.eye-container .menu .item { + height: 18px; + font-size: 12px; + font-weight: bold; + color: white; + margin: 4px 0 4px 0; + padding: 0 0 0 30px; + /* @embed */ + background: #a3c2d5 url(../images/eye-bw.png) 5px 1px no-repeat; +} + +.eye-container .menu .item:hover { + background-color: white; + box-shadow: 1px 1px 1px -1px black; + color: #a3c2d5; +} + +/* +.profile-info-other-info .eye-container,.profile-info-custom-info .eye-container, +#profile-update-personal-aboutme .eye-container, +#profile-update-personal-work .eye-container, +#profile-update-personal-education .eye-container, +#profile-update-personal-places .eye-container, +#profile-update-personal-web .eye-container { + top: -35px !important; +} +*/ \ No newline at end of file diff --git a/UserProfile/UserProfile.php b/UserProfile/UserProfile.php index 153fb33..95ffbf6 100644 --- a/UserProfile/UserProfile.php +++ b/UserProfile/UserProfile.php @@ -84,6 +84,7 @@ // Modules for Special:EditProfile/Special:UpdateProfile $wgResourceModules['ext.userProfile.updateProfile'] = array( 'scripts' => 'UpdateProfile.js', + 'dependencies' => 'mediawiki.util', 'localBasePath' => __DIR__, 'remoteExtPath' => 'SocialProfile/UserProfile', 'position' => 'top' diff --git a/UserProfile/UserProfilePage.php b/UserProfile/UserProfilePage.php index 143db02..93a4fd7 100644 --- a/UserProfile/UserProfilePage.php +++ b/UserProfile/UserProfilePage.php @@ -43,6 +43,11 @@ public $profile_data; /** + * @var Array: array of profile fields visible to the user viewing the profile + */ + public $profile_visible_fields; + + /** * Constructor */ function __construct( $title ) { @@ -57,6 +62,7 @@ $profile = new UserProfile( $this->user_name ); $this->profile_data = $profile->getProfile(); + $this->profile_visible_fields = SPUserSecurity::getVisibleFields( $this->user_id, $wgUser->getId() ); } /** @@ -564,13 +570,33 @@ $location = $profile_data['location_city'] . ', ' . $profile_data['location_state'] . ', ' . $profile_data['location_country']; + // Privacy + $location = ''; + if ( in_array( 'up_location_city', $this->profile_visible_fields ) ) { + $location .= $profile_data['location_city'] . ', '; + } + $location .= $profile_data['location_state']; + if ( in_array( 'up_location_country', $this->profile_visible_fields ) ) { + $location .= ', ' . $profile_data['location_country'] . ', '; + } } elseif ( $profile_data['location_city'] && !$profile_data['location_state'] ) { // city, but no state - $location = $profile_data['location_city'] . ', ' . $profile_data['location_country']; + $location = ''; + if ( in_array( 'up_location_city', $this->profile_visible_fields ) ) { + $location .= $profile_data['location_city'] .', '; + } + if ( in_array( 'up_location_country', $this->profile_visible_fields ) ) { + $location .= $profile_data['location_country']; + } } elseif ( $profile_data['location_state'] && !$profile_data['location_city'] ) { // state, but no city - $location = $profile_data['location_state'] . ', ' . $profile_data['location_country']; + $location = $profile_data['location_state']; + if ( in_array( 'up_location_country', $this->profile_visible_fields ) ) { + $location.= ', ' . $profile_data['location_country']; + } } else { $location = ''; - $location .= $profile_data['location_country']; + if ( in_array( 'up_location_country', $this->profile_visible_fields ) ) { + $location .= $profile_data['location_country']; + } } } @@ -585,13 +611,31 @@ $hometown = $profile_data['hometown_city'] . ', ' . $profile_data['hometown_state'] . ', ' . $profile_data['hometown_country']; + $hometown = ''; + if ( in_array( 'up_hometown_city', $this->profile_visible_fields ) ) { + $hometown .= $profile_data['hometown_city'] . ', ' . $profile_data['hometown_state']; + } + if ( in_array( 'up_hometown_country', $this->profile_visible_fields ) ) { + $hometown .= ', ' . $profile_data['hometown_country']; + } } elseif ( $profile_data['hometown_city'] && !$profile_data['hometown_state'] ) { // city, but no state - $hometown = $profile_data['hometown_city'] . ', ' . $profile_data['hometown_country']; + $hometown = ''; + if ( in_array( 'up_hometown_city', $this->profile_visible_fields ) ) { + $hometown .= $profile_data['hometown_city'] .', '; + } + if ( in_array( 'up_hometown_country', $this->profile_visible_fields ) ) { + $hometown .= $profile_data['hometown_country']; + } } elseif ( $profile_data['hometown_state'] && !$profile_data['hometown_city'] ) { // state, but no city - $hometown = $profile_data['hometown_state'] . ', ' . $profile_data['hometown_country']; + $hometown = $profile_data['hometown_state']; + if ( in_array( 'up_hometown_country', $this->profile_visible_fields ) ) { + $hometown .= ', ' . $profile_data['hometown_country']; + } } else { $hometown = ''; - $hometown .= $profile_data['hometown_country']; + if ( in_array( 'up_hometown_country', $this->profile_visible_fields ) ) { + $hometown .= $profile_data['hometown_country']; + } } } @@ -604,6 +648,39 @@ $profile_data['websites'] . $profile_data['places_lived'] . $profile_data['schools'] . $profile_data['about']; $edit_info_link = SpecialPage::getTitleFor( 'UpdateProfile' ); + + // Privacy fields holy shit! + $personal_output = ''; + if ( in_array( 'up_real_name', $this->profile_visible_fields ) ) { + $personal_output .= $this->getProfileSection( wfMessage( 'user-personal-info-real-name' )->escaped(), $profile_data['real_name'], false ); + } + + $personal_output .= $this->getProfileSection( wfMessage( 'user-personal-info-location' )->escaped(), $location, false ); + $personal_output .= $this->getProfileSection( wfMessage( 'user-personal-info-hometown' )->escaped(), $hometown, false ); + + if ( in_array( 'up_birthday', $this->profile_visible_fields ) ) { + $personal_output .= $this->getProfileSection( wfMessage( 'user-personal-info-birthday' )->escaped(), $profile_data['birthday'], false ); + } + + if ( in_array( 'up_occupation', $this->profile_visible_fields ) ) { + $personal_output .= $this->getProfileSection( wfMessage( 'user-personal-info-occupation' )->escaped(), $profile_data['occupation'], false ); + } + + if ( in_array( 'up_websites', $this->profile_visible_fields ) ) { + $personal_output .= $this->getProfileSection( wfMessage( 'user-personal-info-websites' )->escaped(), $profile_data['websites'], false ); + } + + if ( in_array( 'up_places_lived', $this->profile_visible_fields ) ) { + $personal_output .= $this->getProfileSection( wfMessage( 'user-personal-info-places-lived' )->escaped(), $profile_data['places_lived'], false ); + } + + if ( in_array( 'up_schools', $this->profile_visible_fields ) ) { + $personal_output .= $this->getProfileSection( wfMessage( 'user-personal-info-schools' )->escaped(), $profile_data['schools'], false ); + } + + if ( in_array( 'up_about', $this->profile_visible_fields ) ) { + $personal_output .= $this->getProfileSection( wfMessage( 'user-personal-info-about-me' )->escaped(), $profile_data['about'], false ); + } $output = ''; if ( $joined_data ) { @@ -623,15 +700,7 @@ </div> <div class="cleared"></div> <div class="profile-info-container">' . - $this->getProfileSection( wfMessage( 'user-personal-info-real-name' )->escaped(), $profile_data['real_name'], false ) . - $this->getProfileSection( wfMessage( 'user-personal-info-location' )->escaped(), $location, false ) . - $this->getProfileSection( wfMessage( 'user-personal-info-hometown' )->escaped(), $hometown, false ) . - $this->getProfileSection( wfMessage( 'user-personal-info-birthday' )->escaped(), $profile_data['birthday'], false ) . - $this->getProfileSection( wfMessage( 'user-personal-info-occupation' )->escaped(), $profile_data['occupation'], false ) . - $this->getProfileSection( wfMessage( 'user-personal-info-websites' )->escaped(), $profile_data['websites'], false ) . - $this->getProfileSection( wfMessage( 'user-personal-info-places-lived' )->escaped(), $profile_data['places_lived'], false ) . - $this->getProfileSection( wfMessage( 'user-personal-info-schools' )->escaped(), $profile_data['schools'], false ) . - $this->getProfileSection( wfMessage( 'user-personal-info-about-me' )->escaped(), $profile_data['about'], false ) . + $personal_output . '</div>'; } elseif ( $wgUser->getName() == $user_name ) { $output .= '<div class="user-section-heading"> @@ -677,6 +746,20 @@ $profile_data['custom_3'] . $profile_data['custom_4']; $edit_info_link = SpecialPage::getTitleFor( 'UpdateProfile' ); + $custom_output = ''; + if ( in_array( 'up_custom_1', $this->profile_visible_fields ) ) { + $custom_output .= $this->getProfileSection( wfMessage( 'custom-info-field1' )->escaped(), $profile_data['custom_1'], false ); + } + if ( in_array( 'up_custom_2', $this->profile_visible_fields ) ) { + $custom_output .= $this->getProfileSection( wfMessage( 'custom-info-field2' )->escaped(), $profile_data['custom_2'], false ); + } + if ( in_array( 'up_custom_3', $this->profile_visible_fields ) ) { + $custom_output .= $this->getProfileSection( wfMessage( 'custom-info-field3' )->escaped(), $profile_data['custom_3'], false ); + } + if ( in_array( 'up_custom_4', $this->profile_visible_fields ) ) { + $custom_output .= $this->getProfileSection( wfMessage( 'custom-info-field4' )->escaped(), $profile_data['custom_4'], false ); + } + $output = ''; if ( $joined_data ) { $output .= '<div class="user-section-heading"> @@ -695,10 +778,7 @@ </div> <div class="cleared"></div> <div class="profile-info-container">' . - $this->getProfileSection( wfMessage( 'custom-info-field1' )->escaped(), $profile_data['custom_1'], false ) . - $this->getProfileSection( wfMessage( 'custom-info-field2' )->escaped(), $profile_data['custom_2'], false ) . - $this->getProfileSection( wfMessage( 'custom-info-field3' )->escaped(), $profile_data['custom_3'], false ) . - $this->getProfileSection( wfMessage( 'custom-info-field4' )->escaped(), $profile_data['custom_4'], false ) . + $custom_output . '</div>'; } elseif ( $wgUser->getName() == $user_name ) { $output .= '<div class="user-section-heading"> @@ -747,6 +827,32 @@ $profile_data['snacks']; $edit_info_link = SpecialPage::getTitleFor( 'UpdateProfile' ); + $interests_output = ''; + if ( in_array( 'up_movies', $this->profile_visible_fields ) ) { + $interests_output .= $this->getProfileSection( wfMessage( 'other-info-movies' )->escaped(), $profile_data['movies'], false ); + } + if ( in_array( 'up_tv', $this->profile_visible_fields ) ) { + $interests_output .= $this->getProfileSection( wfMessage( 'other-info-tv' )->escaped(), $profile_data['tv'], false ); + } + if ( in_array( 'up_music', $this->profile_visible_fields ) ) { + $interests_output .= $this->getProfileSection( wfMessage( 'other-info-music' )->escaped(), $profile_data['music'], false ); + } + if ( in_array( 'up_books', $this->profile_visible_fields ) ) { + $interests_output .= $this->getProfileSection( wfMessage( 'other-info-books' )->escaped(), $profile_data['books'], false ); + } + if ( in_array( 'up_video_games', $this->profile_visible_fields ) ) { + $interests_output .= $this->getProfileSection( wfMessage( 'other-info-video-games' )->escaped(), $profile_data['video_games'], false ); + } + if ( in_array( 'up_magazines', $this->profile_visible_fields ) ) { + $interests_output .= $this->getProfileSection( wfMessage( 'other-info-magazines' )->escaped(), $profile_data['magazines'], false ); + } + if ( in_array( 'up_snacks', $this->profile_visible_fields ) ) { + $interests_output .= $this->getProfileSection( wfMessage( 'other-info-snacks' )->escaped(), $profile_data['snacks'], false ); + } + if ( in_array( 'up_drinks', $this->profile_visible_fields ) ) { + $interests_output .= $this->getProfileSection( wfMessage( 'other-info-drinks' )->escaped(), $profile_data['drinks'], false ); + } + $output = ''; if ( $joined_data ) { $output .= '<div class="user-section-heading"> @@ -765,14 +871,7 @@ </div> <div class="cleared"></div> <div class="profile-info-container">' . - $this->getProfileSection( wfMessage( 'other-info-movies' )->escaped(), $profile_data['movies'], false ) . - $this->getProfileSection( wfMessage( 'other-info-tv' )->escaped(), $profile_data['tv'], false ) . - $this->getProfileSection( wfMessage( 'other-info-music' )->escaped(), $profile_data['music'], false ) . - $this->getProfileSection( wfMessage( 'other-info-books' )->escaped(), $profile_data['books'], false ) . - $this->getProfileSection( wfMessage( 'other-info-video-games' )->escaped(), $profile_data['video_games'], false ) . - $this->getProfileSection( wfMessage( 'other-info-magazines' )->escaped(), $profile_data['magazines'], false ) . - $this->getProfileSection( wfMessage( 'other-info-snacks' )->escaped(), $profile_data['snacks'], false ) . - $this->getProfileSection( wfMessage( 'other-info-drinks' )->escaped(), $profile_data['drinks'], false ) . + $interests_output . '</div>'; } elseif ( $this->isOwner() ) { $output .= '<div class="user-section-heading"> diff --git a/UserProfile/i18n/en.json b/UserProfile/i18n/en.json index e7295a0..64914fc 100644 --- a/UserProfile/i18n/en.json +++ b/UserProfile/i18n/en.json @@ -180,6 +180,10 @@ "user-profile-create-threshold-quiz-correct": "{{PLURAL:$1|one correctly answered quiz|$1 correctly answered quizzes}}", "user-profile-create-threshold-quiz-points": "{{PLURAL:$1|one quiz point|$1 quiz points}}", "user-profile-create-threshold-reason": "Sorry, you cannot create a user profile until you have at least $1", + "user-profile-privacy-status-privacy-public": "public", + "user-profile-privacy-status-privacy-hidden": "hidden", + "user-profile-privacy-status-privacy-friends": "friends", + "user-profile-privacy-status-privacy-foaf": "friends of friends", "user-no-images": "No images uploaded", "edit-profile-title": "Edit your profile", "edit-profiles-title": "Edit profiles", diff --git a/UserProfile/i18n/fi.json b/UserProfile/i18n/fi.json index 01ea6a1..d6a5e65 100644 --- a/UserProfile/i18n/fi.json +++ b/UserProfile/i18n/fi.json @@ -176,6 +176,10 @@ "user-profile-create-threshold-weekly-wins": "{{PLURAL:$1|yksi viikottainen voitto|$1 viikottaista voittoa}}", "user-profile-create-threshold-monthly-wins": "{{PLURAL:$1|yksi kuukausittainen voitto|$1 kuukausittaista voittoa}}", "user-profile-create-threshold-reason": "Pahoittelut, et voi luoda käyttäjäprofiilia ennen kuin sinulla on ainakin $1", + "user-profile-privacy-status-privacy-public": "julkinen", + "user-profile-privacy-status-privacy-hidden": "piilotettu", + "user-profile-privacy-status-privacy-friends": "ystävät", + "user-profile-privacy-status-privacy-foaf": "ystävien ystävät", "user-no-images": "Ei ladattuja kuvia", "edit-profile-title": "Muokkaa profiiliasi", "edit-profiles-title": "Muokkaa profiileja", diff --git a/UserProfile/i18n/fr.json b/UserProfile/i18n/fr.json index e8f9a5a..549b9c8 100644 --- a/UserProfile/i18n/fr.json +++ b/UserProfile/i18n/fr.json @@ -168,6 +168,10 @@ "user-profile-picture-picsize": "Votre image doit être au format jpeg, png ou gif, et sa taille ne doit pas dépasser 100ko.", "user-profile-goback": "Revenir en arrière", "user-profile-userlevels-link": "Niveaux de l’utilisateur", + "user-profile-privacy-status-privacy-public": "public", + "user-profile-privacy-status-privacy-hidden": "caché", + "user-profile-privacy-status-privacy-friends": "amis", + "user-profile-privacy-status-privacy-foaf": "amis d’amis", "user-no-images": "Aucune image téléversée", "edit-profile-title": "Modifier votre profil", "edit-profiles-title": "Modifier les profils", diff --git a/UserProfile/i18n/ru.json b/UserProfile/i18n/ru.json index e632c58..f0b4dda 100644 --- a/UserProfile/i18n/ru.json +++ b/UserProfile/i18n/ru.json @@ -161,6 +161,10 @@ "user-profile-picture-picsize": "Ваше изображение должно быть в формате jpeg, png, или gif и не превышать 100 КБ в размере.", "user-profile-goback": "Назад", "user-profile-userlevels-link": "Уровни участника", + "user-profile-privacy-status-privacy-public": "видно всем", + "user-profile-privacy-status-privacy-hidden": "скрыто", + "user-profile-privacy-status-privacy-friends": "друзьям", + "user-profile-privacy-status-privacy-foaf": "друзья друзей", "user-no-images": "изображения не загружены", "edit-profile-title": "Править ваш профиль", "edit-profiles-title": "Изменение профилей", diff --git a/UserProfile/user_fields_privacy.postgres.sql b/UserProfile/user_fields_privacy.postgres.sql new file mode 100644 index 0000000..1a7604d --- /dev/null +++ b/UserProfile/user_fields_privacy.postgres.sql @@ -0,0 +1,9 @@ +-- +-- Table structure for table `user_fields_privacy` +-- + +CREATE TABLE user_fields_privacy ( + user_id INTEGER NOT NULL, + field_key text, + privacy text +); \ No newline at end of file diff --git a/UserProfile/user_fields_privacy.sql b/UserProfile/user_fields_privacy.sql new file mode 100644 index 0000000..3360581 --- /dev/null +++ b/UserProfile/user_fields_privacy.sql @@ -0,0 +1,9 @@ +-- +-- Table structure for table `user_fields_privacy` +-- + +CREATE TABLE /*_*/user_fields_privacy ( + user_id varchar(255) NOT NULL, + field_key varchar(255) default NULL, + privacy varchar(255) default NULL +) /*$wgDBTableOptions*/; \ No newline at end of file diff --git a/UserSecurity/UserSecurityClass.php b/UserSecurity/UserSecurityClass.php new file mode 100644 index 0000000..fbd5e68 --- /dev/null +++ b/UserSecurity/UserSecurityClass.php @@ -0,0 +1,243 @@ +<?php +/** + * Provides functions for managing user profile fields' visibility + * + * @file + * @ingroup Extensions + * @author Vedmaka <[email protected]> + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later + */ + +class SPUserSecurity { + /** + * Set the visibility of a given user's given profile field ($fieldKey) to + * whatever $priv is. + * + * @param int $uid User ID of the user whose profile we're dealing with + * @param string $fieldKey Field key, i.e. up_movies for the "Movies" field + * @param string $priv New privacy value (in plain English, i.e. "public" or "hidden") + */ + public static function setPrivacy( $uid, $fieldKey, $priv ) { + $dbw = wfGetDB( DB_MASTER ); + $s = $dbw->selectRow( + 'user_fields_privacy', + array( '*' ), + array( 'user_id' => $uid, 'field_key' => $fieldKey ), + __METHOD__ + ); + + if ( !$s ) { + $dbw->insert( + 'user_fields_privacy', + array( + 'user_id' => $uid, + 'field_key' => $fieldKey, + 'privacy' => $priv + ), + __METHOD__ + ); + } else { + $dbw->update( + 'user_fields_privacy', + array( 'privacy' => $priv ), + array( 'user_id' => $uid, 'field_key' => $fieldKey ), + __METHOD__ + ); + } + } + + /** + * Get the privacy value for the supplied user's supplied field key + * + * @param int $uid User ID of the user whose profile we're dealing with + * @param string $fieldKey Field key, i.e. up_movies for the "Movies" field + * @return string Privacy value (in plain English, i.e. "public" or "hidden") + */ + public static function getPrivacy( $uid, $fieldKey ) { + $dbw = wfGetDB( DB_MASTER ); + $s = $dbw->selectRow( + 'user_fields_privacy', + array( '*' ), + array( 'field_key' => $fieldKey, 'user_id' => $uid ), + __METHOD__ + ); + + if ( $s ) { + return $s->privacy; + } else { + return 'public'; + } + } + + /** + * Render fields privacy button by field code + * + * @param string $fieldKey Field key, i.e. up_movies for the "Movies" field + * @param int|null $uid User ID of the user whose profile we're dealing with + * @return string HTML suitable for output + */ + public static function renderEye( $fieldKey, $uid = null ) { + global $wgUser; + + if ( !$uid || $uid == null ) { + $uid = $wgUser->getId(); + } + + $dbw = wfGetDB( DB_MASTER ); + $s = $dbw->selectRow( + 'user_fields_privacy', + array( '*' ), + array( 'field_key' => $fieldKey, 'user_id' => $uid ), + __METHOD__ + ); + + if ( $s ) { + $privacy = $s->privacy; + } else { + $privacy = 'public'; + } + + // Form list with remaining privacies + $all_privacy = array( 'public', 'hidden', 'friends', 'foaf' ); + + $ret = '<div class="eye-container" current_action="' . + htmlspecialchars( $privacy, ENT_QUOTES ) . '" fieldkey="' . + htmlspecialchars( $fieldKey, ENT_QUOTES ) . '"> + <div class="title">' . + // For grep: i18n messages used here: + // user-profile-privacy-status-privacy-public, + // user-profile-privacy-status-privacy-hidden, + // user-profile-privacy-status-privacy-friends, + // user-profile-privacy-status-privacy-foaf + wfMessage( 'user-profile-privacy-status-privacy-' . $privacy )->plain() . '</div> + <div class="menu">'; + + foreach ( $all_privacy as $priv ) { + if ( $priv == $privacy ) { + continue; + } + + $ret .= '<div class="item" action="' . htmlspecialchars( $priv, ENT_QUOTES ) . '">' . + wfMessage( 'user-profile-privacy-status-privacy-' . $priv )->plain() . + '</div>'; + } + + $ret .= '</div> + </div>'; + + return $ret; + } + + /** + * Get the list of user profile fields visible to the supplied viewer + * + * @param int $owner_user_id User ID of the person whose profile we're dealing with + * @param null|int $viewer_user_id User ID of the person who's viewing the owner's profile + * @return array Array of field keys (up_movies for "Movies" and so on) + */ + public static function getVisibleFields( $owner_user_id, $viewer_user_id = null ) { + global $wgUser; + + if ( $viewer_user_id == null ) { + $viewer_user_id = $wgUser->getId(); + } + + $arResult = array(); + // Get fields list + $user = User::newFromId( $owner_user_id ); + if ( !$user instanceof User ) { + return $arResult; + } + // The following line originally had the inline comment "does not matter", + // but it actually matters if you pass in something that the constructor + // expects (a username) or something that it doesn't (a user ID), because + // the latter will lead into "fun" fatals that are tricky to track down + // unless you know what you're doing... + $profile = new UserProfile( $user->getName() ); + $arFields = $profile->profile_fields; + + foreach ( $arFields as $field ) { + if ( SPUserSecurity::isFieldVisible( $owner_user_id, 'up_' . $field, $viewer_user_id ) ) { + $arResult[] = 'up_' . $field; + } + } + + return $arResult; + } + + /** + * Checks if the viewer can view the profile owner's field + * + * @todo Implement new function which returns an array of accessible fields + * in order to reduce SQL queries + * + * @param int $owner_user_id User ID of the person whose profile we're dealing with + * @param string $fkey Field key, i.e. up_movies for the "Movies" field + * @param null|int $viewer_user_id User ID of the person who's viewing the owner's profile + * @return bool True if the user can view the field, otherwise false + */ + public static function isFieldVisible( $owner_user_id, $fkey, $viewer_user_id = null ) { + global $wgUser; + + // No user ID -> use the current user's ID + if ( $viewer_user_id == null ) { + $viewer_user_id = $wgUser->getId(); + } + + // Owner can always view all of their profile fields, obviously + if ( $viewer_user_id == $owner_user_id ) { + return true; + } + + $relation = UserRelationship::getUserRelationshipByID( $viewer_user_id, $owner_user_id ); // 1 = friend, 2 = foe + $privacy = SPUserSecurity::getPrivacy( $owner_user_id, $fkey ); + + switch ( $privacy ) { + case 'public': + return true; + break; + + case 'hidden': + return false; + break; + + case 'friends': + if ( $relation == 1 ) { + return true; + } + break; + + case 'foaf': + if ( $relation == 1 ) { + return true; + } + + // Now we know that the viewer is not the user's friend, but we + // must check if the viewer has friends that are the owner's friends: + if ( isset( $owner_user_id ) && ( $owner_user_id !== null ) ) { + $what = $owner_user_id; + } else { + $what = $wgUser->getId(); + } + $user = User::newFromId( $what ); + if ( !$user instanceof User ) { + return false; + } + $ur = new UserRelationship( $user->getName() ); + $owner_friends = $ur->getRelationshipList( 1 ); + + foreach ( $owner_friends as $friend ) { + // If someone in the owner's friends has the viewer in their + // friends, the test is passed + if ( UserRelationship::getUserRelationshipByID( $friend['user_id'], $viewer_user_id ) == 1 ) { + return true; + } + } + + break; + } + + return false; + } + +} \ No newline at end of file diff --git a/images/eye-bw.png b/images/eye-bw.png new file mode 100644 index 0000000..71045de --- /dev/null +++ b/images/eye-bw.png Binary files differ diff --git a/images/eye.png b/images/eye.png new file mode 100644 index 0000000..34cea8d --- /dev/null +++ b/images/eye.png Binary files differ diff --git a/images/slide-arrow.png b/images/slide-arrow.png new file mode 100644 index 0000000..6287991 --- /dev/null +++ b/images/slide-arrow.png Binary files differ diff --git a/images/star.png b/images/star.png new file mode 100644 index 0000000..2ef9c6e --- /dev/null +++ b/images/star.png Binary files differ -- To view, visit https://gerrit.wikimedia.org/r/184858 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I52672f9fffa92ea48e110f9ebb419dd0fbdc1f6f Gerrit-PatchSet: 1 Gerrit-Project: mediawiki/extensions/SocialProfile Gerrit-Branch: master Gerrit-Owner: Jack Phoenix <[email protected]> _______________________________________________ MediaWiki-commits mailing list [email protected] https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits
