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