Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 125 |
|
0.00% |
0 / 15 |
CRAP | |
0.00% |
0 / 1 |
CommunityUpdates | |
0.00% |
0 / 125 |
|
0.00% |
0 / 15 |
992 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
initializeProvider | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
shouldShowCommunityUpdatesModule | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
getCssClasses | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getHeaderText | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
canRender | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
getThumbnail | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
generateThumbnailHtml | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getThumbnailUrlFromCommonsApi | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
30 | |||
getBody | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
30 | |||
getActionData | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
shouldWrapModuleWithLink | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getMobileSummaryBody | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getMobileSummaryHeader | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getHeaderIconName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\HomepageModules; |
4 | |
5 | use GrowthExperiments\ExperimentUserManager; |
6 | use GrowthExperiments\Util; |
7 | use HtmlArmor; |
8 | use MediaWiki\Config\Config; |
9 | use MediaWiki\Context\IContextSource; |
10 | use MediaWiki\Extension\CommunityConfiguration\Provider\ConfigurationProviderFactory; |
11 | use MediaWiki\Extension\CommunityConfiguration\Provider\IConfigurationProvider; |
12 | use MediaWiki\Html\Html; |
13 | use MediaWiki\Http\HttpRequestFactory; |
14 | use MediaWiki\Linker\LinkRenderer; |
15 | use MediaWiki\Title\TitleFactory; |
16 | use MediaWiki\User\UserEditTracker; |
17 | use Psr\Log\LoggerInterface; |
18 | use stdClass; |
19 | use Wikimedia\ObjectCache\WANObjectCache; |
20 | |
21 | class 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 | } |