Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.95% covered (warning)
84.95%
79 / 93
80.00% covered (warning)
80.00%
12 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
WatchAction
85.87% covered (warning)
85.87%
79 / 92
80.00% covered (warning)
80.00%
12 / 15
44.51
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requiresUnblock
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDescription
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onSubmit
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 checkCanExecute
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 getRestriction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 usesOOUI
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFormFields
44.44% covered (danger)
44.44%
8 / 18
0.00% covered (danger)
0.00%
0 / 1
2.69
 getExpiryOptions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getExpiryOptionsFromMessage
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 alterForm
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 onSuccess
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 makeSuccessMessage
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
9.02
 doesWrites
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Performs the watch actions on a page
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program; if not, write to the Free Software
17 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
18 *
19 * @file
20 * @ingroup Actions
21 */
22
23namespace MediaWiki\Actions;
24
25use MediaWiki\Context\IContextSource;
26use MediaWiki\Exception\PermissionsError;
27use MediaWiki\Exception\ReadOnlyError;
28use MediaWiki\Exception\UserBlockedError;
29use MediaWiki\Exception\UserNotLoggedIn;
30use MediaWiki\HTMLForm\HTMLForm;
31use MediaWiki\MainConfigNames;
32use MediaWiki\Page\Article;
33use MediaWiki\Status\Status;
34use MediaWiki\User\User;
35use MediaWiki\User\UserOptionsLookup;
36use MediaWiki\Watchlist\WatchedItem;
37use MediaWiki\Watchlist\WatchedItemStoreInterface;
38use MediaWiki\Watchlist\WatchlistManager;
39use MediaWiki\Xml\XmlSelect;
40use MessageLocalizer;
41use Wikimedia\Message\MessageValue;
42use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
43
44/**
45 * Page addition to a user's watchlist
46 *
47 * @ingroup Actions
48 */
49class WatchAction extends FormAction {
50
51    /** @var bool The value of the $wgWatchlistExpiry configuration variable. */
52    protected $watchlistExpiry;
53
54    /** @var string */
55    protected $expiryFormFieldName = 'expiry';
56
57    /** @var false|WatchedItem */
58    protected $watchedItem = false;
59
60    private WatchlistManager $watchlistManager;
61    private UserOptionsLookup $userOptionsLookup;
62
63    /**
64     * Only public since 1.21
65     *
66     * @param Article $article
67     * @param IContextSource $context
68     * @param WatchlistManager $watchlistManager
69     * @param WatchedItemStoreInterface $watchedItemStore
70     * @param UserOptionsLookup $userOptionsLookup
71     */
72    public function __construct(
73        Article $article,
74        IContextSource $context,
75        WatchlistManager $watchlistManager,
76        WatchedItemStoreInterface $watchedItemStore,
77        UserOptionsLookup $userOptionsLookup
78    ) {
79        parent::__construct( $article, $context );
80        $this->watchlistExpiry = $this->getContext()->getConfig()->get( MainConfigNames::WatchlistExpiry );
81        if ( $this->watchlistExpiry ) {
82            // The watchedItem is only used in this action's form if $wgWatchlistExpiry is enabled.
83            $this->watchedItem = $watchedItemStore->getWatchedItem(
84                $this->getUser(),
85                $this->getTitle()
86            );
87        }
88        $this->watchlistManager = $watchlistManager;
89        $this->userOptionsLookup = $userOptionsLookup;
90    }
91
92    /** @inheritDoc */
93    public function getName() {
94        return 'watch';
95    }
96
97    /** @inheritDoc */
98    public function requiresUnblock() {
99        return false;
100    }
101
102    /** @inheritDoc */
103    protected function getDescription() {
104        return '';
105    }
106
107    /** @inheritDoc */
108    public function onSubmit( $data ) {
109        // Even though we're never unwatching here, use WatchlistManager::setWatch()
110        // because it also checks for changed expiry.
111        $result = $this->watchlistManager->setWatch(
112            true,
113            $this->getAuthority(),
114            $this->getTitle(),
115            $this->getRequest()->getVal( 'wp' . $this->expiryFormFieldName )
116        );
117
118        return Status::wrap( $result );
119    }
120
121    /**
122     * @throws UserNotLoggedIn
123     * @throws PermissionsError
124     * @throws ReadOnlyError
125     * @throws UserBlockedError
126     */
127    protected function checkCanExecute( User $user ) {
128        if ( !$user->isRegistered()
129            || ( $user->isTemp() && !$user->isAllowed( 'editmywatchlist' ) )
130        ) {
131            throw new UserNotLoggedIn( 'watchlistanontext', 'watchnologin' );
132        }
133
134        parent::checkCanExecute( $user );
135    }
136
137    /** @inheritDoc */
138    public function getRestriction() {
139        return 'editmywatchlist';
140    }
141
142    /** @inheritDoc */
143    protected function usesOOUI() {
144        return true;
145    }
146
147    /** @inheritDoc */
148    protected function getFormFields() {
149        // If watchlist expiry is not enabled, return a simple confirmation message.
150        if ( !$this->watchlistExpiry ) {
151            return [
152                'intro' => [
153                    'type' => 'info',
154                    'raw' => true,
155                    'default' => $this->msg( 'confirm-watch-top' )->parse(),
156                ],
157            ];
158        }
159
160        // Otherwise, use a select-list of expiries, where the default is the user's
161        // preferred expiry time (or the existing watch duration if already temporarily watched).
162        $default = $this->userOptionsLookup->getOption( $this->getUser(), 'watchstar-expiry' );
163        $expiryOptions = static::getExpiryOptions( $this->getContext(), $this->watchedItem, $default );
164        return [
165            $this->expiryFormFieldName => [
166                'type' => 'select',
167                'label-message' => 'confirm-watch-label',
168                'options' => $expiryOptions['options'],
169                'default' => $expiryOptions['default'],
170            ]
171        ];
172    }
173
174    /**
175     * Get options and default for a watchlist expiry select list. If an expiry time is provided, it
176     * will be added to the top of the list as 'x days left'.
177     *
178     * @since 1.35
179     * @todo Move this somewhere better when it's being used in more than just this action.
180     *
181     * @param MessageLocalizer $msgLocalizer
182     * @param WatchedItem|false $watchedItem
183     * @param string $defaultExpiry The default expiry time to use if $watchedItem isn't already on a watchlist.
184     * @return mixed[] With keys `options` (string[]) and `default` (string).
185     */
186    public static function getExpiryOptions(
187        MessageLocalizer $msgLocalizer,
188        $watchedItem,
189        string $defaultExpiry = 'infinite'
190    ) {
191        $expiryOptions = self::getExpiryOptionsFromMessage( $msgLocalizer );
192
193        if ( !in_array( $defaultExpiry, $expiryOptions ) ) {
194            $expiryOptions = array_merge( [ $defaultExpiry => $defaultExpiry ], $expiryOptions );
195        }
196
197        if ( $watchedItem instanceof WatchedItem && $watchedItem->getExpiry() ) {
198            // If it's already being temporarily watched, add the existing expiry as an option in the dropdown.
199            $currentExpiry = $watchedItem->getExpiry( TS_ISO_8601 );
200            $daysLeft = $watchedItem->getExpiryInDaysText( $msgLocalizer, true );
201            $expiryOptions = array_merge( [ $daysLeft => $currentExpiry ], $expiryOptions );
202
203            // Always preselect the existing expiry.
204            $defaultExpiry = $currentExpiry;
205        }
206
207        return [
208            'options' => $expiryOptions,
209            'default' => $defaultExpiry,
210        ];
211    }
212
213    /**
214     * Parse expiry options message. Fallback to english options
215     * if translated options are invalid or broken
216     *
217     * @param MessageLocalizer $msgLocalizer
218     * @param string|null $lang
219     * @return string[]
220     * @since 1.45 Method is public
221     */
222    public static function getExpiryOptionsFromMessage(
223        MessageLocalizer $msgLocalizer, ?string $lang = null
224    ): array {
225        $expiryOptionsMsg = $msgLocalizer->msg( 'watchlist-expiry-options' );
226        $optionsText = !$lang ? $expiryOptionsMsg->text() : $expiryOptionsMsg->inLanguage( $lang )->text();
227        $options = XmlSelect::parseOptionsMessage(
228            $optionsText
229        );
230
231        $expiryOptions = [];
232        foreach ( $options as $label => $value ) {
233            if ( strtotime( $value ) || wfIsInfinity( $value ) ) {
234                $expiryOptions[$label] = $value;
235            }
236        }
237
238        // If message options is invalid try to recover by returning
239        // english options (T267611)
240        if ( !$expiryOptions && $expiryOptionsMsg->getLanguage()->getCode() !== 'en' ) {
241            return self::getExpiryOptionsFromMessage( $msgLocalizer, 'en' );
242        }
243
244        return $expiryOptions;
245    }
246
247    protected function alterForm( HTMLForm $form ) {
248        $msg = $this->watchlistExpiry && $this->watchedItem ? 'updatewatchlist' : 'addwatch';
249        $form->setWrapperLegendMsg( $msg );
250        $submitMsg = $this->watchlistExpiry ? 'confirm-watch-button-expiry' : 'confirm-watch-button';
251        $form->setSubmitTextMsg( $submitMsg );
252        $form->setTokenSalt( 'watch' );
253    }
254
255    /**
256     * Show one of 8 possible success messages.
257     * The messages are:
258     * 1. addedwatchtext
259     * 2. addedwatchtext-talk
260     * 3. addedwatchindefinitelytext
261     * 4. addedwatchindefinitelytext-talk
262     * 5. addedwatchexpirytext
263     * 6. addedwatchexpirytext-talk
264     * 7. addedwatchexpiryhours
265     * 8. addedwatchexpiryhours-talk
266     */
267    public function onSuccess() {
268        $submittedExpiry = $this->getContext()->getRequest()->getText( 'wp' . $this->expiryFormFieldName );
269        $this->getOutput()->addWikiMsg( $this->makeSuccessMessage( $submittedExpiry ) );
270    }
271
272    protected function makeSuccessMessage( string $submittedExpiry ): MessageValue {
273        $msgKey = $this->getTitle()->isTalkPage() ? 'addedwatchtext-talk' : 'addedwatchtext';
274        $params = [];
275        if ( $submittedExpiry ) {
276            // We can't use $this->watchedItem to get the expiry because it's not been saved at this
277            // point in the request and so its values are those from before saving.
278            $expiry = ExpiryDef::normalizeExpiry( $submittedExpiry, TS_ISO_8601 );
279
280            // If the expiry label isn't one of the predefined ones in the dropdown, calculate 'x days'.
281            $expiryDays = WatchedItem::calculateExpiryInDays( $expiry );
282            $defaultLabels = static::getExpiryOptionsFromMessage( $this->getContext() );
283            $localizedExpiry = array_search( $submittedExpiry, $defaultLabels );
284
285            // Determine which message to use, depending on whether this is a talk page or not
286            // and whether an expiry was selected.
287            $isTalk = $this->getTitle()->isTalkPage();
288            if ( wfIsInfinity( $expiry ) ) {
289                $msgKey = $isTalk ? 'addedwatchindefinitelytext-talk' : 'addedwatchindefinitelytext';
290            } elseif ( $expiryDays >= 1 ) {
291                $msgKey = $isTalk ? 'addedwatchexpirytext-talk' : 'addedwatchexpirytext';
292                $params[] = $localizedExpiry === false
293                    ? $this->getContext()->msg( 'days', $expiryDays )->text()
294                    : $localizedExpiry;
295            } else {
296                // Less than one day.
297                $msgKey = $isTalk ? 'addedwatchexpiryhours-talk' : 'addedwatchexpiryhours';
298            }
299        }
300        return MessageValue::new( $msgKey )->params( $this->getTitle()->getPrefixedText(), ...$params );
301    }
302
303    /** @inheritDoc */
304    public function doesWrites() {
305        return true;
306    }
307}
308
309/** @deprecated class alias since 1.44 */
310class_alias( WatchAction::class, 'WatchAction' );