Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 182
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
VectorComponentUserLinks
0.00% covered (danger)
0.00%
0 / 182
0.00% covered (danger)
0.00%
0 / 9
930
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 msg
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDropdown
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
72
 getMenus
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
72
 stripIcons
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 makeLinksButtons
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 makeItemsCollapsible
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getOverflowMenuClass
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getTemplateData
0.00% covered (danger)
0.00%
0 / 88
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2namespace MediaWiki\Skins\Vector\Components;
3
4use MediaWiki\Linker\Linker;
5use MediaWiki\Message\Message;
6use MediaWiki\Skin\SkinComponentLink;
7use MediaWiki\Title\MalformedTitleException;
8use MediaWiki\Title\Title;
9use MediaWiki\User\UserIdentity;
10use MessageLocalizer;
11
12/**
13 * VectorComponentUserLinks component
14 */
15class VectorComponentUserLinks implements VectorComponent {
16
17    private const BUTTON_CLASSES = 'cdx-button cdx-button--fake-button '
18        . 'cdx-button--fake-button--enabled cdx-button--weight-quiet';
19    private const ICON_ONLY_BUTTON_CLASS = 'cdx-button--icon-only';
20
21    /** @var MessageLocalizer */
22    private $localizer;
23    /** @var UserIdentity */
24    private $user;
25    /** @var array */
26    private $portletData;
27    /** @var array */
28    private $linkOptions;
29    /** @var string */
30    private $userIcon;
31
32    /**
33     * @param MessageLocalizer $localizer
34     * @param UserIdentity $user
35     * @param array $portletData
36     * @param array $linkOptions
37     * @param string $userIcon that represents the current type of user
38     */
39    public function __construct(
40        MessageLocalizer $localizer,
41        UserIdentity $user,
42        array $portletData,
43        array $linkOptions,
44        string $userIcon = 'userAvatar'
45    ) {
46        $this->localizer = $localizer;
47        $this->user = $user;
48        $this->portletData = $portletData;
49        $this->linkOptions = $linkOptions;
50        $this->userIcon = $userIcon;
51    }
52
53    /**
54     * @param string $key
55     * @return Message
56     */
57    private function msg( $key ): Message {
58        return $this->localizer->msg( $key );
59    }
60
61    /**
62     * @param bool $isDefaultAnonUserLinks
63     * @param bool $isAnonEditorLinksEnabled
64     * @param int $userLinksCount
65     * @return VectorComponentDropdown
66     */
67    private function getDropdown( $isDefaultAnonUserLinks, $isAnonEditorLinksEnabled, $userLinksCount ) {
68        $user = $this->user;
69        $isAnon = !$user->isRegistered();
70
71        $class = 'vector-user-menu';
72        $class .= ' vector-button-flush-right';
73        $class .= !$isAnon ?
74            ' vector-user-menu-logged-in' :
75            ' vector-user-menu-logged-out';
76
77        // Hide entire user links dropdown on larger viewports if it only contains
78        // create account & login link, which are only shown on smaller viewports
79        if ( $isAnon && $isDefaultAnonUserLinks && !$isAnonEditorLinksEnabled ) {
80            $linkclass = ' user-links-collapsible-item';
81
82            if ( $userLinksCount === 0 ) {
83                // The user links can be completely empty when even login is not possible
84                // (e.g using remote authentication). In this case, we need to hide the
85                // dropdown completely not only on larger viewports.
86                $linkclass .= '--none';
87            }
88
89            $class .= $linkclass;
90        }
91
92        $tooltip = '';
93        $icon = $this->userIcon;
94        if ( $icon === '' && $userLinksCount ) {
95            $icon = 'ellipsis';
96            // T287494 We use tooltip messages to provide title attributes on hover over certain menu icons.
97            // For modern Vector, the "tooltip-p-personal" key is set to "User menu" which is appropriate for
98            // the user icon (dropdown indicator for user links menu) for logged-in users.
99            // This overrides the tooltip for the user links menu icon which is an ellipsis for anonymous users.
100            $tooltip = Linker::tooltip( 'vector-anon-user-menu-title' ) ?? '';
101        }
102
103        return new VectorComponentDropdown(
104            'vector-user-links-dropdown', $this->msg( 'personaltools' )->text(), $class, $icon, $tooltip
105        );
106    }
107
108    /**
109     * @param bool $isDefaultAnonUserLinks
110     * @param bool $isAnonEditorLinksEnabled
111     * @return array
112     */
113    private function getMenus( $isDefaultAnonUserLinks, $isAnonEditorLinksEnabled ) {
114        $user = $this->user;
115        $isAnon = !$user->isRegistered();
116        $portletData = $this->portletData;
117
118        // Hide default user menu on larger viewports if it only contains
119        // create account & login link, which are only shown on smaller viewports
120        // FIXME: Replace array_merge with an add class helper function
121        $userMenuClass = $portletData[ 'data-user-menu' ][ 'class' ];
122        $userMenuClass = $isAnon && $isDefaultAnonUserLinks ?
123            $userMenuClass . ' user-links-collapsible-item' : $userMenuClass;
124        $dropdownMenus = [
125            new VectorComponentMenu( [
126                'label' => null,
127                'class' => $userMenuClass
128            ] + $portletData[ 'data-user-menu' ] )
129        ];
130
131        if ( $isAnon ) {
132            // T317789: The `anontalk` and `anoncontribs` links will not be added to
133            // the menu if `$wgGroupPermissions['*']['edit']` === false which can
134            // leave the menu empty due to our removal of other user menu items in
135            // `Hooks::updateUserLinksDropdownItems`. In this case, we do not want
136            // to render the anon "learn more" link.
137            if ( $isAnonEditorLinksEnabled ) {
138                $anonUserMenuData = $portletData[ 'data-user-menu-anon-editor' ];
139                try {
140                    $anonEditorLabelLinkData = [
141                        'text' => $this->msg( 'vector-anon-user-menu-pages-learn' )->text(),
142                        'href' => Title::newFromTextThrow( $this->msg( 'vector-intro-page' )->text() )->getLocalURL(),
143                        'aria-label' => $this->msg( 'vector-anon-user-menu-pages-label' )->text(),
144                    ];
145                    $anonEditorLabelLink = new SkinComponentLink(
146                        '', $anonEditorLabelLinkData, $this->localizer, $this->linkOptions
147                    );
148                    $anonEditorLabelLinkHtml = $anonEditorLabelLink->getTemplateData()[ 'html' ];
149                    $anonUserMenuData['html-label'] = $this->msg( 'vector-anon-user-menu-pages' )->escaped() .
150                        " " . $anonEditorLabelLinkHtml;
151                    $anonUserMenuData['label'] = null;
152                } catch ( MalformedTitleException $e ) {
153                    // ignore (T340220)
154                }
155                $dropdownMenus[] = new VectorComponentMenu( $anonUserMenuData );
156            }
157        } else {
158            // Logout isn't enabled for temp users, who are considered still considered registered
159            $isLogoutLinkEnabled = isset( $portletData[ 'data-user-menu-logout' ][ 'is-empty' ] ) &&
160                !$portletData[ 'data-user-menu-logout'][ 'is-empty' ];
161            if ( $isLogoutLinkEnabled ) {
162                $dropdownMenus[] = new VectorComponentMenu( [
163                    'label' => null
164                ] + $portletData[ 'data-user-menu-logout' ] );
165            }
166        }
167
168        return $dropdownMenus;
169    }
170
171    /**
172     * Strips icons from the menu.
173     *
174     * @param array $arrayListItems
175     * @return array
176     */
177    private static function stripIcons( array $arrayListItems ) {
178        return array_map( static function ( $item ) {
179            $item['array-links'] = array_map( static function ( $link ) {
180                $link['icon'] = null;
181                return $link;
182            }, $item['array-links'] );
183            return $item;
184        }, $arrayListItems );
185    }
186
187    /**
188     * Converts links to button icons
189     *
190     * @param array $arrayListItems
191     * @param bool $iconOnlyButton whether label should be visible.
192     * @param array $exceptions list of names of items that should not be converted.
193     * @return array
194     */
195    private static function makeLinksButtons( $arrayListItems, $iconOnlyButton = true, $exceptions = [] ) {
196        return array_map( static function ( $item ) use ( $iconOnlyButton, $exceptions ) {
197            if ( in_array( $item[ 'name'], $exceptions ) ) {
198                return $item;
199            }
200            $item['array-links'] = array_map( static function ( $link ) use ( $iconOnlyButton ) {
201                $link['array-attributes'] = array_map( static function ( $attribute ) use ( $iconOnlyButton ) {
202                    if ( $attribute['key'] === 'class' ) {
203                        $newClass = $attribute['value'] . ' ' . self::BUTTON_CLASSES;
204                        if ( $iconOnlyButton ) {
205                            $newClass .= ' ' . self::ICON_ONLY_BUTTON_CLASS;
206                        }
207                        $attribute['value'] = $newClass;
208                    }
209                    return $attribute;
210                }, $link['array-attributes'] );
211                return $link;
212            }, $item['array-links'] );
213            return $item;
214        }, $arrayListItems );
215    }
216
217    /**
218     * Makes all menu items collapsible at lower resolutions.
219     *
220     * @param array $arrayListItems
221     * @return array
222     */
223    private static function makeItemsCollapsible( $arrayListItems ) {
224        return array_map( static function ( $item ) {
225            $item['class'] .= ' user-links-collapsible-item';
226            return $item;
227        }, $arrayListItems );
228    }
229
230    /**
231     * What class should the overflow menu have?
232     *
233     * @param array $arrayListItems
234     * @return string
235     */
236    private static function getOverflowMenuClass( $arrayListItems ) {
237        $overflowMenuClass = 'mw-portlet';
238        if ( count( $arrayListItems ) === 0 ) {
239            $overflowMenuClass .= ' emptyPortlet';
240        }
241        return $overflowMenuClass;
242    }
243
244    /**
245     * @inheritDoc
246     */
247    public function getTemplateData(): array {
248        $portletData = $this->portletData;
249
250        $userLinksCount = count( $portletData['data-user-menu']['array-items'] );
251        $isDefaultAnonUserLinks = $userLinksCount <= 3;
252        $isAnonEditorLinksEnabled = isset( $portletData['data-user-menu-anon-editor']['is-empty'] )
253            && !$portletData['data-user-menu-anon-editor']['is-empty'];
254
255        $userInterfacePreferences = $this->makeLinksButtons(
256            $this->makeItemsCollapsible(
257                $portletData[ 'data-user-interface-preferences' ]['array-items'] ?? []
258            ),
259            false
260        );
261        $userPage = $this->makeItemsCollapsible(
262            $this->stripIcons( $portletData[ 'data-user-page' ]['array-items'] ?? [] )
263        );
264        $notifications = $this->makeLinksButtons(
265            $portletData[ 'data-notifications' ]['array-items'] ?? [],
266            true,
267            [ 'talk-alert' ]
268        );
269
270        $overflow = $this->makeItemsCollapsible(
271            array_map(
272                static function ( $item ) {
273                    // Since we're creating duplicate icons
274                    $item['id'] .= '-2';
275                    // Restore icon removed in hooks.
276                    if ( $item['name'] === 'watchlist' ) {
277                        $item['icon'] = 'watchlist';
278                    }
279                    return $item;
280                },
281                // array_filter preserves keys so use array_values to restore array.
282                array_values(
283                    array_filter(
284                        $portletData['data-user-menu']['array-items'] ?? [],
285                        static function ( $item ) {
286                            // Only certain items get promoted to the overflow menu:
287                            // * watchlist
288                            // * login
289                            // * create account
290                            $name = $item['name'];
291                            return in_array( $name, [
292                                'watchlist',
293                                'createaccount',
294                                'login',
295                                'login-private',
296                                'sitesupport'
297                            ] );
298                        }
299                    )
300                )
301            )
302        );
303        // Convert to buttons for logged in users.
304        // For anons these will remain as links.
305        // Note: This list is empty for temporary users currently.
306        if ( $this->user->isRegistered() ) {
307            $overflow = $this->makeLinksButtons( $overflow );
308        }
309
310        $preferencesMenu = new VectorComponentMenu( [
311            'id' => 'p-vector-user-menu-preferences',
312            'class' => self::getOverflowMenuClass( $userInterfacePreferences ),
313            'label' => null,
314            'html-items' => null,
315            'array-list-items' => $userInterfacePreferences,
316        ] );
317        $userPageMenu = new VectorComponentMenu( [
318            'id' => 'p-vector-user-menu-userpage',
319            'class' => self::getOverflowMenuClass( $userPage ),
320            'label' => null,
321            'html-items' => null,
322            'array-list-items' => $userPage,
323        ] );
324        $notificationsMenu = new VectorComponentMenu( [
325            'id' => 'p-vector-user-menu-notifications',
326            'class' => self::getOverflowMenuClass( $notifications ),
327            'label' => null,
328            'html-items' => null,
329            'array-list-items' => $notifications,
330        ] );
331        $overflowMenu = new VectorComponentMenu( [
332            'id' => 'p-vector-user-menu-overflow',
333            'class' => self::getOverflowMenuClass( $overflow ),
334            'label' => null,
335            'html-items' => null,
336            'array-list-items' => $overflow,
337        ] );
338
339        return [
340            'is-wide' => array_filter(
341                [ $overflow, $notifications, $userPage, $userInterfacePreferences ]
342            ) !== [],
343            'data-user-links-notifications' => $notificationsMenu->getTemplateData(),
344            'data-user-links-overflow' => $overflowMenu->getTemplateData(),
345            'data-user-links-preferences' => $preferencesMenu->getTemplateData(),
346            'data-user-links-user-page' => $userPageMenu->getTemplateData(),
347            'data-user-links-dropdown' => $this->getDropdown(
348                $isDefaultAnonUserLinks, $isAnonEditorLinksEnabled, $userLinksCount )->getTemplateData(),
349            'data-user-links-menus' => array_map( static function ( $menu ) {
350                return $menu->getTemplateData();
351            }, $this->getMenus( $isDefaultAnonUserLinks, $isAnonEditorLinksEnabled ) ),
352        ];
353    }
354}