Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 1322
0.00% covered (danger)
0.00%
0 / 70
CRAP
0.00% covered (danger)
0.00%
0 / 1
LqtView
0.00% covered (danger)
0.00%
0 / 1322
0.00% covered (danger)
0.00%
0 / 70
75350
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 methodAppliesToThread
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 methodApplies
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 permalinkUrl
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 permalinkData
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 permalinkUrlWithQuery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 permalink
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 linkInContextData
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 linkInContext
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 linkInContextFullURL
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 linkInContextCanonicalURL
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 diffQuery
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 diffPermalinkURL
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 diffPermalink
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 talkpageLink
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 talkpageLinkData
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
90
 talkpageUrl
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 perpetuate
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 showReplyProtectedNotice
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 doInlineEditForm
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
30
 fixFauxRequestSession
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getInlineEditForm
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 showNewThreadForm
0.00% covered (danger)
0.00%
0 / 79
0.00% covered (danger)
0.00%
0 / 1
156
 showReplyForm
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
90
 showPostEditingForm
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 1
132
 showSummarizeForm
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
56
 checkNonce
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 consumeNonce
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getSubjectEditor
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getSignatureEditor
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 replyMetadataUpdates
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 summarizeMetadataUpdates
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 editMetadataUpdates
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
72
 newPostMetadataUpdates
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 newThreadTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newSummaryTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newReplyTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 scratchTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 threadCommands
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
110
 threadMajorCommands
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
56
 topLevelThreadCommands
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
72
 showPostBody
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 showThreadToolbar
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
12
 listItemsForCommands
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 contentForCommand
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 showThreadBody
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
72
 threadSignature
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 threadInfoPanel
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
42
 showThreadHeading
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 postDivClass
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 anchorName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showMovedThread
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 showDeletedThread
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 showSingleThread
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 getMustShowThreads
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 getShowMore
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 getShowReplies
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 threadContainsRepliesWithContent
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
72
 showThreadReplies
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
156
 showThread
