Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 275
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMobileOptions
0.00% covered (danger)
0.00%
0 / 275
0.00% covered (danger)
0.00%
0 / 10
1332
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setJsConfigVars
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 buildAMCToggle
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
2
 buildMobileUserPreferences
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
2
 contentElement
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 addSettingsForm
0.00% covered (danger)
0.00%
0 / 111
0.00% covered (danger)
0.00%
0 / 1
132
 getRedirectUrl
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 submitSettingsForm
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
210
1<?php
2
3use MediaWiki\Config\Config;
4use MediaWiki\Deferred\DeferredUpdates;
5use MediaWiki\Html\Html;
6use MediaWiki\MediaWikiServices;
7use MediaWiki\Request\WebRequest;
8use MediaWiki\SpecialPage\UnlistedSpecialPage;
9use MediaWiki\Title\Title;
10use MediaWiki\User\Options\UserOptionsManager;
11use MobileFrontend\Amc\UserMode;
12use MobileFrontend\Features\IFeature;
13use Wikimedia\Rdbms\ReadOnlyMode;
14
15/**
16 * Adds a special page with mobile specific preferences
17 */
18class SpecialMobileOptions extends UnlistedSpecialPage {
19    /** @var bool Whether this special page has a desktop version or not */
20    protected $hasDesktopVersion = true;
21
22    /**
23     * Advanced Mobile Contributions mode
24     * @var \MobileFrontend\Amc\Manager
25     */
26    private $amc;
27
28    /**
29     * @var \MobileFrontend\Features\FeaturesManager
30     */
31    private $featuresManager;
32
33    /** @var UserMode */
34    private $userMode;
35
36    /** @var UserOptionsManager */
37    private UserOptionsManager $userOptionsManager;
38
39    /** @var ReadOnlyMode */
40    private ReadOnlyMode $readOnlyMode;
41    /** @var MobileContext */
42    private $mobileContext;
43    /** @var Config MobileFrontend's config object */
44    protected Config $config;
45
46    public function __construct(
47        UserOptionsManager $userOptionsManager,
48        ReadOnlyMode $readOnlyMode,
49        Config $config
50    ) {
51        parent::__construct( 'MobileOptions' );
52        $services = MediaWikiServices::getInstance();
53        $this->amc = $services->getService( 'MobileFrontend.AMC.Manager' );
54        $this->featuresManager = $services->getService( 'MobileFrontend.FeaturesManager' );
55        $this->userMode = $services->getService( 'MobileFrontend.AMC.UserMode' );
56        $this->mobileContext = $services->getService( 'MobileFrontend.Context' );
57        $this->userOptionsManager = $userOptionsManager;
58        $this->readOnlyMode = $readOnlyMode;
59        $this->config = $config;
60    }
61
62    /**
63     * @return bool
64     */
65    public function doesWrites() {
66        return true;
67    }
68
69    /**
70     * Set the required config for the page.
71     */
72    public function setJsConfigVars() {
73        $this->getOutput()->addJsConfigVars( [
74            'wgMFEnableFontChanger' => $this->featuresManager->isFeatureAvailableForCurrentUser(
75                'MFEnableFontChanger'
76            ),
77        ] );
78    }
79
80    /**
81     * Render the special page
82     * @param string|null $par Parameter submitted as subpage
83     */
84    public function execute( $par = '' ) {
85        parent::execute( $par );
86        $out = $this->getOutput();
87
88        $this->setHeaders();
89        $out->addBodyClasses( 'mw-mf-special-page' );
90        $out->addModuleStyles( [
91            'mobile.special.styles',
92            'mobile.special.codex.styles',
93            'mobile.special.mobileoptions.styles',
94        ] );
95        $out->addModules( [
96            'mobile.special.mobileoptions.scripts',
97        ] );
98        $this->setJsConfigVars();
99
100        $this->mobileContext->setForceMobileView( true );
101
102        if ( $this->getRequest()->wasPosted() ) {
103            $this->submitSettingsForm();
104        } else {
105            $this->addSettingsForm();
106        }
107    }
108
109    private function buildAMCToggle() {
110        $amcToggle = new OOUI\CheckboxInputWidget( [
111            'name' => 'enableAMC',
112            'infusable' => true,
113            'selected' => $this->userMode->isEnabled(),
114            'id' => 'enable-amc-toggle',
115            'value' => '1',
116        ] );
117        $layout = new OOUI\FieldLayout(
118            $amcToggle,
119            [
120                'label' => new OOUI\LabelWidget( [
121                    'input' => $amcToggle,
122                    'label' => new OOUI\HtmlSnippet(
123                        Html::openElement( 'div' ) .
124                        Html::rawElement( 'strong', [],
125                            $this->msg( 'mw-mf-amc-name' )->parse() ) .
126                        Html::rawElement( 'div', [ 'class' => 'option-description' ],
127                            $this->msg( 'mw-mf-amc-description' )->parse()
128                        ) .
129                        Html::closeElement( 'div' )
130                    )
131                ] ),
132                'id' => 'amc-field',
133            ]
134        );
135        // placing links inside a label reduces usability and accessibility so
136        // append links to $layout and outside of label instead
137        // https://www.w3.org/TR/html52/sec-forms.html#example-42c5e0c5
138        $layout->appendContent( new OOUI\HtmlSnippet(
139            Html::openElement( 'ul', [ 'class' => 'hlist option-links' ] ) .
140            Html::openElement( 'li' ) .
141            Html::rawElement(
142                    'a',
143                    // phpcs:ignore Generic.Files.LineLength.TooLong
144                    [ 'href' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/Reading/Web/Advanced_mobile_contributions' ],
145                    $this->msg( 'mobile-frontend-mobile-option-amc-learn-more' )->parse()
146            ) .
147            Html::closeElement( 'li' ) .
148            Html::openElement( 'li' ) .
149            Html::rawElement(
150                    'a',
151                    // phpcs:ignore Generic.Files.LineLength.TooLong
152                    [ 'href' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/Talk:Reading/Web/Advanced_mobile_contributions' ],
153                    $this->msg( 'mobile-frontend-mobile-option-amc-send-feedback' )->parse()
154            ) .
155            Html::closeElement( 'li' ) .
156            Html::closeElement( 'ul' )
157        ) );
158        return $layout;
159    }
160
161    /**
162     * Builds mobile user preferences field.
163     * @return \OOUI\FieldLayout
164     * @throws \OOUI\Exception
165     */
166    private function buildMobileUserPreferences() {
167        $spacer = new OOUI\LabelWidget( [
168            'name' => 'mobile_preference_spacer',
169        ] );
170        $userPreferences = new OOUI\FieldLayout(
171            $spacer,
172            [
173                'label' => new OOUI\LabelWidget( [
174                    'input' => $spacer,
175                    'label' => new OOUI\HtmlSnippet(
176                        Html::openElement( 'div' ) .
177                        Html::rawElement( 'strong', [],
178                             $this->msg( 'mobile-frontend-user-pref-option' )->parse() ) .
179                        Html::rawElement( 'div', [ 'class' => 'option-description' ],
180                             $this->msg( 'mobile-frontend-user-pref-description' )->parse()
181                        ) .
182                        Html::closeElement( 'div' )
183                    )
184                ] ),
185                'id' => 'mobile-user-pref',
186            ]
187        );
188
189        $userPreferences->appendContent( new OOUI\HtmlSnippet(
190            Html::openElement( 'ul', [ 'class' => 'hlist option-links' ] ) .
191            Html::openElement( 'li' ) .
192            Html::rawElement(
193                'a',
194                [ 'href' => Title::newFromText( 'Special:Preferences' )->getLocalURL() ],
195                $this->msg( 'mobile-frontend-user-pref-link' )->parse()
196            ) .
197            Html::closeElement( 'li' ) .
198            Html::closeElement( 'ul' )
199        ) );
200        return $userPreferences;
201    }
202
203    /**
204     * Mark some html as being content
205     * @param string $html HTML content
206     * @param string $className additional class names
207     * @return string of html
208     */
209    private static function contentElement( $html, $className = '' ) {
210        return Html::rawElement( 'div', [
211            'class' => 'content'
212        ], $html );
213    }
214
215    /**
216     * Render the settings form (with actual set settings) and add it to the
217     * output as well as any supporting modules.
218     */
219    private function addSettingsForm() {
220        $out = $this->getOutput();
221        $user = $this->getUser();
222        $isTemp = $user->isTemp();
223
224        $out->setPageTitleMsg( $this->msg( 'mobile-frontend-main-menu-settings-heading' ) );
225        $out->enableOOUI();
226
227        if ( $this->getRequest()->getCheck( 'success' ) ) {
228            $out->wrapWikiMsg(
229                self::contentElement(
230                    Html::successBox(
231                        $this->msg( 'savedprefs' )->parse(),
232                        'mw-mf-mobileoptions-message'
233                    )
234                )
235            );
236        }
237
238        $fields = [];
239        $form = new OOUI\FormLayout( [
240            'method' => 'POST',
241            'id' => 'mobile-options',
242            'action' => $this->getPageTitle()->getLocalURL(),
243        ] );
244        $form->addClasses( [ 'mw-mf-settings' ] );
245
246        if ( $this->amc->isAvailable() && !$isTemp ) {
247            $fields[] = $this->buildAMCToggle();
248        }
249
250        // beta settings
251        $isInBeta = $this->mobileContext->isBetaGroupMember();
252        if ( $this->config->get( 'MFEnableBeta' ) ) {
253            $input = new OOUI\CheckboxInputWidget( [
254                'name' => 'enableBeta',
255                'infusable' => true,
256                'selected' => $isInBeta,
257                'id' => 'enable-beta-toggle',
258                'value' => '1',
259            ] );
260            $fields[] = new OOUI\FieldLayout(
261                $input,
262                [
263                    'label' => new OOUI\LabelWidget( [
264                        'input' => $input,
265                        'label' => new OOUI\HtmlSnippet(
266                            Html::openElement( 'div' ) .
267                            Html::rawElement( 'strong', [],
268                                $this->msg( 'mobile-frontend-settings-beta' )->parse() ) .
269                            Html::rawElement( 'div', [ 'class' => 'option-description' ],
270                                $this->msg( 'mobile-frontend-opt-in-explain' )->parse()
271                            ) .
272                            Html::closeElement( 'div' )
273                        )
274                    ] ),
275                    'id' => 'beta-field',
276                ]
277            );
278
279            // TODO The userMode should know how to retrieve features assigned to that mode,
280            // we shouldn't do any special logic like this in anywhere else in the code
281            $features = array_diff(
282                $this->featuresManager->getAvailableForMode(
283                    $this->featuresManager->getMode( IFeature::CONFIG_BETA )
284                ),
285                $this->featuresManager->getAvailableForMode(
286                    $this->featuresManager->getMode( IFeature::CONFIG_STABLE )
287                )
288            );
289
290            $classNames = [ 'mobile-options-beta-feature' ];
291            if ( $isInBeta ) {
292                $classNames[] = 'is-enabled';
293                $icon = 'check';
294            } else {
295                $icon = 'lock';
296            }
297            /** @var IFeature $feature */
298            foreach ( $features as $feature ) {
299                $fields[] = new OOUI\FieldLayout(
300                    new OOUI\IconWidget( [
301                        'icon' => $icon,
302                        'title' => $this->msg( 'mobile-frontend-beta-only' )->text(),
303                    ] ),
304                    [
305                        'classes' => $classNames,
306                        'label' => new OOUI\LabelWidget( [
307                            'label' => new OOUI\HtmlSnippet(
308                                Html::rawElement( 'div', [],
309                                    Html::element( 'strong', [],
310                                        $this->msg( $feature->getNameKey() )->text() ) .
311                                    Html::element( 'div', [ 'class' => 'option-description' ],
312                                        $this->msg( $feature->getDescriptionKey() )->text() )
313                                )
314                            ),
315                        ] )
316                    ]
317                );
318            }
319        }
320
321        $fields[] = new OOUI\ButtonInputWidget( [
322            'id' => 'mw-mf-settings-save',
323            'infusable' => true,
324            'value' => $this->msg( 'mobile-frontend-save-settings' )->text(),
325            'label' => $this->msg( 'mobile-frontend-save-settings' )->text(),
326            'flags' => [ 'primary', 'progressive' ],
327            'type' => 'submit',
328        ] );
329
330        if ( $user->isRegistered() && !$isTemp ) {
331            $fields[] = new OOUI\HiddenInputWidget( [ 'name' => 'token',
332                'value' => $user->getEditToken() ] );
333            // Special:Preferences link (https://phabricator.wikimedia.org/T327506)
334            $fields[] = $this->buildMobileUserPreferences();
335        }
336
337        $feedbackLink = $this->getConfig()->get( 'MFBetaFeedbackLink' );
338        if ( $feedbackLink && $isInBeta ) {
339            $fields[] = new OOUI\ButtonWidget( [
340                'framed' => false,
341                'href' => $feedbackLink,
342                'icon' => 'feedback',
343                'flags' => [
344                    'progressive',
345                ],
346                'classes' => [ 'mobile-options-feedback' ],
347                'label' => $this->msg( 'mobile-frontend-send-feedback' )->text(),
348            ] );
349        }
350
351        $form->appendContent(
352            ...$fields
353        );
354        $out->addHTML( $form );
355    }
356
357    /**
358     * @param WebRequest $request
359     * @return string url to redirect to
360     */
361    private function getRedirectUrl( WebRequest $request ) {
362        $returnTo = $request->getText( 'returnto' );
363        if ( $returnTo !== '' ) {
364            $title = Title::newFromText( $returnTo );
365
366            if ( $title !== null ) {
367                return $title->getFullURL( $request->getText( 'returntoquery' ) );
368            }
369        }
370
371        return $this->mobileContext->getMobileUrl(
372            $this->getPageTitle()->getFullURL( 'success' )
373        );
374    }
375
376    /**
377     * Saves the settings submitted by the settings form
378     */
379    private function submitSettingsForm() {
380        $request = $this->getRequest();
381        $user = $this->getUser();
382
383        if ( $user->isRegistered() && !$user->matchEditToken( $request->getVal( 'token' ) ) ) {
384            $errorText = __METHOD__ . '(): token mismatch';
385            wfDebugLog( 'mobile', $errorText );
386            $this->getOutput()->addHTML(
387                Html::errorBox(
388                    $this->msg( "mobile-frontend-save-error" )->parse()
389                )
390            );
391            $this->addSettingsForm();
392            return;
393        }
394
395        // We must treat forms that only update a single field specially because if we
396        // don't, all the other options will be clobbered with default values
397        $updateSingleOption = $request->getRawVal( 'updateSingleOption' );
398        $enableAMC = $request->getBool( 'enableAMC' );
399        $enableBetaMode = $request->getBool( 'enableBeta' );
400        $mobileMode = $enableBetaMode ? MobileContext::MODE_BETA : '';
401
402        if ( $updateSingleOption !== 'enableAMC' ) {
403            $this->mobileContext->setMobileMode( $mobileMode );
404        }
405
406        if ( $this->amc->isAvailable() && $updateSingleOption !== 'enableBeta' ) {
407            $this->userMode->setEnabled( $enableAMC );
408        }
409
410        DeferredUpdates::addCallableUpdate( function () use (
411            $updateSingleOption,
412            $mobileMode,
413            $enableAMC ) {
414            if ( $this->readOnlyMode->isReadOnly() ) {
415                return;
416            }
417
418            $latestUser = $this->getUser()->getInstanceForUpdate();
419            if ( $latestUser === null || !$latestUser->isNamed() ) {
420                // The user is anon, temp user or could not be loaded from the database.
421                return;
422            }
423
424            if ( $updateSingleOption !== 'enableAMC' ) {
425                $this->userOptionsManager->setOption(
426                    $latestUser,
427                    MobileContext::USER_MODE_PREFERENCE_NAME,
428                    $mobileMode
429                );
430            }
431
432            if ( $this->amc->isAvailable() && $updateSingleOption !== 'enableBeta' ) {
433                $this->userOptionsManager->setOption(
434                    $latestUser,
435                    UserMode::USER_OPTION_MODE_AMC,
436                    $enableAMC ? UserMode::OPTION_ENABLED : UserMode::OPTION_DISABLED
437                );
438            }
439            $latestUser->saveSettings();
440        }, DeferredUpdates::PRESEND );
441
442        $this->getOutput()->redirect( $this->getRedirectUrl( $request ) );
443    }
444}