Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 125
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommunityUpdates
0.00% covered (danger)
0.00%
0 / 125
0.00% covered (danger)
0.00%
0 / 15
992
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 initializeProvider
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 shouldShowCommunityUpdatesModule
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getCssClasses
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canRender
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getThumbnail
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 generateThumbnailHtml
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getThumbnailUrlFromCommonsApi
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
30
 getBody
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
30
 getActionData
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 shouldWrapModuleWithLink
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMobileSummaryBody
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMobileSummaryHeader
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderIconName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace GrowthExperiments\HomepageModules;
4
5use GrowthExperiments\ExperimentUserManager;
6use GrowthExperiments\Util;
7use HtmlArmor;
8use MediaWiki\Config\Config;
9use MediaWiki\Context\IContextSource;
10use MediaWiki\Extension\CommunityConfiguration\Provider\ConfigurationProviderFactory;
11use MediaWiki\Extension\CommunityConfiguration\Provider\IConfigurationProvider;
12use MediaWiki\Html\Html;
13use MediaWiki\Http\HttpRequestFactory;
14use MediaWiki\Linker\LinkRenderer;
15use MediaWiki\Title\TitleFactory;
16use MediaWiki\User\UserEditTracker;
17use Psr\Log\LoggerInterface;
18use stdClass;
19use Wikimedia\ObjectCache\WANObjectCache;
20
21class CommunityUpdates extends BaseModule {
22    private LoggerInterface $logger;
23    private ?IConfigurationProvider $provider = null;
24    private ConfigurationProviderFactory $providerFactory;
25    private UserEditTracker $userEditTracker;
26    private LinkRenderer $linkRenderer;
27    private TitleFactory $titleFactory;
28    private WANObjectCache $cache;
29    private HttpRequestFactory $httpRequestFactory;
30
31    public function __construct(
32        LoggerInterface $logger,
33        IContextSource $context,
34        Config $wikiConfig,
35        ExperimentUserManager $experimentUserManager,
36        ConfigurationProviderFactory $providerFactory,
37        UserEditTracker $userEditTracker,
38        LinkRenderer $linkRenderer,
39        TitleFactory $titleFactory,
40        WANObjectCache $cache,
41        HttpRequestFactory $httpRequestFactory
42    ) {
43        parent::__construct( 'community-updates', $context, $wikiConfig, $experimentUserManager );
44        $this->logger = $logger;
45        $this->providerFactory = $providerFactory;
46        $this->userEditTracker = $userEditTracker;
47        $this->linkRenderer = $linkRenderer;
48        $this->titleFactory = $titleFactory;
49        $this->cache = $cache;
50        $this->httpRequestFactory = $httpRequestFactory;
51    }
52
53    private function initializeProvider() {
54        if ( !$this->provider ) {
55            $this->provider = $this->providerFactory->newProvider( 'CommunityUpdates' );
56        }
57    }
58
59    private function shouldShowCommunityUpdatesModule( stdClass $config ): bool {
60        return $config->GEHomepageCommunityUpdatesContentTitle !== '' &&
61            $config->GEHomepageCommunityUpdatesContentBody !== '' &&
62            ( $this->userEditTracker->getUserEditCount( $this->getUser() ) >=
63                $config->GEHomepageCommunityUpdatesMinEdits );
64    }
65
66    /**
67     * @inheritDoc
68     */
69    protected function getCssClasses(): array {
70        return array_merge( parent::getCssClasses(),
71            // Enable "Poor man's dark mode" per-module. Temporary workaround for T357699.
72            // FIXME: This should be removed when there is capacity for updating the extension
73            // to use Codex design tokens.
74            [ 'notheme skin-invert ' ],
75        );
76    }
77
78    /**
79     * @inheritDoc
80     */
81    protected function getHeaderText() {
82        return $this->getContext()->msg( 'growthexperiments-homepage-community-updates-header' )->text();
83    }
84
85    /**
86     * Determines if the CommunityUpdates module can be rendered based on configuration and other conditions.
87     * @return bool
88     */
89    protected function canRender(): bool {
90        $this->initializeProvider();
91        $configStatus = $this->provider->loadValidConfiguration();
92        if ( !$configStatus->isOK() ) {
93            return false;
94        }
95        $config = $configStatus->getValue();
96        return $config->GEHomepageCommunityUpdatesEnabled && $this->shouldShowCommunityUpdatesModule( $config );
97    }
98
99    private function getThumbnail( string $fileTitle ): string {
100        $cacheKey = $this->cache->makeKey( 'community-updates-thumburl', md5( $fileTitle ) );
101        $cachedThumbUrl = $this->cache->get( $cacheKey );
102
103        if ( $cachedThumbUrl ) {
104            return $this->generateThumbnailHtml( $cachedThumbUrl );
105        }
106
107        $thumbUrl = $this->getThumbnailUrlFromCommonsApi( $fileTitle );
108        if ( $thumbUrl ) {
109            $this->cache->set( $cacheKey, $thumbUrl, $this->cache::TTL_HOUR );
110            return $this->generateThumbnailHtml( $thumbUrl );
111        }
112
113        return '';
114    }
115
116    /**
117     * Generates the HTML for a thumbnail image.
118     *
119     * This method generates HTML on each call rather than caching the full HTML.
120     * This approach was chosen for the following reasons:
121     * 1. Flexibility: Allows easy updates to HTML structure without invalidating caches.
122     * 2. Separation of concerns: Keeps caching logic separate from presentation logic.
123     * 3. Future-proofing: Facilitates easier implementation of responsive images or other
124     *    advanced features in the future.
125     *
126     * Trade-offs and considerations:
127     * - Performance: There's a small performance cost of generating HTML on each request.
128     *   However, this is typically a lightweight operation compared to API calls or DB queries.
129     * - Caching: We're still caching the thumbnail URL, which provides the main performance benefit
130     *   by avoiding repeated API calls to Commons.
131     * - Flexibility vs. Performance: We've prioritized flexibility and maintainability over the
132     *   minor performance gain of caching full HTML.
133     *
134     * Future considerations:
135     * - If performance becomes a critical issue, we can consider implementing a short-lived cache
136     *   for the generated HTML in addition to caching the URL.
137     * - Monitor performance metrics to ensure this approach meets performance requirements.
138     *
139     * @param string $thumbUrl The URL of the thumbnail image
140     * @return string The generated HTML for the thumbnail
141     */
142    private function generateThumbnailHtml( string $thumbUrl ): string {
143        $thumbnailContent = Html::rawElement( 'img', [
144            'class' => 'cdx-thumbnail__image ext-growthExperiments-CommunityUpdates__thumbnail__image',
145            'src' => $thumbUrl,
146            'alt' => ''
147        ] );
148        return Html::rawElement( 'div', [ 'class' => 'cdx-card__thumbnail' ], $thumbnailContent );
149    }
150
151    private function getThumbnailUrlFromCommonsApi( string $fileTitle ): string {
152        $apiUrl = $this->getGrowthWikiConfig()->get( 'CommunityConfigurationCommonsApiURL' );
153        if ( !$apiUrl ) {
154            $this->logger->debug(
155                'CommunityUpdates module displaying without images support: Commons API URL is undefined'
156            );
157            return '';
158        }
159        // The thumbnail width is set to 120px, which is 3x the standard Codex thumbnail size (40px).
160        // This provides a high-quality image that can be scaled down for various display sizes
161        // while maintaining clarity and allowing for high-DPI displays.
162        $thumbnailWidth = 120;
163        $params = [
164            'action' => 'query',
165            'format' => 'json',
166            'prop' => 'imageinfo',
167            'titles' => $fileTitle,
168            'iiprop' => 'url|size',
169            'iiurlwidth' => $thumbnailWidth
170        ];
171
172        $url = wfAppendQuery( $apiUrl, $params );
173        $status = Util::getJsonUrl(
174            $this->httpRequestFactory,
175            $url,
176            // Assume we're on the same wikifarm, this enhances request with extra headers that
177            // are ocassionally useful for debugging
178            true
179        );
180
181        if ( !$status->isOK() ) {
182            Util::logStatus( $status );
183            return '';
184        }
185
186        $response = $status->getValue();
187        if ( isset( $response['query']['pages'] ) ) {
188            $page = reset( $response['query']['pages'] );
189            if ( isset( $page['imageinfo'][0]['thumburl'] ) ) {
190                return $page['imageinfo'][0]['thumburl'];
191            }
192        }
193
194        $this->logger->error(
195            'CommunityUpdates failed to find thumburl in Commons API response',
196            [
197                'exception' => new \RuntimeException,
198                'response' => $response,
199            ]
200        );
201        return '';
202    }
203
204    /**
205     * @inheritDoc
206     */
207    protected function getBody() {
208        $this->initializeProvider();
209        if ( !$this->canRender() ) {
210            return '';
211        }
212        $config = $this->provider->loadValidConfiguration()->getValue();
213        $contentTitle = $config->GEHomepageCommunityUpdatesContentTitle;
214        $contentBody = $config->GEHomepageCommunityUpdatesContentBody;
215        $callToAction = $config->GEHomepageCommunityUpdatesCallToAction;
216
217        $pageTitle = $this->titleFactory->newFromText( $callToAction->pageTitle );
218        $link = '';
219        if ( $pageTitle ) {
220            $buttonText = $callToAction->buttonText ?: $this->getContext()->msg(
221                'growthexperiments-homepage-communityupdates-calltoaction-button'
222            )->text();
223
224            $link = $this->linkRenderer->makeLink( $pageTitle, $buttonText, [
225                'class' => 'ext-growthExperiments-CommunityUpdates__CallToAction__link',
226                'data-link-id' => 'community-updates-cta',
227                'data-link-data' => $pageTitle->getDBkey()
228            ] );
229        }
230
231        $thumbnail = '';
232        if ( $config->GEHomepageCommunityUpdatesThumbnailFile->title !== '' ) {
233            $thumbnail = $this->getThumbnail( $config->GEHomepageCommunityUpdatesThumbnailFile->title );
234        }
235
236        return Html::rawElement( 'div', [ 'class' => 'cdx-card-content' ],
237            Html::rawElement( 'div', [ 'class' => 'cdx-card-content-row-1' ],
238                $thumbnail .
239                Html::rawElement(
240                    'div', [ 'class' => 'cdx-card__text__title' ], HtmlArmor::getHtml( $contentTitle )
241                )
242            ) .
243            Html::rawElement( 'div', [ 'class' => 'cdx-card-content-row-2' ],
244                Html::rawElement(
245                    'div', [ 'class' => 'cdx-card__text__description' ], HtmlArmor::getHtml( $contentBody )
246                ) . $link
247            )
248        );
249    }
250
251    public function getActionData(): array {
252        $result = $this->provider->loadValidConfiguration();
253        if ( !$result->isOK() ) {
254            return parent::getActionData();
255        }
256        $config = $result->getValue();
257        $cleanTitle = preg_replace( '/[^a-zA-Z0-9_ -]/s', '',
258            $config->GEHomepageCommunityUpdatesContentTitle
259        );
260        $updateTitle = strtolower( implode( "_", explode( " ", $cleanTitle ) ) );
261
262        return array_merge( parent::getActionData(), [
263            'context' => $updateTitle
264        ] );
265    }
266
267    public function shouldWrapModuleWithLink(): bool {
268        return false;
269    }
270
271    /**
272     * @inheritDoc
273     */
274    protected function getMobileSummaryBody() {
275        return $this->getBody();
276    }
277
278    /**
279     * @inheritDoc
280     */
281    protected function getMobileSummaryHeader() {
282        return $this->getHeaderTextElement();
283    }
284
285    /**
286     * @inheritDoc
287     */
288    protected function getHeaderIconName() {
289        return '';
290    }
291}