Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 630
0.00% covered (danger)
0.00%
0 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventPageDecorator
0.00% covered (danger)
0.00%
0 / 630
0.00% covered (danger)
0.00%
0 / 22
8556
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 decoratePage
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 maybeAddEnableRegistrationHeader
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 getEnableRegistrationHeader
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
2
 addRegistrationHeader
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
12
 getEventQuestionsData
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
56
 getHeaderElement
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 getParticipantNoticeRow
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getEventInfoHeaderRow
0.00% covered (danger)
0.00%
0 / 86
0.00% covered (danger)
0.00%
0 / 1
30
 getDetailsDialogContent
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
2
 getDetailsDialogOrganizers
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
12
 getDetailsDialogEventInfo
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 getDetailsDialogDates
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
2
 getDetailsDialogLocation
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
182
 getDetailsDialogChat
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
72
 getDetailsDialogParticipants
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
20
 getParticipantRows
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 getActionElement
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
72
 getUserStatus
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
182
 getParticipantFooter
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getParticipantRow
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 makeDetailsDialogSection
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\EventPage;
6
7use Language;
8use LogicException;
9use MediaWiki\Extension\CampaignEvents\Event\EventRegistration;
10use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration;
11use MediaWiki\Extension\CampaignEvents\Event\PageEventLookup;
12use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup;
13use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsPageFactory;
14use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUser;
15use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUserNotFoundException;
16use MediaWiki\Extension\CampaignEvents\MWEntity\HiddenCentralUserException;
17use MediaWiki\Extension\CampaignEvents\MWEntity\ICampaignsAuthority;
18use MediaWiki\Extension\CampaignEvents\MWEntity\ICampaignsPage;
19use MediaWiki\Extension\CampaignEvents\MWEntity\MWAuthorityProxy;
20use MediaWiki\Extension\CampaignEvents\MWEntity\UserLinker;
21use MediaWiki\Extension\CampaignEvents\MWEntity\UserNotGlobalException;
22use MediaWiki\Extension\CampaignEvents\Organizers\OrganizersStore;
23use MediaWiki\Extension\CampaignEvents\Participants\Participant;
24use MediaWiki\Extension\CampaignEvents\Participants\ParticipantsStore;
25use MediaWiki\Extension\CampaignEvents\Participants\RegisterParticipantCommand;
26use MediaWiki\Extension\CampaignEvents\Participants\UnregisterParticipantCommand;
27use MediaWiki\Extension\CampaignEvents\Permissions\PermissionChecker;
28use MediaWiki\Extension\CampaignEvents\Questions\EventQuestionsRegistry;
29use MediaWiki\Extension\CampaignEvents\Special\SpecialCancelEventRegistration;
30use MediaWiki\Extension\CampaignEvents\Special\SpecialEnableEventRegistration;
31use MediaWiki\Extension\CampaignEvents\Special\SpecialEventDetails;
32use MediaWiki\Extension\CampaignEvents\Special\SpecialRegisterForEvent;
33use MediaWiki\Extension\CampaignEvents\Time\EventTimeFormatter;
34use MediaWiki\Extension\CampaignEvents\Utils;
35use MediaWiki\Extension\CampaignEvents\Widget\TextWithIconWidget;
36use MediaWiki\Html\Html;
37use MediaWiki\Linker\Linker;
38use MediaWiki\Linker\LinkRenderer;
39use MediaWiki\Output\OutputPage;
40use MediaWiki\Page\ProperPageIdentity;
41use MediaWiki\Permissions\Authority;
42use MediaWiki\SpecialPage\SpecialPage;
43use MediaWiki\User\UserIdentity;
44use OOUI\ButtonWidget;
45use OOUI\Element;
46use OOUI\HorizontalLayout;
47use OOUI\HtmlSnippet;
48use OOUI\IconWidget;
49use OOUI\MessageWidget;
50use OOUI\PanelLayout;
51use OOUI\Tag;
52use UnexpectedValueException;
53use Wikimedia\Message\IMessageFormatterFactory;
54use Wikimedia\Message\ITextFormatter;
55use Wikimedia\Message\MessageValue;
56
57/**
58 * This service is used to add some widgets to the event page, like the registration header.
59 */
60class EventPageDecorator {
61    public const SERVICE_NAME = 'CampaignEventsEventPageDecorator';
62
63    private const ADDRESS_MAX_LENGTH = 30;
64    // See T304719#7909758 for how these numbers were chosen
65    private const ORGANIZERS_LIMIT = 4;
66    private const PARTICIPANTS_LIMIT = 10;
67
68    // Constants for the different statuses of a user wrt a given event registration
69    private const USER_STATUS_BLOCKED = 1;
70    private const USER_STATUS_ORGANIZER = 2;
71    private const USER_STATUS_PARTICIPANT_CAN_UNREGISTER = 3;
72    private const USER_STATUS_CAN_REGISTER = 4;
73    private const USER_STATUS_CANNOT_REGISTER_ENDED = 5;
74    private const USER_STATUS_CANNOT_REGISTER_CLOSED = 6;
75
76    private PageEventLookup $pageEventLookup;
77    private ParticipantsStore $participantsStore;
78    private OrganizersStore $organizersStore;
79    private PermissionChecker $permissionChecker;
80    private LinkRenderer $linkRenderer;
81    private CampaignsPageFactory $campaignsPageFactory;
82    private CampaignsCentralUserLookup $centralUserLookup;
83    private UserLinker $userLinker;
84    private EventTimeFormatter $eventTimeFormatter;
85    private EventPageCacheUpdater $eventPageCacheUpdater;
86    private EventQuestionsRegistry $eventQuestionsRegistry;
87
88    private Language $language;
89    private ICampaignsAuthority $authority;
90    private UserIdentity $viewingUser;
91    private OutputPage $out;
92    private ITextFormatter $msgFormatter;
93
94    /**
95     * @var bool|null Whether the user is registered publicly or privately. This value is lazy-loaded iff the user
96     * status is USER_STATUS_PARTICIPANT_CAN_UNREGISTER.
97     */
98    private ?bool $participantIsPublic = null;
99
100    /**
101     * @param PageEventLookup $pageEventLookup
102     * @param ParticipantsStore $participantsStore
103     * @param OrganizersStore $organizersStore
104     * @param PermissionChecker $permissionChecker
105     * @param IMessageFormatterFactory $messageFormatterFactory
106     * @param LinkRenderer $linkRenderer
107     * @param CampaignsPageFactory $campaignsPageFactory
108     * @param CampaignsCentralUserLookup $centralUserLookup
109     * @param UserLinker $userLinker
110     * @param EventTimeFormatter $eventTimeFormatter
111     * @param EventPageCacheUpdater $eventPageCacheUpdater
112     * @param EventQuestionsRegistry $eventQuestionsRegistry
113     * @param Language $language
114     * @param Authority $viewingAuthority
115     * @param OutputPage $out
116     */
117    public function __construct(
118        PageEventLookup $pageEventLookup,
119        ParticipantsStore $participantsStore,
120        OrganizersStore $organizersStore,
121        PermissionChecker $permissionChecker,
122        IMessageFormatterFactory $messageFormatterFactory,
123        LinkRenderer $linkRenderer,
124        CampaignsPageFactory $campaignsPageFactory,
125        CampaignsCentralUserLookup $centralUserLookup,
126        UserLinker $userLinker,
127        EventTimeFormatter $eventTimeFormatter,
128        EventPageCacheUpdater $eventPageCacheUpdater,
129        EventQuestionsRegistry $eventQuestionsRegistry,
130        Language $language,
131        Authority $viewingAuthority,
132        OutputPage $out
133    ) {
134        $this->pageEventLookup = $pageEventLookup;
135        $this->participantsStore = $participantsStore;
136        $this->organizersStore = $organizersStore;
137        $this->permissionChecker = $permissionChecker;
138        $this->linkRenderer = $linkRenderer;
139        $this->campaignsPageFactory = $campaignsPageFactory;
140        $this->centralUserLookup = $centralUserLookup;
141        $this->userLinker = $userLinker;
142        $this->eventTimeFormatter = $eventTimeFormatter;
143        $this->eventPageCacheUpdater = $eventPageCacheUpdater;
144        $this->eventQuestionsRegistry = $eventQuestionsRegistry;
145
146        $this->language = $language;
147        $this->authority = new MWAuthorityProxy( $viewingAuthority );
148        $this->viewingUser = $viewingAuthority->getUser();
149        $this->out = $out;
150        $this->msgFormatter = $messageFormatterFactory->getTextFormatter( $language->getCode() );
151    }
152
153    /**
154     * This is the main entry point for this class. It adds all the necessary HTML (registration header, popup etc.)
155     * to the given OutputPage, as well as loading some JS/CSS resources.
156     *
157     * @param ProperPageIdentity $page
158     */
159    public function decoratePage( ProperPageIdentity $page ): void {
160        $registration = $this->pageEventLookup->getRegistrationForLocalPage( $page );
161
162        if ( $registration && $registration->getDeletionTimestamp() !== null ) {
163            return;
164        }
165
166        if ( $registration ) {
167            $this->addRegistrationHeader( $registration );
168            $this->eventPageCacheUpdater->adjustCacheForPageWithRegistration( $this->out, $registration );
169        } else {
170            $campaignsPage = $this->campaignsPageFactory->newFromLocalMediaWikiPage( $page );
171            $this->maybeAddEnableRegistrationHeader( $campaignsPage );
172        }
173    }
174
175    /**
176     * @param ICampaignsPage $eventPage
177     */
178    private function maybeAddEnableRegistrationHeader( ICampaignsPage $eventPage ): void {
179        if ( !$this->permissionChecker->userCanEnableRegistration( $this->authority, $eventPage ) ) {
180            return;
181        }
182
183        $this->out->enableOOUI();
184        $this->out->addModuleStyles( [
185            'ext.campaignEvents.eventpage.styles',
186            'oojs-ui.styles.icons-editing-advanced',
187        ] );
188        $this->out->addModules( [ 'ext.campaignEvents.eventpage' ] );
189        // We pass this to the client to avoid hardcoding the name of the page field in JS. Apparently we can't use
190        // a RL callback for this because it doesn't provide the current page.
191        $enableRegistrationURL = SpecialPage::getTitleFor( SpecialEnableEventRegistration::PAGE_NAME )->getLocalURL( [
192            SpecialEnableEventRegistration::PAGE_FIELD_NAME => $eventPage->getPrefixedText()
193        ] );
194        $this->out->addJsConfigVars( [ 'wgCampaignEventsEnableRegistrationURL' => $enableRegistrationURL ] );
195        $this->out->addHTML( $this->getEnableRegistrationHeader( $enableRegistrationURL ) );
196    }
197
198    /**
199     * @param string $enableRegistrationURL
200     * @return Tag
201     */
202    private function getEnableRegistrationHeader( string $enableRegistrationURL ): Tag {
203        $organizerText = ( new Tag( 'div' ) )->appendContent(
204            $this->msgFormatter->format( MessageValue::new( 'campaignevents-eventpage-enableheader-organizer' ) )
205        )->setAttributes( [ 'class' => 'ext-campaignevents-eventpage-organizer-label' ] );
206
207        // Wrap it in a span for use inside a flex container, since the message contains HTML.
208        // XXX Can't use ITextFormatter here because the message contains HTML, see T260689
209        $infoMsg = ( new Tag( 'span' ) )->appendContent(
210            new HtmlSnippet( $this->out->msg( 'campaignevents-eventpage-enableheader-eventpage-desc' )->parse() )
211        );
212        $infoText = ( new Tag( 'div' ) )->appendContent(
213            new IconWidget( [ 'icon' => 'calendar', 'classes' => [ 'ext-campaignevents-eventpage-icon' ] ] ),
214            $infoMsg
215        )->setAttributes( [ 'class' => 'ext-campaignevents-eventpage-enableheader-message' ] );
216        $infoElement = ( new Tag( 'div' ) )->appendContent( $organizerText, $infoText );
217
218        $enableRegistrationBtn = new ButtonWidget( [
219            'flags' => [ 'primary', 'progressive' ],
220            'label' => $this->msgFormatter->format(
221                MessageValue::new( 'campaignevents-eventpage-enableheader-button-label' )
222            ),
223            'classes' => [ 'ext-campaignevents-eventpage-enable-registration-btn' ],
224            'href' => $enableRegistrationURL,
225        ] );
226
227        $layout = new PanelLayout( [
228            'content' => [ $infoElement, $enableRegistrationBtn ],
229            'padded' => true,
230            'framed' => true,
231            'expanded' => false,
232            'classes' => [ 'ext-campaignevents-eventpage-enableheader' ],
233        ] );
234
235        $layout->setAttributes( [
236            // Set the lang/dir explicitly, otherwise it will use that of the site/page language,
237            // not that of the interface.
238            'dir' => $this->language->getDir(),
239            'lang' => $this->language->getHtmlCode()
240        ] );
241        return $layout;
242    }
243
244    /**
245     * @param ExistingEventRegistration $registration
246     */
247    private function addRegistrationHeader( ExistingEventRegistration $registration ): void {
248        $this->out->setPreventClickjacking( true );
249        $this->out->enableOOUI();
250        $this->out->addModuleStyles( array_merge(
251            [
252                'ext.campaignEvents.eventpage.styles',
253                'oojs-ui.styles.icons-location',
254                'oojs-ui.styles.icons-interactions',
255                'oojs-ui.styles.icons-moderation',
256                'oojs-ui.styles.icons-user',
257                'oojs-ui.styles.icons-alerts',
258            ],
259            UserLinker::MODULE_STYLES
260        ) );
261
262        $this->out->addModules( [ 'ext.campaignEvents.eventpage' ] );
263
264        try {
265            $centralUser = $this->centralUserLookup->newFromAuthority( $this->authority );
266            $curParticipant = $this->participantsStore->getEventParticipant(
267                $registration->getID(),
268                $centralUser,
269                true
270            );
271            $hasAggregatedAnswers = $this->participantsStore->userHasAggregatedAnswers(
272                $registration->getID(),
273                $centralUser
274            );
275        } catch ( UserNotGlobalException $_ ) {
276            $centralUser = null;
277            $curParticipant = null;
278            $hasAggregatedAnswers = false;
279        }
280
281        $userStatus = $this->getUserStatus( $registration, $centralUser, $curParticipant );
282
283        $this->out->addHTML( $this->getHeaderElement( $registration, $userStatus ) );
284        $this->out->addHTML(
285            $this->getDetailsDialogContent(
286                $registration,
287                $userStatus,
288                $curParticipant
289            )
290        );
291
292        $aggregationTimestamp = $curParticipant
293            ? Utils::getAnswerAggregationTimestamp( $curParticipant, $registration )
294            : null;
295        $this->out->addJsConfigVars( [
296            'wgCampaignEventsEventID' => $registration->getID(),
297            'wgCampaignEventsParticipantIsPublic' => $this->participantIsPublic,
298            'wgCampaignEventsEventQuestions' => $this->getEventQuestionsData( $registration, $curParticipant ),
299            'wgCampaignEventsAnswersAlreadyAggregated' => $hasAggregatedAnswers,
300            'wgCampaignEventsAggregationTimestamp' => $aggregationTimestamp
301        ] );
302    }
303
304    /**
305     * @param ExistingEventRegistration $registration
306     * @param Participant|null $participant
307     * @return array[]
308     */
309    private function getEventQuestionsData(
310        ExistingEventRegistration $registration,
311        ?Participant $participant
312    ): array {
313        $enabledQuestions = $registration->getParticipantQuestions();
314        $curAnswers = $participant ? $participant->getAnswers() : [];
315        $questionsToShow = EventQuestionsRegistry::getParticipantQuestionsToShow( $enabledQuestions, $curAnswers );
316
317        $questionsData = [];
318        $questionsAPI = $this->eventQuestionsRegistry->getQuestionsForAPI( $questionsToShow );
319        // Localise all messages to avoid having to do that in the client side.
320        foreach ( $questionsAPI as $questionAPIData ) {
321            $curQuestionData = [
322                'type' => $questionAPIData['type'],
323                'label' => $this->msgFormatter->format( MessageValue::new( $questionAPIData['label-message'] ) ),
324            ];
325            if ( isset( $questionAPIData['options-messages'] ) ) {
326                $curQuestionData['options'] = [];
327                foreach ( $questionAPIData['options-messages'] as $messageKey => $value ) {
328                    $message = $this->msgFormatter->format( MessageValue::new( $messageKey ) );
329                    $curQuestionData['options'][$messageKey] = [
330                        'value' => $value,
331                        'message' => $message
332                    ];
333                }
334            }
335            if ( isset( $questionAPIData['other-options'] ) ) {
336                $curQuestionData['other-options'] = [];
337                foreach ( $questionAPIData['other-options'] as $showIfVal => $otherOptData ) {
338                    $curQuestionData['other-options'][$showIfVal] = [
339                        'type' => $otherOptData['type'],
340                        'placeholder' => $this->msgFormatter->format(
341                            MessageValue::new( $otherOptData['label-message'] )
342                        ),
343                    ];
344                }
345            }
346            $questionsData[$questionAPIData['name']] = $curQuestionData;
347        }
348
349        return [
350            'questions' => $questionsData,
351            'answers' => $this->eventQuestionsRegistry->formatAnswersForAPI( $curAnswers, $enabledQuestions )
352        ];
353    }
354
355    /**
356     * Returns the header element.
357     *
358     * @param ExistingEventRegistration $registration
359     * @param int $userStatus One of the self::USER_STATUS_* constants
360     * @return Tag
361     */
362    private function getHeaderElement(
363        ExistingEventRegistration $registration,
364        int $userStatus
365    ): Tag {
366        $content = [];
367
368        $participantNoticeRow = $this->getParticipantNoticeRow( $userStatus );
369        if ( $participantNoticeRow ) {
370            $content[] = $participantNoticeRow;
371        }
372
373        $content[] = $this->getEventInfoHeaderRow( $registration, $userStatus );
374
375        $layout = new PanelLayout( [
376            'content' => $content,
377            'padded' => true,
378            'framed' => true,
379            'expanded' => false,
380            'classes' => [ 'ext-campaignevents-eventpage-header' ],
381        ] );
382
383        $layout->setAttributes( [
384            // Set the lang/dir explicitly, otherwise it will use that of the site/page language,
385            // not that of the interface.
386            'dir' => $this->language->getDir(),
387            'lang' => $this->language->getHtmlCode()
388        ] );
389
390        return $layout;
391    }
392
393    /**
394     * @param int $userStatus
395     * @return Tag|null
396     */
397    private function getParticipantNoticeRow( int $userStatus ): ?Tag {
398        if ( $userStatus !== self::USER_STATUS_PARTICIPANT_CAN_UNREGISTER ) {
399            return null;
400        }
401        $msg = $this->participantIsPublic
402            ? 'campaignevents-eventpage-header-registered-publicly'
403            : 'campaignevents-eventpage-header-registered-privately';
404        return new MessageWidget( [
405            'type' => 'success',
406            'label' => $this->msgFormatter->format( MessageValue::new( $msg ) ),
407            'inline' => true,
408            'classes' => [ 'ext-campaignevents-eventpage-participant-notice' ]
409        ] );
410    }
411
412    /**
413     * @param ExistingEventRegistration $registration
414     * @param int $userStatus
415     * @return Tag
416     */
417    private function getEventInfoHeaderRow(
418        ExistingEventRegistration $registration,
419        int $userStatus
420    ): Tag {
421        $eventID = $registration->getID();
422        $items = [];
423
424        $meetingType = $registration->getMeetingType();
425        if ( $meetingType === EventRegistration::MEETING_TYPE_ONLINE_AND_IN_PERSON ) {
426            $locationContent = $this->msgFormatter->format(
427                MessageValue::new( 'campaignevents-eventpage-header-type-online-and-in-person' )
428            );
429        } elseif ( $meetingType & EventRegistration::MEETING_TYPE_ONLINE ) {
430            $locationContent = $this->msgFormatter->format(
431                MessageValue::new( 'campaignevents-eventpage-header-type-online' )
432            );
433        } else {
434            // In-person event
435            $address = $registration->getMeetingAddress();
436            if ( $address !== null ) {
437                $locationContent = new Tag( 'div' );
438                $locationContent->setAttributes( [
439                    'dir' => Utils::guessStringDirection( $address )
440                ] );
441                $locationContent->addClasses( [ 'ext-campaignevents-eventpage-header-address' ] );
442                $locationContent->appendContent(
443                    $this->language->truncateForVisual( $address, self::ADDRESS_MAX_LENGTH )
444                );
445            } else {
446                $locationContent = $this->msgFormatter->format(
447                    MessageValue::new( 'campaignevents-eventpage-header-type-in-person' )
448                );
449            }
450        }
451        $items[] = new TextWithIconWidget( [
452            'icon' => 'mapPin',
453            'content' => $locationContent,
454            'label' => $this->msgFormatter->format(
455                MessageValue::new( 'campaignevents-eventpage-header-location-label' )
456            ),
457            'icon_classes' => [ 'ext-campaignevents-eventpage-icon' ],
458        ] );
459
460        $formattedStart = $this->eventTimeFormatter->formatStart( $registration, $this->language, $this->viewingUser );
461        $formattedEnd = $this->eventTimeFormatter->formatEnd( $registration, $this->language, $this->viewingUser );
462        $datesMsg = $this->msgFormatter->format(
463            MessageValue::new( 'campaignevents-eventpage-header-dates' )->params(
464                $formattedStart->getTimeAndDate(),
465                $formattedStart->getDate(),
466                $formattedStart->getTime(),
467                $formattedEnd->getTimeAndDate(),
468                $formattedEnd->getDate(),
469                $formattedEnd->getTime()
470            )
471        );
472        $formattedTimezone = $this->eventTimeFormatter->formatTimezone( $registration, $this->viewingUser );
473        // XXX Can't use ITextFormatter due to parse()
474        $timezoneMsg = $this->out->msg( 'campaignevents-eventpage-header-timezone' )
475            ->params( $formattedTimezone )
476            ->parse();
477        $items[] = new TextWithIconWidget( [
478            'icon' => 'clock',
479            'content' => [
480                $datesMsg,
481                ( new Tag( 'div' ) )->appendContent( new HtmlSnippet( $timezoneMsg ) )
482            ],
483            'label' => $this->msgFormatter->format(
484                MessageValue::new( 'campaignevents-eventpage-header-dates-label' )
485            ),
486            'icon_classes' => [ 'ext-campaignevents-eventpage-icon' ],
487        ] );
488
489        $items[] = new TextWithIconWidget( [
490            'icon' => 'userGroup',
491            'content' => $this->msgFormatter->format(
492                MessageValue::new( 'campaignevents-eventpage-header-participants' )
493                    ->numParams( $this->participantsStore->getFullParticipantCountForEvent( $eventID ) )
494            ),
495            'label' => $this->msgFormatter->format(
496                MessageValue::new( 'campaignevents-eventpage-header-participants-label' )
497            ),
498            'icon_classes' => [ 'ext-campaignevents-eventpage-icon' ],
499        ] );
500
501        $btnContainer = ( new Tag( 'div' ) )
502            ->addClasses( [ 'ext-campaignevents-eventpage-header-buttons' ] );
503        $btnContainer->appendContent( new ButtonWidget( [
504            'framed' => false,
505            'flags' => [ 'progressive' ],
506            'label' => $this->msgFormatter->format( MessageValue::new( 'campaignevents-eventpage-header-details' ) ),
507            'classes' => [ 'ext-campaignevents-event-details-btn' ],
508            'href' => SpecialPage::getTitleFor( SpecialEventDetails::PAGE_NAME, (string)$eventID )->getLocalURL(),
509        ] ) );
510
511        $actionElement = $this->getActionElement( $eventID, $userStatus );
512        if ( $actionElement ) {
513            $btnContainer->appendContent( $actionElement );
514        }
515
516        $items[] = $btnContainer;
517
518        return ( new Tag( 'div' ) )
519            ->addClasses( [ 'ext-campaignevents-eventpage-header-eventinfo' ] )
520            ->appendContent( ...$items );
521    }
522
523    /**
524     * Returns the content of the "more details" dialog. Unfortunately, we have to build it here rather then on the
525     * client side, for the following reasons:
526     * - There's no way to format dates according to the user preferences (T21992)
527     * - There's no easy way to get the directionality of a language (T181684)
528     * - Other utilities are missing (e.g., generating user links)
529     * - Secondarily, no need to make 3 API requests and worry about them failing.
530     *
531     * @param ExistingEventRegistration $registration
532     * @param int $userStatus One of the self::USER_STATUS_* constants
533     * @param Participant|null $participant
534     * @return string
535     */
536    private function getDetailsDialogContent(
537        ExistingEventRegistration $registration,
538        int $userStatus,
539        ?Participant $participant
540    ): string {
541        $eventID = $registration->getID();
542        $organizersCount = $this->organizersStore->getOrganizerCountForEvent( $eventID );
543
544        $eventInfoContainer = $this->getDetailsDialogEventInfo(
545            $registration,
546            $organizersCount,
547            $userStatus
548        );
549        $participantsContainer = $this->getDetailsDialogParticipants(
550            $eventID,
551            $participant
552        );
553
554        $dialogContent = Html::element(
555            'h2',
556            [ 'class' => 'ext-campaignevents-detailsdialog-header' ],
557            $registration->getName()
558        );
559        $dialogContent .= $this->getDetailsDialogOrganizers(
560            $eventID,
561            $organizersCount
562        );
563        $dialogContent .= Html::rawElement(
564            'div',
565            [ 'class' => 'ext-campaignevents-detailsdialog-body-container' ],
566            $eventInfoContainer . $participantsContainer
567        );
568
569        return Html::rawElement( 'div', [ 'id' => 'ext-campaignEvents-detailsDialog-content' ], $dialogContent );
570    }
571
572    /**
573     * @param int $eventID
574     * @param int $organizersCount
575     * @return string
576     */
577    private function getDetailsDialogOrganizers(
578        int $eventID,
579        int $organizersCount
580    ): string {
581        $partialOrganizers = $this->organizersStore->getEventOrganizers( $eventID, self::ORGANIZERS_LIMIT );
582
583        $organizerElements = [];
584        foreach ( $partialOrganizers as $organizer ) {
585            $organizerElements[] = $this->userLinker->generateUserLinkWithFallback(
586                $organizer->getUser(),
587                $this->language->getCode()
588            );
589        }
590        // XXX We need to use OutputPage here because there's no supported way to change the format of
591        // MessageFormatterFactory...
592        $organizersStr = $this->out->msg( 'campaignevents-eventpage-dialog-organizers' )
593            ->rawParams( $this->language->commaList( $organizerElements ) )
594            ->escaped();
595        if ( count( $partialOrganizers ) < $organizersCount ) {
596            $organizersStr .= Html::rawElement(
597                'p',
598                [],
599                $this->linkRenderer->makeKnownLink(
600                    SpecialPage::getTitleFor( SpecialEventDetails::PAGE_NAME, (string)$eventID ),
601                    $this->msgFormatter->format(
602                        MessageValue::new( 'campaignevents-eventpage-dialog-organizers-view-all' )
603                    )
604                )
605            );
606        }
607
608        return Html::rawElement(
609            'div',
610            [ 'class' => 'ext-campaignevents-detailsdialog-organizers' ],
611            $organizersStr
612        );
613    }
614
615    /**
616     * @param ExistingEventRegistration $registration
617     * @param int $organizersCount
618     * @param int $userStatus
619     * @return string
620     */
621    private function getDetailsDialogEventInfo(
622        ExistingEventRegistration $registration,
623        int $organizersCount,
624        int $userStatus
625    ): string {
626        $eventInfo = $this->getDetailsDialogDates( $registration );
627        $eventInfo .= $this->getDetailsDialogLocation(
628            $registration,
629            $organizersCount,
630            $userStatus
631        );
632        $eventInfo .= $this->getDetailsDialogChat( $registration, $userStatus );
633
634        return Html::rawElement(
635            'div',
636            [ 'class' => 'ext-campaignevents-detailsdialog-eventinfo-container' ],
637            $eventInfo
638        );
639    }
640
641    /**
642     * @param ExistingEventRegistration $registration
643     * @return string
644     */
645    private function getDetailsDialogDates( ExistingEventRegistration $registration ): string {
646        $formattedStart = $this->eventTimeFormatter->formatStart( $registration, $this->language, $this->viewingUser );
647        $formattedEnd = $this->eventTimeFormatter->formatEnd( $registration, $this->language, $this->viewingUser );
648        $datesMsg = $this->msgFormatter->format(
649            MessageValue::new( 'campaignevents-eventpage-dialog-dates' )->params(
650                $formattedStart->getTimeAndDate(),
651                $formattedStart->getDate(),
652                $formattedStart->getTime(),
653                $formattedEnd->getTimeAndDate(),
654                $formattedEnd->getDate(),
655                $formattedEnd->getTime()
656            )
657        );
658        $formattedTimezone = $this->eventTimeFormatter->formatTimezone( $registration, $this->viewingUser );
659        // XXX Can't use $msgFormatter due to parse()
660        $timezoneMsg = $this->out->msg( 'campaignevents-eventpage-dialog-timezone' )
661            ->params( $formattedTimezone )
662            ->parse();
663        return $this->makeDetailsDialogSection(
664            'clock',
665            [
666                $datesMsg,
667                ( new Tag( 'div' ) )->appendContent( new HtmlSnippet( $timezoneMsg ) )
668            ],
669            $this->msgFormatter->format(
670                MessageValue::new( 'campaignevents-eventpage-dialog-dates-label' )
671            )
672        );
673    }
674
675    /**
676     * @param ExistingEventRegistration $registration
677     * @param int $organizersCount
678     * @param int $userStatus
679     * @return string
680     */
681    private function getDetailsDialogLocation(
682        ExistingEventRegistration $registration,
683        int $organizersCount,
684        int $userStatus
685    ): string {
686        $locationElements = [];
687        $onlineLocationElements = [];
688        if ( $registration->getMeetingType() & EventRegistration::MEETING_TYPE_ONLINE ) {
689            $onlineLocationElements[] = ( new Tag( 'h4' ) )
690                ->addClasses( [ 'ext-campaignevents-eventpage-detailsdialog-location-header' ] )
691                ->appendContent(
692                    $this->msgFormatter->format(
693                        MessageValue::new( 'campaignevents-eventpage-dialog-online-label' )
694                    ) );
695            $meetingURL = $registration->getMeetingURL();
696            if ( $meetingURL === null ) {
697                $linkContent = $this->msgFormatter->format(
698                    MessageValue::new( 'campaignevents-eventpage-dialog-link-not-available' )
699                        ->numParams( $organizersCount )
700                );
701            } elseif (
702                $userStatus === self::USER_STATUS_ORGANIZER ||
703                $userStatus === self::USER_STATUS_PARTICIPANT_CAN_UNREGISTER
704            ) {
705                $linkContent = new HtmlSnippet( Linker::makeExternalLink( $meetingURL, $meetingURL ) );
706            } elseif ( $userStatus === self::USER_STATUS_CAN_REGISTER ) {
707                $linkContent = $this->msgFormatter->format(
708                    MessageValue::new( 'campaignevents-eventpage-dialog-link-register' )
709                );
710            } elseif (
711                $userStatus === self::USER_STATUS_BLOCKED ||
712                $userStatus === self::USER_STATUS_CANNOT_REGISTER_CLOSED ||
713                $userStatus === self::USER_STATUS_CANNOT_REGISTER_ENDED
714            ) {
715                $linkContent = '';
716            } else {
717                throw new LogicException( "Unexpected user status $userStatus" );
718            }
719            $onlineLocationElements[] = ( new Tag( 'p' ) )->appendContent( $linkContent );
720        }
721        if ( $registration->getMeetingType() & EventRegistration::MEETING_TYPE_IN_PERSON ) {
722            $rawAddress = $registration->getMeetingAddress();
723            $rawCountry = $registration->getMeetingCountry();
724            $addressElement = new Tag( 'p' );
725            $addressElement->addClasses( [ 'ext-campaignevents-eventpage-details-address' ] );
726            if ( $rawAddress || $rawCountry ) {
727                $address = $rawAddress . "\n" . $rawCountry;
728                $addressElement->setAttributes( [
729                    'dir' => Utils::guessStringDirection( $address )
730                ] );
731                $addressElement->appendContent( $address );
732            } else {
733                $addressElement->appendContent( $this->msgFormatter->format(
734                    MessageValue::new( 'campaignevents-eventpage-dialog-venue-not-available' )
735                        ->numParams( $organizersCount )
736                ) );
737            }
738            if ( $onlineLocationElements ) {
739                $inPersonLabel = ( new Tag( 'h4' ) )
740                    ->addClasses( [ 'ext-campaignevents-eventpage-detailsdialog-location-header' ] )
741                    ->appendContent( $this->msgFormatter->format(
742                        MessageValue::new( 'campaignevents-eventpage-dialog-in-person-label' )
743                    ) );
744                $locationElements[] = $inPersonLabel;
745                $locationElements[] = $addressElement;
746                $locationElements = array_merge( $locationElements, $onlineLocationElements );
747            } else {
748                $locationElements[] = $addressElement;
749            }
750        } else {
751            $locationElements = array_merge( $locationElements, $onlineLocationElements );
752        }
753        return $this->makeDetailsDialogSection(
754            'mapPin',
755            $locationElements,
756            $this->msgFormatter->format(
757                MessageValue::new( 'campaignevents-eventpage-dialog-location-label' )
758            )
759        );
760    }
761
762    /**
763     * @param ExistingEventRegistration $registration
764     * @param int $userStatus
765     * @return string
766     */
767    private function getDetailsDialogChat(
768        ExistingEventRegistration $registration,
769        int $userStatus
770    ): string {
771        $chatURL = $registration->getChatURL();
772        if ( $chatURL === null ) {
773            $chatURLContent = $this->msgFormatter->format(
774                MessageValue::new( 'campaignevents-eventpage-dialog-chat-not-available' )
775            );
776        } elseif (
777            $userStatus === self::USER_STATUS_ORGANIZER ||
778            $userStatus === self::USER_STATUS_PARTICIPANT_CAN_UNREGISTER
779        ) {
780            $chatURLContent = new HtmlSnippet( Linker::makeExternalLink( $chatURL, $chatURL ) );
781        } elseif ( $userStatus === self::USER_STATUS_CAN_REGISTER ) {
782            $chatURLContent = $this->msgFormatter->format(
783                MessageValue::new( 'campaignevents-eventpage-dialog-chat-register' )
784            );
785        } elseif (
786            $userStatus === self::USER_STATUS_BLOCKED ||
787            $userStatus === self::USER_STATUS_CANNOT_REGISTER_CLOSED ||
788            $userStatus === self::USER_STATUS_CANNOT_REGISTER_ENDED
789        ) {
790            $chatURLContent = '';
791        } else {
792            throw new LogicException( "Unexpected user status $userStatus" );
793        }
794        return $this->makeDetailsDialogSection(
795            'speechBubbles',
796            $chatURLContent,
797            $this->msgFormatter->format(
798                MessageValue::new( 'campaignevents-eventpage-dialog-chat-label' )
799            )
800        );
801    }
802
803    /**
804     * @param int $eventID
805     * @param Participant|null $participant
806     * @return string
807     */
808    private function getDetailsDialogParticipants(
809        int $eventID,
810        ?Participant $participant
811    ): string {
812        $showPrivateParticipants = $this->permissionChecker->userCanViewPrivateParticipants(
813            $this->authority,
814            $eventID
815        );
816        $participantsCount = $this->participantsStore->getFullParticipantCountForEvent( $eventID );
817        $privateCount = $this->participantsStore->getPrivateParticipantCountForEvent( $eventID );
818        $participantsList = $this->getParticipantRows(
819            $eventID,
820            $participant,
821            $showPrivateParticipants
822        );
823        $participantsFooter = '';
824        if ( self::PARTICIPANTS_LIMIT < $participantsCount ) {
825            $participantsFooter = $this->getParticipantFooter( $eventID );
826        }
827
828        $privateCountFooter = '';
829        if ( $privateCount > 0 ) {
830            $privateCountFooter = new Tag();
831            $privateCountFooter->addClasses( [
832                'ext-campaignevents-detailsdialog-private-participants-footer'
833            ] );
834            $privateCountIcon = new IconWidget( [
835                'icon' => 'lock'
836            ] );
837            $privateCountText = ( new Tag( 'span' ) )
838                ->addClasses( [ 'ext-campaignevents-detailsdialog-private-participants-footer-text' ] );
839            $privateCountText->appendContent(
840                $this->msgFormatter->format(
841                    MessageValue::new( 'campaignevents-eventpage-dialog-participants-private' )
842                        ->numParams( $privateCount )
843                )
844            );
845
846            $privateCountFooter->appendContent( [ $privateCountIcon, $privateCountText ] );
847        }
848
849        return $this->makeDetailsDialogSection(
850            'userGroup',
851            [ $participantsList ?: '', $participantsFooter ],
852            $this->msgFormatter->format(
853                MessageValue::new( 'campaignevents-eventpage-dialog-participants' )
854                    ->numParams( $participantsCount )
855            ),
856            $privateCountFooter
857        );
858    }
859
860    /**
861     * @param int $eventID
862     * @param Participant|null $curUserParticipant
863     * @param bool $showPrivateParticipants
864     *
865     * @return Tag|null
866     */
867    private function getParticipantRows(
868        int $eventID,
869        ?Participant $curUserParticipant,
870        bool $showPrivateParticipants
871    ): ?Tag {
872        $participantsList = ( new Tag( 'ul' ) )
873            ->addClasses( [ 'ext-campaignevents-detailsdialog-participants-list' ] );
874        $partialParticipants = $this->participantsStore->getEventParticipants(
875            $eventID,
876            $curUserParticipant ?
877                self::PARTICIPANTS_LIMIT - 1 :
878                self::PARTICIPANTS_LIMIT,
879            null,
880            null,
881            null,
882            $showPrivateParticipants,
883            $curUserParticipant ? [ $curUserParticipant->getUser()->getCentralID() ] : null
884        );
885
886        if ( !$curUserParticipant && !$partialParticipants ) {
887            return null;
888        }
889
890        if ( $curUserParticipant ) {
891            $participantsList->appendContent(
892                $this->getParticipantRow( $curUserParticipant )
893            );
894        }
895        foreach ( $partialParticipants as $participant ) {
896            $participantsList->appendContent(
897                $this->getParticipantRow( $participant )
898            );
899        }
900        return $participantsList;
901    }
902
903    /**
904     * Returns the "action" element for the header (that are also cloned into the popup). This can be a button for
905     * managing the event, or one to register for it. Or it can be a widget informing the user that they are already
906     * registered, with a button to unregister. There can also be no element if the user is not allowed to register.
907     *
908     * @param int $eventID
909     * @param int $userStatus
910     * @return Element|null
911     */
912    private function getActionElement( int $eventID, int $userStatus ): ?Element {
913        if ( $userStatus === self::USER_STATUS_BLOCKED ) {
914            return null;
915        }
916
917        if (
918            $userStatus === self::USER_STATUS_CANNOT_REGISTER_CLOSED ||
919            $userStatus === self::USER_STATUS_CANNOT_REGISTER_ENDED
920        ) {
921            $msgKey = $userStatus === self::USER_STATUS_CANNOT_REGISTER_CLOSED
922                ? 'campaignevents-eventpage-btn-registration-closed'
923                : 'campaignevents-eventpage-btn-event-ended';
924            return new ButtonWidget( [
925                'disabled' => true,
926                'label' => $this->msgFormatter->format( MessageValue::new( $msgKey ) ),
927                'classes' => [
928                    'ext-campaignevents-eventpage-action-element'
929                ],
930            ] );
931        }
932
933        if ( $userStatus === self::USER_STATUS_ORGANIZER ) {
934            return new ButtonWidget( [
935                'flags' => [ 'progressive' ],
936                'label' => $this->msgFormatter->format( MessageValue::new( 'campaignevents-eventpage-btn-manage' ) ),
937                'classes' => [
938                    'ext-campaignevents-eventpage-action-element',
939                ],
940                'href' => SpecialPage::getTitleFor(
941                    SpecialEventDetails::PAGE_NAME,
942                    (string)$eventID
943                )->getLocalURL(),
944            ] );
945        }
946
947        if ( $userStatus === self::USER_STATUS_PARTICIPANT_CAN_UNREGISTER ) {
948            $unregisterURL = SpecialPage::getTitleFor(
949                SpecialCancelEventRegistration::PAGE_NAME,
950                (string)$eventID
951            )->getLocalURL();
952
953            // Note that this will be replaced with a ButtonMenuSelectWidget in JS.
954            return new HorizontalLayout( [
955                'items' => [
956                    new ButtonWidget( [
957                        'flags' => [ 'progressive' ],
958                        'label' => $this->msgFormatter->format(
959                            MessageValue::new( 'campaignevents-eventpage-btn-edit' )
960                        ),
961                        'href' => SpecialPage::getTitleFor( SpecialRegisterForEvent::PAGE_NAME, (string)$eventID )
962                            ->getLocalURL(),
963                    ] ),
964                    new ButtonWidget( [
965                        'flags' => [ 'destructive' ],
966                        'label' => $this->msgFormatter->format(
967                            MessageValue::new( 'campaignevents-eventpage-btn-cancel' )
968                        ),
969                        'href' => $unregisterURL,
970                    ] )
971                ],
972                'classes' => [
973                    'ext-campaignevents-eventpage-manage-registration-layout',
974                    'ext-campaignevents-eventpage-action-element'
975                ]
976            ] );
977        }
978
979        if ( $userStatus === self::USER_STATUS_CAN_REGISTER ) {
980            return new ButtonWidget( [
981                'flags' => [ 'primary', 'progressive' ],
982                'label' => $this->msgFormatter->format( MessageValue::new( 'campaignevents-eventpage-btn-register' ) ),
983                'classes' => [
984                    'ext-campaignevents-eventpage-register-btn',
985                    'ext-campaignevents-eventpage-action-element'
986                ],
987                'href' => SpecialPage::getTitleFor( SpecialRegisterForEvent::PAGE_NAME, (string)$eventID )
988                    ->getLocalURL(),
989            ] );
990        }
991        throw new LogicException( "Unexpected user status $userStatus" );
992    }
993
994    /**
995     * @param ExistingEventRegistration $event
996     * @param CentralUser|null $centralUser Corresponding to $this->authority, if it exists
997     * @param Participant|null $participant For $centralUser, if they're a participant
998     * @return int One of the SELF::USER_STATUS_* constants
999     */
1000    private function getUserStatus(
1001        ExistingEventRegistration $event,
1002        ?CentralUser $centralUser,
1003        ?Participant $participant
1004    ): int {
1005        // Do not check user blocks or other user-dependent conditions for logged-out users, so that we can serve the
1006        // same (cached) version of the page to everyone. Also, even if the IP is blocked, the user might have an
1007        // account that they can log into, so showing the button is fine.
1008        if ( $centralUser ) {
1009            if ( $this->authority->isSitewideBlocked() ) {
1010                return self::USER_STATUS_BLOCKED;
1011            }
1012
1013            if ( $this->organizersStore->isEventOrganizer( $event->getID(), $centralUser ) ) {
1014                return self::USER_STATUS_ORGANIZER;
1015            }
1016
1017            if ( $participant ) {
1018                $checkUnregistrationAllowedVal = UnregisterParticipantCommand::checkIsUnregistrationAllowed( $event );
1019                switch ( $checkUnregistrationAllowedVal ) {
1020                    case UnregisterParticipantCommand::CANNOT_UNREGISTER_DELETED:
1021                        throw new UnexpectedValueException( "Registration should not be deleted at this point." );
1022                    case UnregisterParticipantCommand::CAN_UNREGISTER:
1023                        $this->participantIsPublic = !$participant->isPrivateRegistration();
1024                        return self::USER_STATUS_PARTICIPANT_CAN_UNREGISTER;
1025                    default:
1026                        throw new UnexpectedValueException( "Unexpected value $checkUnregistrationAllowedVal" );
1027                }
1028            }
1029        }
1030
1031        // User is logged-in and not already participating, or logged-out, in which case we'll know better
1032        // once they log in.
1033        $checkRegistrationAllowedVal = RegisterParticipantCommand::checkIsRegistrationAllowed( $event );
1034        switch ( $checkRegistrationAllowedVal ) {
1035            case RegisterParticipantCommand::CANNOT_REGISTER_DELETED:
1036                throw new UnexpectedValueException( "Registration should not be deleted at this point." );
1037            case RegisterParticipantCommand::CANNOT_REGISTER_ENDED:
1038                return self::USER_STATUS_CANNOT_REGISTER_ENDED;
1039            case RegisterParticipantCommand::CANNOT_REGISTER_CLOSED:
1040                return self::USER_STATUS_CANNOT_REGISTER_CLOSED;
1041            case RegisterParticipantCommand::CAN_REGISTER:
1042                return self::USER_STATUS_CAN_REGISTER;
1043            default:
1044                throw new UnexpectedValueException( "Unexpected value $checkRegistrationAllowedVal" );
1045        }
1046    }
1047
1048    /**
1049     * @param int $eventID
1050     * @return Tag
1051     */
1052    private function getParticipantFooter( int $eventID ): Tag {
1053        $viewParticipantsURL = SpecialPage::getTitleFor( SpecialEventDetails::PAGE_NAME, (string)$eventID )
1054            ->getLocalURL( [ 'tab' => SpecialEventDetails::PARTICIPANTS_PANEL ] );
1055        return new ButtonWidget( [
1056            'framed' => false,
1057            'flags' => [ 'progressive' ],
1058            'label' => $this->msgFormatter->format(
1059                MessageValue::new( 'campaignevents-eventpage-dialog-participants-view-list' )
1060            ),
1061            'href' => $viewParticipantsURL,
1062        ] );
1063    }
1064
1065    /**
1066     * @param Participant $participant
1067     * @return Tag
1068     */
1069    private function getParticipantRow( Participant $participant ): Tag {
1070        $usernameElement = new HtmlSnippet(
1071            $this->userLinker->generateUserLinkWithFallback(
1072                $participant->getUser(),
1073                $this->language->getCode()
1074            )
1075        );
1076
1077        $tag = ( new Tag( 'li' ) )
1078            ->appendContent( $usernameElement );
1079
1080        if ( $participant->isPrivateRegistration() ) {
1081            try {
1082                $userName = $this->centralUserLookup->getUserName( $participant->getUser() );
1083            } catch ( CentralUserNotFoundException | HiddenCentralUserException $_ ) {
1084                // Hack: use an invalid username to force unspecified gender
1085                $userName = '@';
1086            }
1087            $labelText = $this->msgFormatter->format(
1088                MessageValue::new( 'campaignevents-eventpage-dialog-private-registration-label' )
1089                    ->params( $userName )
1090            );
1091            $tag->appendContent( new IconWidget( [
1092                    'icon' => 'lock',
1093                    'title' => $labelText,
1094                    'label' => $labelText,
1095                    'classes' => [ 'ext-campaignevents-event-details-participants-private-icon' ]
1096                ] )
1097            );
1098        }
1099
1100        return $tag;
1101    }
1102
1103    /**
1104     * @param string $icon
1105     * @param string|Tag|array $content
1106     * @param string $label
1107     * @param string|Tag|array $footer
1108     * @return string
1109     */
1110    private function makeDetailsDialogSection( string $icon, $content, string $label, $footer = '' ): string {
1111        $iconWidget = new IconWidget( [
1112            'icon' => $icon,
1113            'classes' => [ 'ext-campaignevents-eventpage-detailsdialog-section-icon' ]
1114        ] );
1115        $header = ( new Tag( 'h3' ) )
1116            ->appendContent( $iconWidget, ( new Tag( 'span' ) )->appendContent( $label ) )
1117            ->addClasses( [ 'ext-campaignevents-eventpage-detailsdialog-section-header' ] );
1118
1119        $contentTag = ( new Tag( 'div' ) )
1120            ->appendContent( $content )
1121            ->addClasses( [ 'ext-campaignevents-eventpage-detailsdialog-section-content' ] );
1122
1123        return (string)( new Tag( 'div' ) )
1124            ->appendContent( $header, $contentTag, $footer );
1125    }
1126}