0.00% covered (danger)
0.00%
0 / 142
0.00% covered (danger)
0.00%
0 / 1
992
 showReplyBox
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 threadDivClass
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getSummary
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
12
 getSignature
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getUserSignature
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 parseSignature
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 signaturePST
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 customizeNavigation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 show
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formatSubject
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @file
4 * @ingroup LiquidThreads
5 * @author David McCabe <davemccabe@gmail.com>
6 * @license GPL-2.0-or-later
7 */
8
9use MediaWiki\Context\RequestContext;
10use MediaWiki\EditPage\EditPage;
11use MediaWiki\Extension\LiquidThreads\Hooks;
12use MediaWiki\Html\Html;
13use MediaWiki\Linker\Linker;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Output\OutputPage;
16use MediaWiki\Parser\Parser;
17use MediaWiki\Parser\Sanitizer;
18use MediaWiki\Request\FauxRequest;
19use MediaWiki\Request\WebRequest;
20use MediaWiki\SpecialPage\SpecialPage;
21use MediaWiki\Title\Title;
22use MediaWiki\User\User;
23use Wikimedia\IPUtils;
24
25class LqtView {
26    /**
27     * @var Article|null
28     */
29    public $article;
30
31    /**
32     * @var OutputPage
33     */
34    public $output;
35
36    /**
37     * @var User
38     */
39    public $user;
40
41    /**
42     * @var Title|null
43     */
44    public $title;
45
46    /**
47     * @var WebRequest
48     */
49    public $request;
50
51    /** @var int h1, h2, h3, etc. */
52    protected $headerLevel = 2;
53    /** @var array */
54    protected $user_colors;
55    /** @var int */
56    protected $user_color_index;
57
58    /** @var int */
59    public $threadNestingLevel = 0;
60
61    public function __construct( $output, $article, $title, $user, $request ) {
62        $this->article = $article;
63        $this->output = $output;
64        $this->user = $user;
65        $this->title = $title;
66        $this->request = $request;
67        $this->user_colors = [];
68        $this->user_color_index = 1;
69    }
70
71    /*************************
72     * (1) linking to liquidthreads pages and
73     * (2) figuring out what page you're on and what you need to do.
74     */
75
76    /**
77     * @param string $method
78     * @param Thread $thread
79     * @return bool
80     */
81    public function methodAppliesToThread( $method, Thread $thread ) {
82        return $this->request->getVal( 'lqt_method' ) == $method &&
83            $this->request->getVal( 'lqt_operand' ) == $thread->id();
84    }
85
86    /**
87     * @param string $method
88     * @return bool
89     */
90    public function methodApplies( $method ) {
91        return $this->request->getVal( 'lqt_method' ) == $method;
92    }
93
94    public static function permalinkUrl(
95        Thread $thread,
96        $method = null,
97        $operand = null,
98        array $uquery = [],
99        $relative = true
100    ) {
101        [ $title, $query ] = self::permalinkData( $thread, $method, $operand );
102
103        $query = array_merge( $query, $uquery );
104
105        $queryString = wfArrayToCgi( $query );
106
107        if ( $relative ) {
108            return $title->getLocalUrl( $queryString );
109        } else {
110            return $title->getCanonicalUrl( $queryString );
111        }
112    }
113
114    /**
115     * Gets an array of (title, query-parameters) for a permalink
116     * @param Thread $thread
117     * @param string|null $method
118     * @param string|null $operand
119     * @return array
120     */
121    public static function permalinkData( Thread $thread, $method = null, $operand = null ) {
122        $query = [];
123
124        if ( $method ) {
125            $query['lqt_method'] = $method;
126        }
127        if ( $operand ) {
128            $query['lqt_operand'] = $operand;
129        }
130
131        $root = $thread->root();
132        if ( !$root ) {
133            // XXX Perhaps this should be replaced with a checked exception
134            throw new RuntimeException( "No root in " . __METHOD__ );
135        }
136
137        return [ $root->getTitle(), $query ];
138    }
139
140    /**
141     * This is used for action=history so that the history tab works, which is
142     * why we break the lqt_method paradigm.
143     *
144     * @param Thread $thread
145     * @param string[] $query
146     * @param bool $relative
147     * @return string
148     */
149    public static function permalinkUrlWithQuery( Thread $thread, array $query, $relative = true ) {
150        return self::permalinkUrl( $thread, null, null, $query, $relative );
151    }
152
153    public static function permalink( Thread $thread, $text = null, $method = null, $operand = null,
154                    $linker = null, $attribs = [], $uquery = [] ) {
155        [ $title, $query ] = self::permalinkData( $thread, $method, $operand );
156
157        $query = array_merge( $query, $uquery );
158
159        return MediaWikiServices::getInstance()->getLinkRenderer()->makeLink(
160            $title,
161            $text,
162            $attribs,
163            $query
164        );
165    }
166
167    /**
168     * @param Thread $thread
169     * @param string $contextType
170     * @throws Exception
171     * @return array
172     */
173    public static function linkInContextData( Thread $thread, $contextType = 'page' ) {
174        $query = [];
175
176        if ( $contextType == 'page' ) {
177            $title = clone $thread->getTitle();
178
179            $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
180            $offset = $thread->topmostThread()->sortkey();
181            $offset = (int)wfTimestamp( TS_UNIX, $offset ) + 1;
182            $offset = $dbr->timestamp( $offset );
183            $query['offset'] = $offset;
184        } else {
185            $title = clone $thread->title();
186        }
187
188        $query['lqt_mustshow'] = $thread->id();
189
190        $title->setFragment( '#' . $thread->getAnchorName() );
191
192        return [ $title, $query ];
193    }
194
195    /**
196     * @param thread $thread
197     * @param string $contextType
198     * @param string|null $text
199     * @return mixed
200     */
201    public static function linkInContext( Thread $thread, $contextType = 'page', $text = null ) {
202        [ $title, $query ] = self::linkInContextData( $thread, $contextType );
203
204        if ( $text === null ) {
205            $text = Threads::stripHTML( $thread->formattedSubject() );
206        }
207
208        return MediaWikiServices::getInstance()->getLinkRenderer()->makeLink(
209            $title,
210            $text,
211            [],
212            $query
213        );
214    }
215
216    public static function linkInContextFullURL( Thread $thread, $contextType = 'page' ) {
217        [ $title, $query ] = self::linkInContextData( $thread, $contextType );
218
219        return $title->getFullURL( $query );
220    }
221
222    public static function linkInContextCanonicalURL( Thread $thread, $contextType = 'page' ) {
223        [ $title, $query ] = self::linkInContextData( $thread, $contextType );
224
225        return $title->getCanonicalURL( $query );
226    }
227
228    /**
229     * @param Thread $thread
230     * @param ThreadRevision $revision
231     * @return array
232     */
233    public static function diffQuery( Thread $thread, ThreadRevision $revision ) {
234        $changed_thread = $revision->getChangeObject();
235        $curr_rev_id = $changed_thread->rootRevision();
236
237        $revLookup = MediaWikiServices::getInstance()->getRevisionLookup();
238        $curr_rev_record = $revLookup->getRevisionById( $curr_rev_id );
239
240        $oldid = '';
241        if ( $curr_rev_record ) {
242            $prev_rev_record = $revLookup->getPreviousRevision( $curr_rev_record );
243            $oldid = $prev_rev_record ? $prev_rev_record->getId() : '';
244        }
245
246        $query = [
247            'lqt_method' => 'diff',
248            'diff' => $curr_rev_id,
249            'oldid' => $oldid
250        ];
251
252        return $query;
253    }
254
255    public static function diffPermalinkURL( Thread $thread, ThreadRevision $revision ) {
256        $query = self::diffQuery( $thread, $revision );
257        return wfExpandUrl( self::permalinkUrl( $thread, null, null, $query ), PROTO_RELATIVE );
258    }
259
260    public static function diffPermalink( Thread $thread, $text, ThreadRevision $revision ) {
261        $query = self::diffQuery( $thread, $revision );
262        return self::permalink( $thread, $text, null, null, null, [], $query );
263    }
264
265    public static function talkpageLink( $title, $text = null, $method = null, $operand = null,
266        $includeFragment = true, $attribs = [],
267        $options = [], $perpetuateOffset = true
268    ) {
269        [ $title, $query ] = self::talkpageLinkData(
270            $title, $method, $operand,
271            $includeFragment,
272            $perpetuateOffset
273        );
274
275        $linkRenderer = MediaWikiServices::getInstance()
276            ->getLinkRendererFactory()
277            ->createFromLegacyOptions( $options );
278        return $linkRenderer->makeLink( $title, $text, $attribs, $query );
279    }
280
281    /**
282     * @param Title $title
283     * @param string|null $method
284     * @param Thread|null $operand
285     * @param bool $includeFragment
286     * @param bool|WebRequest $perpetuateOffset
287     * @return array
288     */
289    public static function talkpageLinkData( $title, $method = null, $operand = null,
290        $includeFragment = true, $perpetuateOffset = true
291    ) {
292        global $wgRequest;
293        $query = [];
294
295        if ( $method ) {
296            $query['lqt_method'] = $method;
297        }
298
299        if ( $operand ) {
300            $query['lqt_operand'] = $operand->id();
301        }
302
303        $oldid = $wgRequest->getVal( 'oldid', null );
304
305        if ( $oldid !== null ) {
306            // this is an immensely ugly hack to make editing old revisions work.
307            $query['oldid'] = $oldid;
308        }
309
310        $request = $perpetuateOffset;
311        if ( $request === true ) {
312            global $wgRequest;
313            $request = $wgRequest;
314        }
315
316        if ( $perpetuateOffset ) {
317            $offset = $request->getVal( 'offset' );
318
319            if ( $offset ) {
320                $query['offset'] = $offset;
321            }
322        }
323
324        // Add fragment if appropriate.
325        if ( $operand && $includeFragment ) {
326            $title->setFragment( $operand->getAnchorName() );
327        }
328
329        return [ $title, $query ];
330    }
331
332    /**
333     * If you want $perpetuateOffset to perpetuate from a specific request,
334     * pass that instead of true
335     * @param Title $title
336     * @param string|null $method
337     * @param Thread|null $operand
338     * @param bool $includeFragment
339     * @param bool|WebRequest $perpetuateOffset
340     * @return string
341     */
342    public static function talkpageUrl( $title, $method = null, $operand = null,
343        $includeFragment = true, $perpetuateOffset = true
344    ) {
345        [ $title, $query ] =
346            self::talkpageLinkData( $title, $method, $operand, $includeFragment,
347                        $perpetuateOffset );
348
349        return $title->getLinkUrl( $query );
350    }
351
352    /*************************************************************
353     * Editing methods (here be dragons)                          *
354     * Forget dragons: This section distorts the rest of the code *
355     * like a star bending spacetime around itself.                  *
356     */
357
358    /**
359     * Return an HTML form element whose value is gotten from the request.
360     * @todo Figure out a clean way to expand this to other forms.
361     * @param string $name
362     * @param string $as
363     * @return string
364     */
365    public function perpetuate( $name, $as = 'hidden' ) {
366        $value = $this->request->getVal( $name, '' );
367        if ( $as == 'hidden' ) {
368            return Html::hidden( $name, $value );
369        }
370
371        return '';
372    }
373
374    /**
375     * @param Thread $thread
376     */
377    public function showReplyProtectedNotice( Thread $thread ) {
378        $log_url = SpecialPage::getTitleFor( 'Log' )->getLocalURL(
379            "type=protect&user=&page={$thread->title()->getPrefixedURL()}" );
380        $link = '<a href="' . $log_url . '">' .
381            wfMessage( 'lqt_protectedfromreply_link' )->escaped() . '</a>';
382        $this->output->addHTML( '<p>' . wfMessage( 'lqt_protectedfromreply' )
383            ->rawParams( $link )->escaped() );
384    }
385
386    public function doInlineEditForm() {
387        $method = $this->request->getVal( 'lqt_method' );
388        $operand = $this->request->getVal( 'lqt_operand' );
389
390        $thread = Threads::withId( intval( $operand ) );
391
392        // Yuck.
393        global $wgOut, $wgRequest, $wgTitle;
394        $oldOut = $wgOut;
395        $oldRequest = $wgRequest;
396        $oldTitle = $wgTitle;
397        // And override the main context too... (T143889)
398        $context = RequestContext::getMain();
399        $oldCOut = $context->getOutput();
400        $oldCRequest = $context->getRequest();
401        $oldCTitle = $context->getTitle();
402        $context->setOutput( $this->output );
403        $context->setRequest( $this->request );
404        $context->setTitle( $this->title );
405        $wgOut = $this->output;
406        $wgRequest = $this->request;
407        $wgTitle = $this->title;
408
409        $hookResult = MediaWikiServices::getInstance()->getHookContainer()->run( 'LiquidThreadsDoInlineEditForm',
410                    [
411                        $thread,
412                        $this->request,
413                        &$this->output
414                    ] );
415
416        if ( !$hookResult ) {
417            // Handled by a hook.
418        } elseif ( $method == 'reply' ) {
419            $this->showReplyForm( $thread );
420        } elseif ( $method == 'talkpage_new_thread' ) {
421            $this->showNewThreadForm( $this->article );
422        } elseif ( $method == 'edit' ) {
423            $this->showPostEditingForm( $thread );
424        } else {
425            throw new LogicException( "Invalid thread method $method" );
426        }
427
428        $wgOut = $oldOut;
429        $wgRequest = $oldRequest;
430        $wgTitle = $oldTitle;
431        $context->setOutput( $oldCOut );
432        $context->setRequest( $oldCRequest );
433        $context->setTitle( $oldCTitle );
434
435        $this->output->setArticleBodyOnly( true );
436    }
437
438    /**
439     * Workaround for bug 27887 caused by r82686
440     * @param FauxRequest $request FauxRequest object to have session data injected into.
441     */
442    public static function fixFauxRequestSession( $request ) {
443        // This is sometimes called before session_start (bug 28826).
444        if ( !isset( $_SESSION ) ) {
445            return;
446        }
447
448        foreach ( $_SESSION as $k => $v ) {
449            $request->setSessionData( $k, $v );
450        }
451    }
452
453    /**
454     * @param Article|null $talkpage
455     * @param string $method
456     * @param int|null $operand
457     * @param User $user
458     * @return string
459     * @throws Exception
460     */
461    public static function getInlineEditForm( $talkpage, $method, $operand, User $user ) {
462        $req = new RequestContext;
463        $output = $req->getOutput();
464        $request = new FauxRequest( [] );
465
466        // Workaround for loss of session data when using FauxRequest
467        global $wgRequest;
468        self::fixFauxRequestSession( $request );
469
470        $title = null;
471
472        if ( $talkpage ) {
473            $title = $talkpage->getTitle();
474        } elseif ( $operand ) {
475            $thread = Threads::withId( $operand );
476            if ( $thread ) {
477                $talkpage = $thread->article();
478                $title = $talkpage->getTitle();
479            } else {
480                throw new LogicException( "Cannot get title" );
481            }
482        }
483
484        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
485        $output->setTitle( $title );
486        $request->setVal( 'lqt_method', $method );
487        $request->setVal( 'lqt_operand', $operand );
488
489        $view = new LqtView( $output, $talkpage, $title, $user, $request );
490
491        $view->doInlineEditForm();
492
493        foreach ( $request->getSessionArray() ?? [] as $k => $v ) {
494            $wgRequest->setSessionData( $k, $v );
495        }
496
497        return $output->getHTML();
498    }
499
500    /**
501     * @param Article $talkpage
502     */
503    public function showNewThreadForm( $talkpage ) {
504        $submitted_nonce = $this->request->getVal( 'lqt_nonce' );
505        if ( $this->request->wasPosted() && !$this->checkNonce( $submitted_nonce ) ) {
506            return;
507        }
508
509        if ( Thread::canUserPost( $this->user, $this->article ) !== true ) {
510            $this->output->addWikiMsg( 'lqt-protected-newthread' );
511            return;
512        }
513        $subject = $this->request->getVal( 'lqt_subject_field' ) ?? false;
514
515        $t = null;
516
517        $subjectOk = Thread::validateSubject(
518            $subject,
519            $this->user,
520            $t,
521            null,
522            $this->article
523        );
524        if ( !$subjectOk ) {
525            try {
526                $t = $this->newThreadTitle( $subject );
527            } catch ( Exception $excep ) {
528                $t = $this->scratchTitle();
529            }
530        }
531
532        $html = Xml::openElement( 'div',
533            [ 'class' => 'lqt-edit-form lqt-new-thread' ] );
534        $this->output->addHTML( $html );
535
536        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
537        $article = new Article( $t, 0 );
538
539        Hooks::$editTalkpage = $talkpage;
540        Hooks::$editArticle = $article;
541        Hooks::$editThread = null;
542        Hooks::$editType = 'new';
543        Hooks::$editAppliesTo = null;
544
545        $e = new EditPage( $article );
546        $e->setContextTitle( $article->getTitle() );
547        $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
548        $hookContainer->run( 'LiquidThreadsShowNewThreadForm', [ &$e, $talkpage ] );
549
550        global $wgRequest;
551        // Quietly force a preview if no subject has been specified.
552        if ( !$subjectOk ) {
553            // Dirty hack to prevent saving from going ahead
554            $wgRequest->setVal( 'wpPreview', true );
555
556            if ( $this->request->wasPosted() ) {
557                if ( !$subject ) {
558                    $msg = 'lqt_empty_subject';
559                } else {
560                    $msg = 'lqt_invalid_subject';
561                }
562
563                $e->editFormPageTop .=
564                    Xml::tags( 'div', [ 'class' => 'error' ],
565                        wfMessage( $msg )->parseAsBlock() );
566            }
567        }
568
569        $e->suppressIntro = true;
570        $e->editFormTextBeforeContent .=
571            $this->perpetuate( 'lqt_method', 'hidden' ) .
572            $this->perpetuate( 'lqt_operand', 'hidden' ) .
573            Html::hidden( 'lqt_nonce', MWCryptRand::generateHex( 32 ) );
574
575        $e->mShowSummaryField = false;
576
577        $summary = wfMessage( 'lqt-newpost-summary', $subject )->inContentLanguage()->text();
578        $wgRequest->setVal( 'wpSummary', $summary );
579
580        [ $signatureEditor, $signatureHTML ] = $this->getSignatureEditor( $this->user );
581
582        $e->editFormTextAfterContent .=
583            $signatureEditor;
584        $e->previewTextAfterContent .=
585            Xml::tags( 'p', null, $signatureHTML );
586
587        $e->editFormTextBeforeContent .= $this->getSubjectEditor( '', $subject );
588
589        $hookContainer->run( 'LiquidThreadsAfterShowNewThreadForm', [ &$e, $talkpage ] );
590
591        $e->edit();
592
593        if ( $e->didSave ) {
594            $signature = $this->request->getVal( 'wpLqtSignature', null );
595
596            $info =
597                [
598                    'talkpage' => $talkpage,
599                    'text' => $e->textbox1,
600                    'summary' => $e->summary,
601                    'signature' => $signature,
602                    'root' => $article,
603                    'subject' => $subject,
604                ];
605
606            $hookContainer->run( 'LiquidThreadsSaveNewThread',
607                    [ &$info, &$e, &$talkpage ] );
608
609            $thread = self::newPostMetadataUpdates( $this->user, $info );
610            self::consumeNonce( $submitted_nonce );
611        }
612
613        if ( $this->output->getRedirect() != '' ) {
614            $redirectTitle = clone $talkpage->getTitle();
615            if ( !empty( $thread ) ) {
616                $redirectTitle->setFragment( '#' . $this->anchorName( $thread ) );
617            }
618            $this->output->redirect( $this->title->getLocalURL() );
619        }
620
621        $this->output->addHTML( '</div>' );
622    }
623
624    /**
625     * @param Thread $thread
626     */
627    public function showReplyForm( Thread $thread ) {
628        global $wgRequest;
629
630        $submitted_nonce = $this->request->getVal( 'lqt_nonce' );
631        if ( $this->request->wasPosted() && !$this->checkNonce( $submitted_nonce ) ) {
632            return;
633        }
634
635        $perm_result = $thread->canUserReply( $this->user, 'quick' );
636        if ( $perm_result !== true ) {
637            $this->showReplyProtectedNotice( $thread );
638            return;
639        }
640
641        $html = Xml::openElement( 'div',
642                    [ 'class' => 'lqt-reply-form lqt-edit-form' ] );
643        $this->output->addHTML( $html );
644
645        try {
646            $t = $this->newReplyTitle( null, $thread );
647        } catch ( Exception $excep ) {
648            $t = $this->scratchTitle();
649        }
650
651        $article = new Article( $t, 0 );
652        $talkpage = $thread->article();
653
654        Hooks::$editTalkpage = $talkpage;
655        Hooks::$editArticle = $article;
656        Hooks::$editThread = $thread;
657        Hooks::$editType = 'reply';
658        Hooks::$editAppliesTo = $thread;
659
660        $e = new EditPage( $article );
661        $e->setContextTitle( $article->getTitle() );
662
663        $e->mShowSummaryField = false;
664
665        $reply_subject = $thread->subject();
666        $reply_title = $thread->title()->getPrefixedText();
667        $summary = wfMessage(
668            'lqt-reply-summary',
669            $reply_subject,
670            $reply_title
671        )->inContentLanguage()->text();
672        $wgRequest->setVal( 'wpSummary', $summary );
673
674        // Add an offset so it works if it's on the wrong page.
675        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
676        $offset = wfTimestamp( TS_UNIX, $thread->topmostThread()->sortkey() );
677        $offset++;
678        $offset = $dbr->timestamp( $offset );
679
680        $e->suppressIntro = true;
681        $e->editFormTextBeforeContent .=
682            $this->perpetuate( 'lqt_method', 'hidden' ) .
683            $this->perpetuate( 'lqt_operand', 'hidden' ) .
684            Html::hidden( 'lqt_nonce', MWCryptRand::generateHex( 32 ) ) .
685            Html::hidden( 'offset', $offset ) .
686            Html::hidden( 'wpMinorEdit', '' );
687
688        [ $signatureEditor, $signatureHTML ] = $this->getSignatureEditor( $this->user );
689
690        $e->editFormTextAfterContent .=
691            $signatureEditor;
692        $e->previewTextAfterContent .=
693            Xml::tags( 'p', null, $signatureHTML );
694
695        $wgRequest->setVal( 'wpWatchThis', false );
696
697        $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
698        $hookContainer->run( 'LiquidThreadsShowReplyForm', [ &$e, $thread ] );
699
700        $e->edit();
701
702        if ( $e->didSave ) {
703            $bump = !$this->request->getCheck( 'wpBumpThread' ) ||
704                $this->request->getBool( 'wpBumpThread' );
705            $signature = $this->request->getVal( 'wpLqtSignature', null );
706
707            $info = [
708                    'replyTo' => $thread,
709                    'text' => $e->textbox1,
710                    'summary' => $e->summary,
711                    'bump' => $bump,
712                    'signature' => $signature,
713                    'root' => $article,
714                ];
715
716            $hookContainer->run( 'LiquidThreadsSaveReply',
717                    [ &$info, &$e, &$thread ] );
718
719            $newThread = self::replyMetadataUpdates( $this->user, $info );
720            self::consumeNonce( $submitted_nonce );
721        }
722
723        if ( $this->output->getRedirect() != '' ) {
724            $redirectTitle = clone $talkpage->getTitle();
725            if ( !empty( $newThread ) ) {
726                $redirectTitle->setFragment( '#' . $this->anchorName( $newThread ) );
727            }
728            $this->output->redirect( $this->title->getLocalURL() );
729        }
730
731        $this->output->addHTML( '</div>' );
732    }
733
734    /**
735     * @param Thread $thread
736     */
737    public function showPostEditingForm( Thread $thread ) {
738        $submitted_nonce = $this->request->getVal( 'lqt_nonce' );
739        if ( $this->request->wasPosted() && !$this->checkNonce( $submitted_nonce ) ) {
740            return;
741        }
742
743        $html = Xml::openElement( 'div',
744            [ 'class' => 'lqt-edit-form' ] );
745        $this->output->addHTML( $html );
746
747        $subject = $this->request->getVal( 'lqt_subject_field', '' );
748
749        if ( !$subject ) {
750            $subject = $thread->subject() ?? '';
751        }
752
753        $t = null;
754        $subjectOk = Thread::validateSubject(
755            $subject,
756            $this->user,
757            $t,
758            $thread->superthread(),
759            $this->article
760        );
761        if ( !$subjectOk ) {
762            $subject = false;
763        }
764
765        $article = $thread->root();
766        $talkpage = $thread->article();
767
768        MediaWikiServices::getInstance()->getHookContainer()
769            ->run( 'LiquidThreadsEditFormContent', [ $thread, &$article, $talkpage ] );
770
771        Hooks::$editTalkpage = $talkpage;
772        Hooks::$editArticle = $article;
773        Hooks::$editThread = $thread;
774        Hooks::$editType = 'edit';
775        Hooks::$editAppliesTo = $thread;
776
777        $e = new EditPage( $article );
778        $e->setContextTitle( $article->getTitle() );
779
780        global $wgRequest;
781        // Quietly force a preview if no subject has been specified.
782        if ( !$subjectOk ) {
783            // Dirty hack to prevent saving from going ahead
784            $wgRequest->setVal( 'wpPreview', true );
785
786            if ( $this->request->wasPosted() ) {
787                $e->editFormPageTop .=
788                    Xml::tags( 'div', [ 'class' => 'error' ],
789                        wfMessage( 'lqt_invalid_subject' )->parse() );
790            }
791        }
792
793        // Add an offset so it works if it's on the wrong page.
794        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
795        $offset = wfTimestamp( TS_UNIX, $thread->topmostThread()->sortkey() );
796        $offset++;
797        $offset = $dbr->timestamp( $offset );
798
799        $e->suppressIntro = true;
800        $e->editFormTextBeforeContent .=
801            $this->perpetuate( 'lqt_method', 'hidden' ) .
802            $this->perpetuate( 'lqt_operand', 'hidden' ) .
803            Html::hidden( 'lqt_nonce', MWCryptRand::generateHex( 32 ) ) .
804            Html::hidden( 'offset', $offset );
805
806        [ $signatureEditor, $signatureHTML ] = $this->getSignatureEditor( $thread );
807
808        $e->editFormTextAfterContent .=
809            $signatureEditor;
810        $e->previewTextAfterContent .=
811            Xml::tags( 'p', null, $signatureHTML );
812
813        if ( $thread->isTopmostThread() ) {
814            $e->editFormTextBeforeContent .=
815                $this->getSubjectEditor( $thread->subject(), $subject );
816        }
817
818        $e->edit();
819
820        if ( $e->didSave ) {
821            $bump = !$this->request->getCheck( 'wpBumpThread' ) ||
822                $this->request->getBool( 'wpBumpThread' );
823            $signature = $this->request->getVal( 'wpLqtSignature', null );
824
825            self::editMetadataUpdates(
826                [
827                    'thread' => $thread,
828                    'text' => $e->textbox1,
829                    'summary' => $e->summary,
830                    'bump' => $bump,
831                    'subject' => $subject,
832                    'signature' => $signature,
833                    'root' => $article,
834                ]
835            );
836            self::consumeNonce( $submitted_nonce );
837        }
838
839        if ( $this->output->getRedirect() != '' ) {
840            $redirectTitle = clone $talkpage->getTitle();
841            $redirectTitle->setFragment( '#' . $this->anchorName( $thread ) );
842            $this->output->redirect( $this->title->getLocalURL() );
843        }
844
845        $this->output->addHTML( '</div>' );
846    }
847
848    /**
849     * @param Thread $thread
850     */
851    public function showSummarizeForm( Thread $thread ) {
852        $submitted_nonce = $this->request->getVal( 'lqt_nonce' );
853        if ( $this->request->wasPosted() && !$this->checkNonce( $submitted_nonce ) ) {
854            return;
855        }
856
857        if ( $thread->summary() ) {
858            $article = $thread->summary();
859            $summarizeMsg = 'lqt-update-summary-intro';
860        } else {
861            $t = $this->newSummaryTitle( $thread );
862            $article = new Article( $t, 0 );
863            $summarizeMsg = 'lqt-summarize-intro';
864        }
865
866        $html = Xml::openElement( 'div',
867            [ 'class' => 'lqt-edit-form lqt-summarize-form' ] );
868        $this->output->addHTML( $html );
869
870        $this->output->addWikiMsg( $summarizeMsg );
871
872        $talkpage = $thread->article();
873
874        Hooks::$editTalkpage = $talkpage;
875        Hooks::$editArticle = $article;
876        Hooks::$editThread = $thread;
877        Hooks::$editType = 'summarize';
878        Hooks::$editAppliesTo = $thread;
879
880        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
881        $e = new EditPage( $article );
882        $e->setContextTitle( $article->getTitle() );
883
884        // Add an offset so it works if it's on the wrong page.
885        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
886        $offset = wfTimestamp( TS_UNIX, $thread->topmostThread()->sortkey() );
887        $offset++;
888        $offset = $dbr->timestamp( $offset );
889
890        $e->suppressIntro = true;
891        $e->editFormTextBeforeContent .=
892            $this->perpetuate( 'lqt_method', 'hidden' ) .
893            $this->perpetuate( 'lqt_operand', 'hidden' ) .
894            Html::hidden( 'lqt_nonce', MWCryptRand::generateHex( 32 ) ) .
895            Html::hidden( 'offset', $offset );
896
897        $e->edit();
898
899        if ( $e->didSave ) {
900            $bump = !$this->request->getCheck( 'wpBumpThread' ) ||
901                $this->request->getBool( 'wpBumpThread' );
902
903            self::summarizeMetadataUpdates(
904                [
905                    'thread' => $thread,
906                    'article' => $article,
907                    'summary' => $e->summary,
908                    'bump' => $bump,
909                ]
910            );
911            self::consumeNonce( $submitted_nonce );
912        }
913
914        if ( $this->output->getRedirect() != '' ) {
915            $redirectTitle = clone $talkpage->getTitle();
916            $redirectTitle->setFragment( '#' . $this->anchorName( $thread ) );
917            $this->output->redirect( $this->title->getLocalURL() );
918        }
919
920        $this->output->addHTML( '</div>' );
921    }
922
923    /**
924     * Check a nonce token on HTTP POST during a thread submission
925     *
926     * @param string $token
927     * @return bool Whether the user nonce token is unused or no token was provided
928     */
929    public function checkNonce( $token ) {
930        if ( !$token ) {
931            return true;
932        }
933
934        // Primary data-center cluster cache
935        $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
936        $nonce_key = $cache->makeKey( 'lqt-nonce', $token, $this->user->getName() );
937
938        if ( $cache->get( $nonce_key ) ) {
939            $this->output->redirect( $this->article->getTitle()->getLocalURL() );
940            return false;
941        }
942
943        return true;
944    }
945
946    /**
947     * Consume a nonce token on HTTP POST during a thread submission
948     *
949     * @param string $token
950     * @return bool Whether the user nonce token was acquired or no token was provided
951     */
952    public function consumeNonce( $token ) {
953        if ( !$token ) {
954            return true;
955        }
956
957        // Primary data-center cluster cache
958        $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
959        $nonce_key = $cache->makeKey( 'lqt-nonce', $token, $this->user->getName() );
960
961        return $cache->add( $nonce_key, 1, $cache::TTL_HOUR );
962    }
963
964    /**
965     * @param string $db_subject
966     * @param string|false $subject
967     * @return string HTML
968     */
969    public function getSubjectEditor( $db_subject, $subject ) {
970        if ( $subject === false ) {
971            $subject = $db_subject;
972        }
973
974        $subject_label = wfMessage( 'lqt_subject' )->text();
975
976        $attr = [ 'tabindex' => 1 ];
977
978        return Xml::inputLabel( $subject_label, 'lqt_subject_field',
979                'lqt_subject_field', 60, $subject, $attr ) .
980            Xml::element( 'br' );
981    }
982
983    public function getSignatureEditor( $from ) {
984        $signatureText = $this->request->getVal( 'wpLqtSignature', null );
985
986        if ( $signatureText === null ) {
987            if ( $from instanceof User ) {
988                $signatureText = self::getUserSignature( $from );
989            } elseif ( $from instanceof Thread ) {
990                $signatureText = $from->signature() ?? '';
991            } else {
992                $signatureText = '';
993            }
994        }
995
996        $signatureHTML = self::parseSignature( $signatureText );
997
998        // Signature edit box
999        $signaturePreview = Xml::tags(
1000            'span',
1001            [
1002                'class' => 'lqt-signature-preview',
1003                'style' => 'display: none;'
1004            ],
1005            $signatureHTML
1006        );
1007        $signatureEditBox = Xml::input(
1008            'wpLqtSignature', 45, $signatureText,
1009            [ 'class' => 'lqt-signature-edit' ]
1010        );
1011
1012        $signatureEditor = $signaturePreview . $signatureEditBox;
1013
1014        return [ $signatureEditor, $signatureHTML ];
1015    }
1016
1017    public static function replyMetadataUpdates( User $user, $data = [] ) {
1018        $requiredFields = [ 'replyTo', 'root', 'text' ];
1019
1020        foreach ( $requiredFields as $f ) {
1021            if ( !isset( $data[$f] ) ) {
1022                throw new RuntimeException( "Missing required field $f" );
1023            }
1024        }
1025
1026        $signature = $data['signature'] ?? self::getUserSignature( $user );
1027
1028        $summary = $data['summary'] ?? '';
1029
1030        $replyTo = $data['replyTo'];
1031        $root = $data['root'];
1032        $bump = !empty( $data['bump'] );
1033
1034        $subject = $replyTo->subject();
1035        $talkpage = $replyTo->article();
1036
1037        $thread = Thread::create(
1038            $root, $talkpage, $user, $replyTo, Threads::TYPE_NORMAL, $subject,
1039            $summary, $bump, $signature
1040        );
1041
1042        MediaWikiServices::getInstance()->getHookContainer()
1043            ->run( 'LiquidThreadsAfterReplyMetadataUpdates', [ &$thread ] );
1044
1045        return $thread;
1046    }
1047
1048    public static function summarizeMetadataUpdates( $data = [] ) {
1049        $requiredFields = [ 'thread', 'article', 'summary' ];
1050
1051        foreach ( $requiredFields as $f ) {
1052            if ( !isset( $data[$f] ) ) {
1053                throw new RuntimeException( "Missing required field $f" );
1054            }
1055        }
1056
1057        $thread = $data["thread"];
1058        $article = $data["article"];
1059        $summary = $data["summary"];
1060
1061        $bump = $data["bump"] ?? null;
1062
1063        $user = RequestContext::getMain()->getUser(); // Need to inject
1064        $thread->setSummary( $article );
1065        $thread->commitRevision(
1066            Threads::CHANGE_EDITED_SUMMARY, $user, $thread, $summary, $bump );
1067
1068        return $thread;
1069    }
1070
1071    public static function editMetadataUpdates( $data = [] ) {
1072        $requiredFields = [ 'thread', 'text', 'summary' ];
1073
1074        foreach ( $requiredFields as $f ) {
1075            if ( !isset( $data[$f] ) ) {
1076                throw new RuntimeException( "Missing required field $f" );
1077            }
1078        }
1079
1080        $thread = $data['thread'];
1081
1082        // Use a separate type if the content is blanked.
1083        $type = strlen( trim( $data['text'] ) )
1084                ? Threads::CHANGE_EDITED_ROOT
1085                : Threads::CHANGE_ROOT_BLANKED;
1086
1087        if ( isset( $data['signature'] ) ) {
1088            $thread->setSignature( $data['signature'] );
1089        }
1090
1091        $bump = !empty( $data['bump'] );
1092
1093        $user = RequestContext::getMain()->getUser(); // Need to inject
1094        // Add the history entry.
1095        $thread->commitRevision( $type, $user, $thread, $data['summary'], $bump );
1096
1097        // Update subject if applicable.
1098        if ( $thread->isTopmostThread() && !empty( $data['subject'] ) &&
1099                $data['subject'] != $thread->subject() ) {
1100            $thread->setSubject( $data['subject'] );
1101            $thread->commitRevision( Threads::CHANGE_EDITED_SUBJECT,
1102                        $user, $thread, $data['summary'] );
1103        }
1104
1105        return $thread;
1106    }
1107
1108    public static function newPostMetadataUpdates( User $user, $data ) {
1109        $requiredFields = [ 'talkpage', 'root', 'text', 'subject' ];
1110
1111        foreach ( $requiredFields as $f ) {
1112            if ( !isset( $data[$f] ) ) {
1113                throw new RuntimeException( "Missing required field $f" );
1114            }
1115        }
1116
1117        $signature = $data['signature'] ?? self::getUserSignature( $user );
1118
1119        $summary = $data['summary'] ?? '';
1120
1121        $talkpage = $data['talkpage'];
1122        '@phan-var Article $talkpage';
1123        $root = $data['root'];
1124        $subject = $data['subject'];
1125
1126        $thread = Thread::create(
1127            $root, $talkpage, $user, null,
1128            Threads::TYPE_NORMAL, $subject,
1129            $summary, null, $signature
1130        );
1131
1132        MediaWikiServices::getInstance()->getHookContainer()
1133            ->run( 'LiquidThreadsAfterNewPostMetadataUpdates', [ &$thread ] );
1134
1135        return $thread;
1136    }
1137
1138    /**
1139     * @param string $subject
1140     * @return Title
1141     */
1142    public function newThreadTitle( $subject ) {
1143        return Threads::newThreadTitle( $subject, $this->article );
1144    }
1145
1146    /**
1147     * @param Thread $thread
1148     * @return Title
1149     */
1150    public function newSummaryTitle( Thread $thread ) {
1151        return Threads::newSummaryTitle( $thread );
1152    }
1153
1154    /**
1155     * @param mixed $unused
1156     * @param Thread $thread
1157     * @return Title
1158     */
1159    public function newReplyTitle( $unused, Thread $thread ) {
1160        return Threads::newReplyTitle( $thread, $this->user );
1161    }
1162
1163    /**
1164     * @return Title
1165     */
1166    public function scratchTitle() {
1167        return Title::makeTitle( NS_LQT_THREAD, MWCryptRand::generateHex( 32 ) );
1168    }
1169
1170    /**
1171     * @param Thread $thread
1172     * @return array Example return value:
1173     *     array (
1174     *         edit => array( 'label'     => 'Edit',
1175     *                     'href'      => 'http...',
1176     *                     'enabled' => false ),
1177     *         reply => array( 'label'      => 'Reply',
1178     *                     'href'      => 'http...',
1179     *                     'enabled' => true )
1180     *     )
1181     */
1182    public function threadCommands( Thread $thread ) {
1183        $commands = [];
1184        $isLqtPage = LqtDispatch::isLqtPage( $thread->getTitle() );
1185
1186        $history_url = self::permalinkUrlWithQuery( $thread, [ 'action' => 'history' ] );
1187        $commands['history'] = [
1188            'label' => wfMessage( 'history_short' )->parse(),
1189            'href' => $history_url,
1190            'enabled' => true
1191        ];
1192
1193        if ( $thread->isHistorical() ) {
1194            return [];
1195        }
1196        $services = MediaWikiServices::getInstance();
1197        $user_can_edit = $services->getPermissionManager()
1198            ->quickUserCan( 'edit', $this->user, $thread->root()->getTitle() );
1199        $editMsg = $user_can_edit ? 'edit' : 'viewsource';
1200
1201        if ( $isLqtPage ) {
1202            $commands['edit'] = [
1203                'label' => wfMessage( $editMsg )->parse(),
1204                'href' => $this->talkpageUrl(
1205                    $this->title,
1206                    'edit', $thread,
1207                    true, /* include fragment */
1208                    $this->request
1209                ),
1210                'enabled' => true
1211            ];
1212        }
1213
1214        if ( $this->user->isAllowed( 'delete' ) ) {
1215            $delete_url = $thread->title()->getLocalURL( 'action=delete' );
1216            $deleteMsg = $thread->type() == Threads::TYPE_DELETED ? 'lqt_undelete' : 'delete';
1217
1218            $commands['delete'] = [
1219                'label' => wfMessage( $deleteMsg )->parse(),
1220                'href' => $delete_url,
1221                'enabled' => true
1222            ];
1223        }
1224
1225        if ( $isLqtPage ) {
1226            if ( !$thread->isTopmostThread() && $this->user->isAllowed( 'lqt-split' ) ) {
1227                $splitUrl = SpecialPage::getTitleFor( 'SplitThread',
1228                    $thread->title()->getPrefixedText() )->getLocalURL();
1229                $commands['split'] = [
1230                    'label' => wfMessage( 'lqt-thread-split' )->parse(),
1231                    'href' => $splitUrl,
1232                    'enabled' => true
1233                ];
1234            }
1235
1236            if ( $this->user->isAllowed( 'lqt-merge' ) ) {
1237                $mergeParams = $_GET;
1238                $mergeParams['lqt_merge_from'] = $thread->id();
1239
1240                unset( $mergeParams['title'] );
1241
1242                $mergeUrl = $this->title->getLocalURL( wfArrayToCgi( $mergeParams ) );
1243                $label = wfMessage( 'lqt-thread-merge' )->parse();
1244
1245                $commands['merge'] = [
1246                    'label' => $label,
1247                    'href' => $mergeUrl,
1248                    'enabled' => true
1249                ];
1250            }
1251        }
1252
1253        $commands['link'] = [
1254            'label' => wfMessage( 'lqt_permalink' )->parse(),
1255            'href' => $thread->title()->getLocalURL(),
1256            'enabled' => true,
1257            'showlabel' => true,
1258            'tooltip' => wfMessage( 'lqt_permalink' )->parse()
1259        ];
1260
1261        $services->getHookContainer()->run( 'LiquidThreadsThreadCommands', [ $thread, &$commands ] );
1262
1263        return $commands;
1264    }
1265
1266    /**
1267     * Commands for the bottom.
1268     * @param Thread $thread
1269     * @return array[]
1270     */
1271    public function threadMajorCommands( Thread $thread ) {
1272        $isLqtPage = LqtDispatch::isLqtPage( $thread->getTitle() );
1273
1274        if ( $thread->isHistorical() ) {
1275            // No links for historical threads.
1276            $history_url = self::permalinkUrlWithQuery( $thread,
1277                    [ 'action' => 'history' ] );
1278            $commands = [];
1279
1280            $commands['history'] = [
1281                'label' => wfMessage( 'history_short' )->parse(),
1282                'href' => $history_url,
1283                'enabled' => true
1284            ];
1285
1286            return $commands;
1287        }
1288
1289        $commands = [];
1290
1291        if ( $isLqtPage ) {
1292            if ( $this->user->isAllowed( 'lqt-merge' ) &&
1293                $this->request->getCheck( 'lqt_merge_from' )
1294            ) {
1295                $srcThread = Threads::withId( $this->request->getInt( 'lqt_merge_from' ) );
1296                $par = $srcThread->title()->getPrefixedText();
1297                $mergeTitle = SpecialPage::getTitleFor( 'MergeThread', $par );
1298                $mergeUrl = $mergeTitle->getLocalURL( 'dest=' . $thread->id() );
1299                $label = wfMessage( 'lqt-thread-merge-to' )->parse();
1300
1301                $commands['merge-to'] = [
1302                    'label' => $label,
1303                    'href' => $mergeUrl,
1304                    'enabled' => true,
1305                    'tooltip' => $label
1306                ];
1307            }
1308
1309            if ( $thread->canUserReply( $this->user, 'quick' ) === true ) {
1310                $commands['reply'] = [
1311                    'label' => wfMessage( 'lqt_reply' )->parse(),
1312                    'href' => $this->talkpageUrl( $this->title, 'reply', $thread,
1313                        true /* include fragment */, $this->request ),
1314                    'enabled' => true,
1315                    'showlabel' => 1,
1316                    'tooltip' => wfMessage( 'lqt_reply' )->parse(),
1317                    'icon' => 'reply.png',
1318                ];
1319            }
1320        }
1321
1322        // Parent post link
1323        if ( !$thread->isTopmostThread() ) {
1324            $parent = $thread->superthread();
1325            $anchor = $parent->getAnchorName();
1326
1327            $commands['parent'] = [
1328                'label' => wfMessage( 'lqt-parent' )->parse(),
1329                'href' => '#' . $anchor,
1330                'enabled' => true,
1331                'showlabel' => 1,
1332            ];
1333        }
1334
1335        MediaWikiServices::getInstance()->getHookContainer()
1336            ->run( 'LiquidThreadsThreadMajorCommands', [ $thread, &$commands ] );
1337
1338        return $commands;
1339    }
1340
1341    /**
1342     * @param Thread $thread
1343     * @return array
1344     */
1345    public function topLevelThreadCommands( Thread $thread ) {
1346        $commands = [];
1347
1348        $commands['history'] = [
1349            'label' => wfMessage( 'history_short' )->parse(),
1350            'href' => self::permalinkUrl( $thread, 'thread_history' ),
1351            'enabled' => true
1352        ];
1353
1354        if ( $this->user->isAllowed( 'move' ) ) {
1355            $move_href = SpecialPage::getTitleFor(
1356                'MoveThread', $thread->title()->getPrefixedText() )->getLocalURL();
1357            $commands['move'] = [
1358                'label' => wfMessage( 'lqt-movethread' )->parse(),
1359                'href' => $move_href,
1360                'enabled' => true
1361            ];
1362        }
1363
1364        $services = MediaWikiServices::getInstance();
1365        if ( $this->user->isAllowed( 'protect' ) ) {
1366            $protect_href = $thread->title()->getLocalURL( 'action=protect' );
1367
1368            // Check if it's already protected
1369            if ( !$services->getRestrictionStore()->isProtected( $thread->title() ) ) {
1370                $label = wfMessage( 'protect' )->parse();
1371            } else {
1372                $label = wfMessage( 'unprotect' )->parse();
1373            }
1374
1375            $commands['protect'] = [
1376                'label' => $label,
1377                'href' => $protect_href,
1378                'enabled' => true
1379            ];
1380        }
1381
1382        if ( !$this->user->isAnon() && !$services->getWatchlistManager()
1383                ->isWatched( $this->user, $thread->title() ) ) {
1384            $commands['watch'] = [
1385                'label' => wfMessage( 'watch' )->parse(),
1386                'href' => self::permalinkUrlWithQuery(
1387                    $thread,
1388                    [
1389                        'action' => 'watch',
1390                        'token' => $this->article->getContext()->getCsrfTokenSet()->getToken( 'watch' )->toString()
1391                    ]
1392                ),
1393                'enabled' => true
1394            ];
1395        } elseif ( !$this->user->isAnon() ) {
1396            $commands['unwatch'] = [
1397                'label' => wfMessage( 'unwatch' )->parse(),
1398                'href' => self::permalinkUrlWithQuery(
1399                    $thread,
1400                    [
1401                        'action' => 'unwatch',
1402                        'token' => $this->article->getContext()->getCsrfTokenSet()->getToken( 'unwatch' )->toString()
1403                    ]
1404                ),
1405                'enabled' => true
1406            ];
1407        }
1408
1409        if ( LqtDispatch::isLqtPage( $thread->getTitle() ) ) {
1410            $summarizeUrl = self::permalinkUrl( $thread, 'summarize', $thread->id() );
1411            $commands['summarize'] = [
1412                'label' => wfMessage( 'lqt_summarize_link' )->parse(),
1413                'href' => $summarizeUrl,
1414                'enabled' => true,
1415            ];
1416        }
1417
1418        $services->getHookContainer()->run( 'LiquidThreadsTopLevelCommands', [ $thread, &$commands ] );
1419
1420        return $commands;
1421    }
1422
1423    /**
1424     * @param Article $post
1425     * @param int|null $oldid
1426     * @return string|false false if the article and revision do not exist. The HTML of the page to
1427     * display if it exists. Note that this impacts the state out OutputPage by adding
1428     * all the other relevant parts of the parser output. If you don't want this, call
1429     * $post->getParserOutput.
1430     */
1431    public function showPostBody( $post, $oldid = null ) {
1432        $parserOutput = $post->getParserOutput( $oldid );
1433
1434        if ( $parserOutput === false ) {
1435            return false;
1436        }
1437
1438        // Remove title, so that it stays set correctly.
1439        $parserOutput->setTitleText( '' );
1440
1441        $out = RequestContext::getMain()->getOutput();
1442        $out->addParserOutputMetadata( $parserOutput );
1443
1444        // LanguageConverter for language conversion
1445        $services = MediaWikiServices::getInstance();
1446        $langConv = $services
1447            ->getLanguageConverterFactory()
1448            ->getLanguageConverter( $services->getContentLanguage() );
1449
1450        return $langConv->convert( $parserOutput->getText() );
1451    }
1452
1453    /**
1454     * @param Thread $thread
1455     * @return string
1456     * @suppress SecurityCheck-DoubleEscaped
1457     */
1458    public function showThreadToolbar( Thread $thread ) {
1459        $html = '';
1460
1461        $headerParts = [];
1462
1463        foreach ( $this->threadMajorCommands( $thread ) as $key => $cmd ) {
1464            $content = $this->contentForCommand( $cmd, false /* No icon divs */ );
1465            $headerParts[] = Xml::tags( 'li',
1466                        [ 'class' => "lqt-command lqt-command-$key" ],
1467                        $content );
1468        }
1469
1470        // Drop-down menu
1471        $commands = $this->threadCommands( $thread );
1472        $menuHTML = Xml::tags( 'ul', [ 'class' => 'lqt-thread-toolbar-command-list' ],
1473                    $this->listItemsForCommands( $commands ) );
1474
1475        $triggerText = Xml::tags( 'a', [
1476                'class' => 'lqt-thread-actions-icon',
1477                'href' => '#'
1478            ],
1479            wfMessage( 'lqt-menu-trigger' )->escaped() );
1480        $dropdownTrigger = Xml::tags( 'div',
1481                [ 'class' => 'lqt-thread-actions-trigger ' .
1482                    'lqt-command-icon', 'style' => 'display: none;' ],
1483                $triggerText );
1484
1485        if ( count( $commands ) ) {
1486            $headerParts[] = Xml::tags( 'li',
1487                        [ 'class' => 'lqt-thread-toolbar-menu' ],
1488                        $dropdownTrigger );
1489        }
1490
1491        $html .= implode( ' ', $headerParts );
1492
1493        $html = Xml::tags( 'ul', [ 'class' => 'lqt-thread-toolbar-commands' ], $html );
1494
1495        $html = Xml::tags( 'div', [ 'class' => 'lqt-thread-toolbar' ], $html ) .
1496                $menuHTML;
1497
1498        return $html;
1499    }
1500
1501    public function listItemsForCommands( $commands ) {
1502        $result = [];
1503        foreach ( $commands as $key => $command ) {
1504            $thisCommand = $this->contentForCommand( $command );
1505
1506            $thisCommand = Xml::tags(
1507                'li',
1508                [ 'class' => 'lqt-command lqt-command-' . $key ],
1509                $thisCommand
1510            );
1511
1512            $result[] = $thisCommand;
1513        }
1514        return implode( ' ', $result );
1515    }
1516
1517    /**
1518     * @param array $command
1519     * @param bool $icon_divs
1520     * @return string HTML
1521     */
1522    public function contentForCommand( $command, $icon_divs = true ) {
1523        $label = $command['label'];
1524        $href = $command['href'];
1525        $enabled = $command['enabled'];
1526        $tooltip = $command['tooltip'] ?? '';
1527
1528        if ( isset( $command['icon'] ) ) {
1529            $icon = Xml::tags( 'div', [ 'title' => $label,
1530                    'class' => 'lqt-command-icon' ], '&#160;' );
1531            if ( $icon_divs ) {
1532                if ( !empty( $command['showlabel'] ) ) {
1533                    $fullLabel = $icon . '&#160;' . $label;
1534                } else {
1535                    $fullLabel = $icon;
1536                }
1537            } elseif ( empty( $command['showlabel'] ) ) {
1538                $fullLabel = '';
1539            } else {
1540                $fullLabel = $label;
1541            }
1542        } else {
1543            $fullLabel = $label;
1544        }
1545
1546        if ( $enabled ) {
1547            $thisCommand = Xml::tags( 'a', [ 'href' => $href, 'title' => $tooltip ],
1548                    $fullLabel );
1549        } else {
1550            $thisCommand = Xml::tags( 'span', [ 'class' => 'lqt_command_disabled',
1551                        'title' => $tooltip ], $fullLabel );
1552        }
1553
1554        return $thisCommand;
1555    }
1556
1557    /**
1558     * Shows a normal (i.e. not deleted or moved) thread body
1559     * @param Thread $thread
1560     */
1561    public function showThreadBody( Thread $thread ) {
1562        $post = $thread->root();
1563
1564        $divClass = $this->postDivClass( $thread );
1565        $html = '';
1566
1567        // This is a bit of a hack to have individual histories work.
1568        // We can grab oldid either from lqt_oldid (which is a thread rev),
1569        // or from oldid (which is a page rev). But oldid only applies to the
1570        // thread being requested, not any replies.
1571        $page_rev = $this->request->getVal( 'oldid', null );
1572        if ( $page_rev !== null && $this->title->equals( $thread->root()->getTitle() ) ) {
1573            $oldid = $page_rev;
1574        } else {
1575            $oldid = $thread->isHistorical() ? $thread->rootRevision() : null;
1576        }
1577
1578        $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
1579        // If we're editing the thread, show the editing form.
1580        $showAnything = $hookContainer->run( 'LiquidThreadsShowThreadBody',
1581                    [ $thread ] );
1582        if ( $this->methodAppliesToThread( 'edit', $thread ) && $showAnything ) {
1583            $html = Xml::openElement( 'div', [ 'class' => $divClass ] );
1584            $this->output->addHTML( $html );
1585            $html = '';
1586
1587            // No way am I refactoring EditForm to return its output as HTML.
1588            // so I'm just flushing the HTML and displaying it as-is.
1589            $this->showPostEditingForm( $thread );
1590            $html .= Xml::closeElement( 'div' );
1591        } elseif ( $showAnything ) {
1592            $html .= Xml::openElement( 'div', [ 'class' => $divClass ] );
1593
1594            $show = $hookContainer->run( 'LiquidThreadsShowPostContent',
1595                        [ $thread, &$post ] );
1596            if ( $show ) {
1597                $html .= $this->showPostBody( $post, $oldid );
1598            }
1599            $html .= Xml::closeElement( 'div' );
1600            $hookContainer->run( 'LiquidThreadsShowPostThreadBody',
1601                [ $thread, $this->request, &$html ] );
1602
1603            $html .= $this->showThreadToolbar( $thread );
1604            $html .= $this->threadSignature( $thread );
1605        }
1606
1607        $this->output->addHTML( $html );
1608    }
1609
1610    /**
1611     * @param Thread $thread
1612     * @return string
1613     */
1614    public function threadSignature( Thread $thread ) {
1615        global $wgLang;
1616
1617        $signature = $thread->signature() ?? '';
1618        $signature = self::parseSignature( $signature );
1619
1620        $signature = Xml::tags( 'span', [ 'class' => 'lqt-thread-user-signature' ],
1621                    $signature );
1622
1623        $signature .= $wgLang->getDirMark();
1624
1625        $timestamp = $wgLang->timeanddate( $thread->created(), true );
1626        $signature .= Xml::element( 'span',
1627                    [ 'class' => 'lqt-thread-toolbar-timestamp' ],
1628                    $timestamp );
1629
1630        MediaWikiServices::getInstance()->getHookContainer()
1631            ->run( 'LiquidThreadsThreadSignature', [ $thread, &$signature ] );
1632
1633        $signature = Xml::tags( 'div', [ 'class' => 'lqt-thread-signature' ],
1634                    $signature );
1635
1636        return $signature;
1637    }
1638
1639    /**
1640     * @param Thread $thread
1641     * @return string
1642     */
1643    private function threadInfoPanel( Thread $thread ) {
1644        global $wgLang;
1645
1646        $infoElements = [];
1647
1648        // Check for edited flag.
1649        $editedFlag = $thread->editedness();
1650        $ebLookup = [ Threads::EDITED_BY_AUTHOR => 'author',
1651                    Threads::EDITED_BY_OTHERS => 'others' ];
1652        $lastEdit = $thread->root()->getPage()->getTimestamp();
1653        $lastEditTime = $wgLang->time( $lastEdit, false, true );
1654        $lastEditDate = $wgLang->date( $lastEdit, false, true );
1655        $lastEdit = $wgLang->timeanddate( $lastEdit, true );
1656        $editors = '';
1657        $editorCount = 0;
1658
1659        if ( $editedFlag > Threads::EDITED_BY_AUTHOR ) {
1660            $editors = $thread->editors();
1661            $editorCount = count( $editors );
1662            $formattedEditors = [];
1663
1664            foreach ( $editors as $ed ) {
1665                $id = IPUtils::isIPAddress( $ed ) ? 0 : 1;
1666                $fEditor = Linker::userLink( $id, $ed ) .
1667                    Linker::userToolLinks( $id, $ed );
1668                $formattedEditors[] = $fEditor;
1669            }
1670
1671            $editors = $wgLang->commaList( $formattedEditors );
1672        }
1673
1674        if ( isset( $ebLookup[$editedFlag] ) ) {
1675            $editedBy = $ebLookup[$editedFlag];
1676            $author = $thread->author();
1677            // Used messages: lqt-thread-edited-author, lqt-thread-edited-others
1678            $editedNotice = wfMessage( "lqt-thread-edited-$editedBy" )
1679                ->params( $lastEdit )->numParams( $editorCount )
1680                ->params( $lastEditTime, $lastEditDate )
1681                ->params( $author->getName() )->parse();
1682            $editedNotice = str_replace( '$3', $editors, $editedNotice );
1683            $infoElements[] = Xml::tags( 'div', [ 'class' =>
1684                        "lqt-thread-toolbar-edited-$editedBy" ],
1685                        $editedNotice );
1686        }
1687
1688        MediaWikiServices::getInstance()->getHookContainer()
1689            ->run( 'LiquidThreadsThreadInfoPanel', [ $thread, &$infoElements ] );
1690
1691        if ( !count( $infoElements ) ) {
1692            return '';
1693        }
1694
1695        return Xml::tags( 'div', [ 'class' => 'lqt-thread-info-panel' ],
1696                            implode( "\n", $infoElements ) );
1697    }
1698
1699    /**
1700     * Shows the headING for a thread (as opposed to the headeER for a post within
1701     * a thread).
1702     * @param Thread $thread
1703     * @return string
1704     */
1705    public function showThreadHeading( Thread $thread ) {
1706        if ( $thread->hasDistinctSubject() ) {
1707            if ( $thread->hasSuperthread() ) {
1708                $commands_html = "";
1709            } else {
1710                $commands = $this->topLevelThreadCommands( $thread );
1711                $lis = $this->listItemsForCommands( $commands );
1712                $id = 'lqt-threadlevel-commands-' . $thread->id();
1713                $commands_html = Xml::tags( 'ul',
1714                        [ 'class' => 'lqt_threadlevel_commands',
1715                            'id' => $id ],
1716                        $lis );
1717            }
1718
1719            $id = 'lqt-header-' . $thread->id();
1720
1721            $services = MediaWikiServices::getInstance();
1722            $langConv = $services
1723                ->getLanguageConverterFactory()
1724                ->getLanguageConverter( $services->getContentLanguage() );
1725            $html = $langConv->convert( $thread->formattedSubject() );
1726
1727            $show = $services->getHookContainer()->run( 'LiquidThreadsShowThreadHeading', [ $thread, &$html ] );
1728
1729            if ( $show ) {
1730                $contLang = MediaWikiServices::getInstance()->getContentLanguage();
1731                $html = Xml::tags( 'span', [ 'class' => 'mw-headline' ], $html );
1732                $html .= Html::hidden( 'raw-header', $thread->subject() );
1733                $html = Xml::tags( 'h' . $this->headerLevel,
1734                        [ 'class' => 'lqt_header', 'id' => $id, 'dir' => $contLang->getDir() ],
1735                        $html ) . $commands_html;
1736            }
1737
1738            // wrap it all in a container
1739            $html = Xml::tags( 'div',
1740                    [ 'class' => 'lqt_thread_heading' ],
1741                    $html );
1742            return $html;
1743        }
1744
1745        return '';
1746    }
1747
1748    public function postDivClass( Thread $thread ) {
1749        $levelClass = 'lqt-thread-nest-' . $this->threadNestingLevel;
1750        $alternatingType = ( $this->threadNestingLevel % 2 ) ? 'odd' : 'even';
1751        $alternatingClass = "lqt-thread-$alternatingType";
1752        $dir = MediaWikiServices::getInstance()->getContentLanguage()->getDir();
1753
1754        return "lqt_post $levelClass $alternatingClass mw-content-$dir";
1755    }
1756
1757    /**
1758     * @param Thread $thread
1759     * @return string
1760     */
1761    public static function anchorName( Thread $thread ) {
1762        return $thread->getAnchorName();
1763    }
1764
1765    /**
1766     * Display a moved thread
1767     *
1768     * @param Thread $thread
1769     * @throws Exception
1770     */
1771    public function showMovedThread( Thread $thread ) {
1772        global $wgLang;
1773
1774        // Grab target thread
1775        if ( !$thread->title() ) {
1776            return; // Odd case: moved thread with no title?
1777        }
1778
1779        $article = new Article( $thread->title(), 0 );
1780        $target = $article->getPage()->getRedirectTarget();
1781
1782        if ( !$target ) {
1783            $content = $article->getPage()->getContent();
1784            $contentText = ( $content instanceof TextContent ) ? $content->getText() : '';
1785            throw new LogicException( "Thread " . $thread->id() . ' purports to be moved, ' .
1786                'but no redirect found in text (' . $contentText . ') of ' .
1787                $thread->root()->getTitle()->getPrefixedText() . '. Dying.'
1788            );
1789        }
1790
1791        $t_thread = Threads::withRoot(
1792            MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $target )
1793        );
1794
1795        // Grab data about the new post.
1796        $author = $thread->author();
1797        $sig = Linker::userLink( $author->getId(), $author->getName() ) .
1798            Linker::userToolLinks( $author->getId(), $author->getName() );
1799        $newTalkpage = is_object( $t_thread ) ? $t_thread->getTitle() : '';
1800
1801        $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
1802        $html = wfMessage( 'lqt_move_placeholder' )
1803            ->rawParams( $linkRenderer->makeLink( $target ), $sig )
1804            ->params( $wgLang->date( $thread->modified() ), $wgLang->time( $thread->modified() ) )
1805            ->rawParams( $newTalkpage ? $linkRenderer->makeLink( $newTalkpage ) : '' )->parse();
1806
1807        $this->output->addHTML( $html );
1808    }
1809
1810    /**
1811     * Shows a deleted thread. Returns true to show the thread body
1812     * @param Thread $thread
1813     * @return bool
1814     */
1815    public function showDeletedThread( $thread ) {
1816        if ( $this->user->isAllowed( 'deletedhistory' ) ) {
1817            $this->output->addWikiMsg( 'lqt_thread_deleted_for_sysops' );
1818            return true;
1819        } else {
1820            $msg = wfMessage( 'lqt_thread_deleted' )->parse();
1821            $msg = Xml::tags( 'em', null, $msg );
1822            $msg = Xml::tags( 'p', null, $msg );
1823
1824            $this->output->addHTML( $msg );
1825            return false;
1826        }
1827    }
1828
1829    /**
1830     * Shows a single thread, rather than a thread tree.
1831     *
1832     * @param Thread $thread
1833     */
1834    public function showSingleThread( Thread $thread ) {
1835        $html = '';
1836
1837        // If it's a 'moved' thread, show the placeholder
1838        if ( $thread->type() == Threads::TYPE_MOVED ) {
1839            $this->showMovedThread( $thread );
1840            return;
1841        } elseif ( $thread->type() == Threads::TYPE_DELETED ) {
1842            $res = $this->showDeletedThread( $thread );
1843
1844            if ( !$res ) {
1845                return;
1846            }
1847        }
1848
1849        $this->output->addHTML( $this->threadInfoPanel( $thread ) );
1850
1851        if ( $thread->summary() ) {
1852            $html .= $this->getSummary( $thread );
1853        }
1854
1855        // Unfortunately, I can't rewrite showRootPost() to pass back HTML
1856        // as it would involve rewriting EditPage, which I do NOT intend to do.
1857
1858        $this->output->addHTML( $html );
1859
1860        $this->showThreadBody( $thread );
1861    }
1862
1863    /**
1864     * @param (Thread|int)[] $threads
1865     * @return Thread[]
1866     */
1867    public function getMustShowThreads( array $threads = [] ) {
1868        if ( $this->request->getCheck( 'lqt_operand' ) ) {
1869            $operands = explode( ',', $this->request->getVal( 'lqt_operand' ) );
1870            $operands = array_filter( $operands, 'ctype_digit' );
1871            $threads = array_merge( $threads, $operands );
1872        }
1873
1874        if ( $this->request->getCheck( 'lqt_mustshow' ) ) {
1875            // Check for must-show in the request
1876            $specifiedMustShow = $this->request->getVal( 'lqt_mustshow' );
1877            $specifiedMustShow = explode( ',', $specifiedMustShow );
1878            $specifiedMustShow = array_filter( $specifiedMustShow, 'ctype_digit' );
1879
1880            $threads = array_merge( $threads, $specifiedMustShow );
1881        }
1882
1883        foreach ( $threads as $walk_thread ) {
1884            do {
1885                if ( !is_object( $walk_thread ) ) {
1886                    $walk_thread = Threads::withId( $walk_thread );
1887                }
1888
1889                if ( !is_object( $walk_thread ) ) {
1890                    continue;
1891                }
1892
1893                $threads[$walk_thread->id()] = $walk_thread;
1894                $walk_thread = $walk_thread->superthread();
1895            } while ( $walk_thread );
1896        }
1897
1898        return $threads;
1899    }
1900
1901    /**
1902     * @param Thread $thread
1903     * @param Thread $st
1904     * @param int $i
1905     * @return string
1906     */
1907    public function getShowMore( Thread $thread, $st, $i ) {
1908        $linkText = new HtmlArmor( wfMessage( 'lqt-thread-show-more' )->parse() );
1909        $linkTitle = clone $thread->topmostThread()->title();
1910        $linkTitle->setFragment( '#' . $st->getAnchorName() );
1911
1912        $link = MediaWikiServices::getInstance()->getLinkRenderer()->makeLink(
1913            $linkTitle,
1914            $linkText,
1915            [
1916                'class' => 'lqt-show-more-posts',
1917            ]
1918        );
1919        $link .= Html::hidden( 'lqt-thread-start-at', (string)$i,
1920                [ 'class' => 'lqt-thread-start-at' ] );
1921
1922        return $link;
1923    }
1924
1925    /**
1926     * When some replies are hidden because the are nested too deep,
1927     * this method creates a link that can be used to show the hidden
1928     * threats.
1929     * @param Thread $thread
1930     * @return string Html
1931     */
1932    public function getShowReplies( Thread $thread ) {
1933        $linkText = new HtmlArmor( wfMessage( 'lqt-thread-show-replies' )
1934            ->numParams( $thread->replyCount() )
1935            ->parse() );
1936        $linkTitle = clone $thread->topmostThread()->title();
1937        $linkTitle->setFragment( '#' . $thread->getAnchorName() );
1938
1939        $link = MediaWikiServices::getInstance()->getLinkRenderer()->makeLink(
1940            $linkTitle,
1941            $linkText,
1942            [
1943                'class' => 'lqt-show-replies',
1944            ]
1945        );
1946        $link = Xml::tags( 'div', [ 'class' => 'lqt-thread-replies' ], $link );
1947
1948        return $link;
1949    }
1950
1951    /**
1952     * @param Thread $thread
1953     * @return bool
1954     */
1955    public static function threadContainsRepliesWithContent( Thread $thread ) {
1956        $replies = $thread->replies();
1957
1958        foreach ( $replies as $reply ) {
1959            $content = '';
1960            if ( $reply->root() ) {
1961                $pageContent = $reply->root()->getPage()->getContent();
1962                $content = ( $pageContent instanceof TextContent ) ? $pageContent->getText() : '';
1963            }
1964
1965            if ( $content !== null && trim( $content ) != '' ) {
1966                return true;
1967            }
1968
1969            if ( self::threadContainsRepliesWithContent( $reply ) ) {
1970                return true;
1971            }
1972
1973            if ( $reply->type() == Threads::TYPE_MOVED ) {
1974                return true;
1975            }
1976        }
1977
1978        return false;
1979    }
1980
1981    /**
1982     * @param Thread $thread
1983     * @param int $startAt
1984     * @param int $maxCount
1985     * @param bool $showThreads
1986     * @param array $cascadeOptions
1987     * @param bool $interruption
1988     */
1989    public function showThreadReplies( Thread $thread, $startAt, $maxCount, $showThreads,
1990            $cascadeOptions, $interruption = false ) {
1991        $repliesClass = 'lqt-thread-replies lqt-thread-replies-' .
1992                    $this->threadNestingLevel;
1993
1994        if ( $interruption ) {
1995            $repliesClass .= ' lqt-thread-replies-interruption';
1996        }
1997
1998        $div = Xml::openElement( 'div', [ 'class' => $repliesClass ] );
1999
2000        $subthreadCount = count( $thread->subthreads() );
2001        $i = 0;
2002        $showCount = 0;
2003        $showThreads = true;
2004
2005        $mustShowThreads = $cascadeOptions['mustShowThreads'];
2006
2007        $replies = $thread->subthreads();
2008        usort( $replies, [ 'Thread', 'createdSortCallback' ] );
2009
2010        foreach ( $replies as $st ) {
2011            ++$i;
2012
2013            // Only show undeleted threads that are above our 'startAt' index.
2014            $shown = false;
2015            if ( $st->type() != Threads::TYPE_DELETED &&
2016                    $i >= $startAt &&
2017                    $showThreads ) {
2018                if ( $showCount > $maxCount && $maxCount > 0 ) {
2019                    // We've shown too many threads.
2020                    $link = $this->getShowMore( $thread, $st, $i );
2021
2022                    $this->output->addHTML( $div . $link . '</div>' );
2023                    $showThreads = false;
2024                    continue;
2025                }
2026
2027                ++$showCount;
2028                if ( $showCount == 1 ) {
2029                    // There's a post sep before each reply group to
2030                    // separate from the parent thread.
2031                    $this->output->addHTML( $div );
2032                }
2033
2034                $this->showThread( $st, $i, $subthreadCount, $cascadeOptions );
2035                $shown = true;
2036            }
2037
2038            // Handle must-show threads.
2039            // FIXME this thread will be duplicated if somebody clicks the
2040            // "show more" link (probably needs fixing in the JS)
2041            if ( $st->type() != Threads::TYPE_DELETED && !$shown &&
2042                    array_key_exists( $st->id(), $mustShowThreads ) ) {
2043                $this->showThread( $st, $i, $subthreadCount, $cascadeOptions );
2044            }
2045        }
2046
2047        // Show reply stuff
2048        $this->showReplyBox( $thread );
2049
2050        $finishDiv = '';
2051        $finishDiv .= Xml::tags(
2052            'div',
2053            [ 'class' => 'lqt-replies-finish' ],
2054            '&#160;'
2055        );
2056
2057        $this->output->addHTML( $finishDiv . Xml::closeElement( 'div' ) );
2058    }
2059
2060    /**
2061     * @param Thread $thread
2062     * @param int $levelNum
2063     * @param int $totalInLevel
2064     * @param array $options
2065     * @throws Exception
2066     */
2067    public function showThread( Thread $thread, $levelNum = 1, $totalInLevel = 1,
2068        $options = []
2069    ) {
2070        // Safeguard
2071        if ( $thread->type() & Threads::TYPE_DELETED ||
2072                !$thread->root() ) {
2073            return;
2074        }
2075
2076        $this->threadNestingLevel++;
2077
2078        // Figure out which threads *need* to be shown because they're involved in an
2079        // operation
2080        $mustShowOption = $options['mustShowThreads'] ?? [];
2081        $mustShowThreads = $this->getMustShowThreads( $mustShowOption );
2082
2083        // For cascading.
2084        $options['mustShowThreads'] = $mustShowThreads;
2085
2086        // Don't show blank posts unless we have to
2087        $content = '';
2088        if ( $thread->root() ) {
2089            $pageContent = $thread->root()->getPage()->getContent();
2090            $content = ( $pageContent instanceof TextContent ) ? $pageContent->getText() : '';
2091        }
2092
2093        if (
2094            $content !== null && trim( $content ) == '' &&
2095            $thread->type() != Threads::TYPE_MOVED &&
2096            !self::threadContainsRepliesWithContent( $thread ) &&
2097            !array_key_exists( $thread->id(), $mustShowThreads )
2098        ) {
2099            $this->threadNestingLevel--;
2100            return;
2101        }
2102
2103        // Grab options
2104        $services = MediaWikiServices::getInstance();
2105        $userOptionsLookup = $services->getUserOptionsLookup();
2106        $maxDepth = $options['maxDepth'] ?? $userOptionsLookup->getOption( $this->user, 'lqtdisplaydepth' );
2107        $maxCount = $options['maxCount'] ?? $userOptionsLookup->getOption( $this->user, 'lqtdisplaycount' );
2108        $startAt = $options['startAt'] ?? 0;
2109
2110        // Figure out if we have replies to show or not.
2111        $showThreads = ( $maxDepth == -1 ) ||
2112                ( $this->threadNestingLevel <= $maxDepth );
2113        $mustShowThreadIds = array_keys( $mustShowThreads );
2114        $subthreadIds = array_keys( $thread->replies() );
2115        $mustShowSubthreadIds = array_intersect( $mustShowThreadIds, $subthreadIds );
2116
2117        $hasSubthreads = self::threadContainsRepliesWithContent( $thread );
2118        $hasSubthreads = $hasSubthreads || count( $mustShowSubthreadIds );
2119        // Show subthreads if one of the subthreads is on the must-show list
2120        $showThreads = $showThreads ||
2121            count( array_intersect(
2122                array_keys( $mustShowThreads ), array_keys( $thread->replies() )
2123            ) );
2124        $replyTo = $this->methodAppliesToThread( 'reply', $thread );
2125
2126        $this->output->addModules( 'ext.liquidThreads' );
2127
2128        $html = '';
2129        $services->getHookContainer()->run( 'EditPageBeforeEditToolbar', [ &$html ] );
2130
2131        $class = $this->threadDivClass( $thread );
2132        if ( $levelNum == 1 ) {
2133            $class .= ' lqt-thread-first';
2134        } elseif ( $levelNum == $totalInLevel ) {
2135            $class .= ' lqt-thread-last';
2136        }
2137
2138        if ( $hasSubthreads && $showThreads ) {
2139            $class .= ' lqt-thread-with-subthreads';
2140        } else {
2141            $class .= ' lqt-thread-no-subthreads';
2142        }
2143
2144        if ( !$services->getPermissionManager()
2145            ->quickUserCan( 'edit', $this->user, $thread->title() )
2146            || !LqtDispatch::isLqtPage( $thread->getTitle() )
2147        ) {
2148            $class .= ' lqt-thread-uneditable';
2149        }
2150
2151        $class .= ' lqt-thread-wrapper';
2152
2153        $html .= Xml::openElement(
2154            'div',
2155            [
2156                'class' => $class,
2157                'id' => 'lqt_thread_id_' . $thread->id()
2158            ]
2159        );
2160
2161        $html .= Xml::element( 'a', [ 'name' => $this->anchorName( $thread ) ], ' ' );
2162        $html .= $this->showThreadHeading( $thread );
2163
2164        // Metadata stuck in the top of the lqt_thread div.
2165        // Modified time for topmost threads...
2166        if ( $thread->isTopmostThread() ) {
2167            $html .= Html::hidden(
2168                'lqt-thread-modified-' . $thread->id(),
2169                wfTimestamp( TS_MW, $thread->modified() ),
2170                [
2171                    'id' => 'lqt-thread-modified-' . $thread->id(),
2172                    'class' => 'lqt-thread-modified'
2173                ]
2174            );
2175            $html .= Html::hidden(
2176                'lqt-thread-sortkey',
2177                $thread->sortkey(),
2178                [ 'id' => 'lqt-thread-sortkey-' . $thread->id() ]
2179            );
2180
2181            $html .= Html::hidden(
2182                'lqt-thread-talkpage-' . $thread->id(),
2183                $thread->article()->getTitle()->getPrefixedText(),
2184                [
2185                    'class' => 'lqt-thread-talkpage-metadata',
2186                ]
2187            );
2188        }
2189
2190        if ( !$thread->title() ) {
2191            throw new LogicException( "Thread " . $thread->id() . " has null title" );
2192        }
2193
2194        // Add the thread's title
2195        $html .= Html::hidden(
2196            'lqt-thread-title-' . $thread->id(),
2197            $thread->title()->getPrefixedText(),
2198            [
2199                'id' => 'lqt-thread-title-' . $thread->id(),
2200                'class' => 'lqt-thread-title-metadata'
2201            ]
2202        );
2203
2204        // Flush output to display thread
2205        $this->output->addHTML( $html );
2206        $this->output->addHTML( Xml::openElement( 'div',
2207                    [ 'class' => 'lqt-post-wrapper' ] ) );
2208        $this->showSingleThread( $thread );
2209        $this->output->addHTML( Xml::closeElement( 'div' ) );
2210
2211        $cascadeOptions = $options;
2212        unset( $cascadeOptions['startAt'] );
2213
2214        $replyInterruption = $levelNum < $totalInLevel;
2215
2216        if ( ( $hasSubthreads && $showThreads ) ) {
2217            // If the thread has subthreads, and we want to show them, we should do so.
2218            $this->showThreadReplies( $thread, $startAt, $maxCount, $showThreads,
2219                $cascadeOptions, $replyInterruption );
2220        } elseif ( $hasSubthreads && !$showThreads ) {
2221            // If the thread has subthreads, but we don't want to show them, then
2222            // show the reply form if necessary, and add the "Show X replies" link.
2223            if ( $replyTo ) {
2224                $this->showReplyForm( $thread );
2225            }
2226
2227            // Add a "show subthreads" link.
2228            $link = $this->getShowReplies( $thread );
2229
2230            $this->output->addHTML( $link );
2231
2232            if ( $levelNum < $totalInLevel ) {
2233                $this->output->addHTML(
2234                    Xml::tags( 'div', [ 'class' => 'lqt-post-sep' ], '&#160;' ) );
2235            }
2236        } elseif ( $levelNum < $totalInLevel ) {
2237            // If we have no replies, and we're not at the end of this level, add the post separator
2238            // and a reply box if necessary.
2239            $this->output->addHTML(
2240                Xml::tags( 'div', [ 'class' => 'lqt-post-sep' ], '&#160;' ) );
2241
2242            if ( $replyTo ) {
2243                $class = 'lqt-thread-replies lqt-thread-replies-' .
2244                        $this->threadNestingLevel;
2245                $html = Xml::openElement( 'div', [ 'class' => $class ] );
2246                $this->output->addHTML( $html );
2247
2248                $this->showReplyForm( $thread );
2249
2250                $finishDiv = Xml::tags( 'div',
2251                        [ 'class' => 'lqt-replies-finish' ],
2252                        '&#160;' );
2253                // Layout plus close div.lqt-thread-replies
2254
2255                $finishHTML = Xml::closeElement( 'div' ); // lqt-reply-form
2256                $finishHTML .= $finishDiv; // Layout
2257                $finishHTML .= Xml::closeElement( 'div' ); // lqt-thread-replies
2258                $this->output->addHTML( $finishHTML );
2259            }
2260        } elseif ( !$hasSubthreads && $replyTo ) {
2261            // If we have no replies, we're at the end of this level, and we want to reply,
2262            // show the reply box.
2263            $class = 'lqt-thread-replies lqt-thread-replies-' .
2264                    $this->threadNestingLevel;
2265            $html = Xml::openElement( 'div', [ 'class' => $class ] );
2266            $this->output->addHTML( $html );
2267
2268            $this->showReplyForm( $thread );
2269
2270            $html = Xml::tags( 'div',
2271                    [ 'class' => 'lqt-replies-finish' ],
2272                    Xml::tags( 'div',
2273                        [ 'class' =>
2274                            'lqt-replies-finish-corner'
2275                        ], '&#160;' ) );
2276            $html .= Xml::closeElement( 'div' );
2277            $this->output->addHTML( $html );
2278        }
2279
2280        $this->output->addHTML( Xml::closeElement( 'div' ) );
2281
2282        $this->threadNestingLevel--;
2283    }
2284
2285    /**
2286     * @param Thread $thread
2287     */
2288    public function showReplyBox( Thread $thread ) {
2289        // Check if we're actually replying to this thread.
2290        if ( $this->methodAppliesToThread( 'reply', $thread ) ) {
2291            $this->showReplyForm( $thread );
2292            return;
2293        } elseif ( !$thread->canUserReply( $this->user, 'quick' ) ) {
2294            return;
2295        }
2296
2297        $html = '';
2298        $html .= Xml::tags( 'div',
2299            [ 'class' => 'lqt-reply-form lqt-edit-form',
2300                'style' => 'display: none;' ],
2301            ''
2302        );
2303
2304        $this->output->addHTML( $html );
2305    }
2306
2307    /**
2308     * @param Thread $thread
2309     * @return string
2310     */
2311    public function threadDivClass( Thread $thread ) {
2312        $levelClass = 'lqt-thread-nest-' . $this->threadNestingLevel;
2313        $alternatingType = ( $this->threadNestingLevel % 2 ) ? 'odd' : 'even';
2314        $alternatingClass = "lqt-thread-$alternatingType";
2315        $topmostClass = $thread->isTopmostThread() ? ' lqt-thread-topmost' : '';
2316
2317        return "lqt_thread $levelClass $alternatingClass$topmostClass";
2318    }
2319
2320    /**
2321     * @param Thread $t
2322     * @return string
2323     */
2324    public function getSummary( $t ) {
2325        if ( !$t->summary() ) {
2326            return '';
2327        }
2328        if ( !( $t->summary()->getPage()->getContent() instanceof TextContent ) ) {
2329            return ''; // Blank summary
2330        }
2331
2332        $label = wfMessage( 'lqt_summary_label' )->parse();
2333        $edit_text = wfMessage( 'edit' )->text();
2334        $link_text = new HtmlArmor( wfMessage( 'lqt_permalink' )->parse() );
2335
2336        $html = '';
2337
2338        $html .= Xml::tags(
2339            'span',
2340            [ 'class' => 'lqt_thread_permalink_summary_title' ],
2341            $label
2342        );
2343
2344        $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
2345        $link = $linkRenderer->makeLink(
2346            $t->summary()->getTitle(),
2347            $link_text,
2348            [
2349                'class' => 'lqt-summary-link',
2350            ]
2351        );
2352        $link .= Html::hidden( 'summary-title', $t->summary()->getTitle()->getPrefixedText() );
2353        $edit_link = self::permalink( $t, $edit_text, 'summarize', $t->id() );
2354        $links = "[$link]\n[$edit_link]";
2355        $html .= Xml::tags(
2356            'span',
2357            [ 'class' => 'lqt_thread_permalink_summary_actions' ],
2358            $links
2359        );
2360
2361        $summary_body = $this->showPostBody( $t->summary() );
2362        $html .= Xml::tags(
2363            'div',
2364            [ 'class' => 'lqt_thread_permalink_summary_body' ],
2365            $summary_body
2366        );
2367
2368        $html = Xml::tags(
2369            'div',
2370            [ 'class' => 'lqt_thread_permalink_summary' ],
2371            $html
2372        );
2373
2374        return $html;
2375    }
2376
2377    /**
2378     * @param User|int|string $user
2379     * @return string
2380     */
2381    public function getSignature( $user ) {
2382        if ( is_object( $user ) ) {
2383            $uid = $user->getId();
2384        } elseif ( is_int( $user ) ) {
2385            $uid = $user;
2386            $user = User::newFromId( $uid );
2387        } else {
2388            $user = User::newFromName( $user );
2389            $uid = $user->getId();
2390        }
2391
2392        return $this->getUserSignature( $user, $uid );
2393    }
2394
2395    /**
2396     * @param User|null $user
2397     * @param int|null $uid
2398     * @return string
2399     */
2400    public static function getUserSignature( $user, $uid = null ) {
2401        $parser = MediaWikiServices::getInstance()->getParser();
2402
2403        if ( !$user ) {
2404            $user = User::newFromId( $uid );
2405        }
2406
2407        $parser->setOptions( new ParserOptions( $user ) );
2408
2409        $sig = $parser->getUserSig( $user );
2410
2411        return $sig;
2412    }
2413
2414    /**
2415     * @param string $sig
2416     * @return string
2417     */
2418    public static function parseSignature( $sig ) {
2419        static $parseCache = [];
2420        $sigKey = md5( $sig );
2421
2422        if ( isset( $parseCache[$sigKey] ) ) {
2423            return $parseCache[$sigKey];
2424        }
2425
2426        $out = RequestContext::getMain()->getOutput();
2427        $sig = Parser::stripOuterParagraph(
2428            $out->parseAsContent( $sig )
2429        );
2430
2431        $parseCache[$sigKey] = $sig;
2432
2433        return $sig;
2434    }
2435
2436    /**
2437     * @param string $sig
2438     * @param User $user
2439     * @return string
2440     */
2441    public static function signaturePST( $sig, $user ) {
2442        global $wgTitle;
2443
2444        $title = $wgTitle ?: $user->getUserPage();
2445
2446        $sig = MediaWikiServices::getInstance()->getParser()->preSaveTransform(
2447            $sig,
2448            $title,
2449            $user,
2450            new ParserOptions( $user ),
2451            true
2452        );
2453
2454        return $sig;
2455    }
2456
2457    public function customizeNavigation( $skin, &$links ) {
2458        // No-op
2459    }
2460
2461    public function show() {
2462        return true; // No-op
2463    }
2464
2465    /**
2466     * Copy-and-modify of MediaWiki\CommentFormatter\CommentFormatter::format
2467     *
2468     * @param string $s
2469     * @return string
2470     */
2471    public static function formatSubject( $s ) {
2472        # Sanitize text a bit:
2473        $s = str_replace( "\n", " ", $s );
2474        # Allow HTML entities
2475        $s = Sanitizer::escapeHtmlAllowEntities( $s );
2476
2477        # Render links:
2478        return MediaWikiServices::getInstance()->getCommentFormatter()
2479            ->formatLinks( $s, null, false );
2480    }
2481}