Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
37.88% |
25 / 66 |
|
43.75% |
7 / 16 |
CRAP | |
0.00% |
0 / 1 |
NewcomerTasksUserOptionsLookup | |
37.88% |
25 / 66 |
|
43.75% |
7 / 16 |
277.48 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getTaskTypeFilter | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getTopics | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getTopicsMatchMode | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
getTopicFilterWithoutFallback | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
areLinkRecommendationsEnabled | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
areImageRecommendationsEnabled | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
areSectionImageRecommendationsEnabled | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
filterTaskTypes | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
getDefaultTaskTypes | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getConversionMap | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
convertTaskTypes | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
filterNonExistentTaskTypes | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getJsonListOption | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
getStringOption | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
shouldUserSeeAllTaskTypes | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\NewcomerTasks; |
4 | |
5 | use GrowthExperiments\ExperimentUserManager; |
6 | use GrowthExperiments\HomepageModules\SuggestedEdits; |
7 | use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader; |
8 | use GrowthExperiments\NewcomerTasks\TaskSuggester\SearchStrategy\SearchStrategy; |
9 | use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskTypeHandler; |
10 | use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskTypeHandler; |
11 | use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskTypeHandler; |
12 | use GrowthExperiments\VariantHooks; |
13 | use MediaWiki\Config\Config; |
14 | use MediaWiki\User\Options\UserOptionsLookup; |
15 | use MediaWiki\User\UserIdentity; |
16 | |
17 | /** |
18 | * Retrieves user settings related to newcomer tasks. |
19 | */ |
20 | class NewcomerTasksUserOptionsLookup { |
21 | |
22 | /** |
23 | * This property isn't used, but we want to preserve the ability to run A/B tests where |
24 | * user options depend on the user's experiment group. |
25 | * @var ExperimentUserManager |
26 | */ |
27 | private $experimentUserManager; |
28 | |
29 | /** @var UserOptionsLookup */ |
30 | private $userOptionsLookup; |
31 | |
32 | /** @var Config */ |
33 | private $config; |
34 | |
35 | /** @var ConfigurationLoader */ |
36 | private $configurationLoader; |
37 | |
38 | /** |
39 | * @param ExperimentUserManager $experimentUserManager |
40 | * @param UserOptionsLookup $userOptionsLookup |
41 | * @param Config $config |
42 | * @param ConfigurationLoader $configurationLoader |
43 | */ |
44 | public function __construct( |
45 | ExperimentUserManager $experimentUserManager, |
46 | UserOptionsLookup $userOptionsLookup, |
47 | Config $config, |
48 | ConfigurationLoader $configurationLoader |
49 | ) { |
50 | $this->experimentUserManager = $experimentUserManager; |
51 | $this->userOptionsLookup = $userOptionsLookup; |
52 | $this->config = $config; |
53 | $this->configurationLoader = $configurationLoader; |
54 | } |
55 | |
56 | /** |
57 | * Get user's task types given their preferences and community configuration. |
58 | * @param UserIdentity $user |
59 | * @return string[] A list of task type IDs, or the default task types when the user |
60 | * has no preference set. |
61 | * @see \GrowthExperiments\NewcomerTasks\TaskType\TaskType::getId() |
62 | */ |
63 | public function getTaskTypeFilter( UserIdentity $user ): array { |
64 | $taskTypes = $this->getJsonListOption( $user, SuggestedEdits::TASKTYPES_PREF ); |
65 | // Filter out invalid task types for the user and use defaults based on user options. |
66 | if ( !$taskTypes ) { |
67 | $taskTypes = $this->getDefaultTaskTypes( $user ); |
68 | } |
69 | return $this->filterNonExistentTaskTypes( $this->convertTaskTypes( $taskTypes, $user ) ); |
70 | } |
71 | |
72 | /** |
73 | * Get the given user's topic preferences. |
74 | * @param UserIdentity $user |
75 | * @return string[] A list of topic IDs, or an empty array when the user has |
76 | * no preference set (This is method is meant to be compatible with TaskSuggester |
77 | * which takes an empty array as "no filtering".) |
78 | * @see \GrowthExperiments\NewcomerTasks\Topic\Topic::getId() |
79 | */ |
80 | public function getTopics( UserIdentity $user ): array { |
81 | return $this->getTopicFilterWithoutFallback( $user ) ?? []; |
82 | } |
83 | |
84 | /** |
85 | * Get the given user's topic mode preference. |
86 | * @param UserIdentity $user |
87 | * @return string A string representing the topic match mode. |
88 | * One of ( 'AND', 'OR'). |
89 | * @see SearchStrategy::TOPIC_MATCH_MODES |
90 | */ |
91 | public function getTopicsMatchMode( UserIdentity $user ): string { |
92 | $matchMode = $this->getStringOption( $user, SuggestedEdits::TOPICS_MATCH_MODE_PREF ) ?? |
93 | SearchStrategy::TOPIC_MATCH_MODE_OR; |
94 | if ( $matchMode === SearchStrategy::TOPIC_MATCH_MODE_AND && |
95 | !$this->config->get( 'GETopicsMatchModeEnabled' ) ) { |
96 | $matchMode = SearchStrategy::TOPIC_MATCH_MODE_OR; |
97 | } |
98 | return $matchMode; |
99 | } |
100 | |
101 | /** |
102 | * Get the given user's topic preferences without a fallback to empty array. |
103 | * @param UserIdentity $user |
104 | * @return string[]|null A list of topic IDs, an empty array when the user has |
105 | * no preference set, or null if preference wasn't set or is invalid. |
106 | */ |
107 | public function getTopicFilterWithoutFallback( UserIdentity $user ): ?array { |
108 | return $this->getJsonListOption( $user, SuggestedEdits::getTopicFiltersPref( $this->config ) ); |
109 | } |
110 | |
111 | /** |
112 | * Check if link recommendations are enabled. When true, the link-recommendation task type |
113 | * should be made available to the user and the links task type hidden. |
114 | * @note This has to be equivalent to areLinkRecommendationsEnabled in TaskTypesAbFilter.js |
115 | * @return bool |
116 | */ |
117 | public function areLinkRecommendationsEnabled( UserIdentity $user ): bool { |
118 | if ( $this->experimentUserManager->isUserInVariant( $user, |
119 | VariantHooks::VARIANT_NO_LINK_RECOMMENDATION ) ) { |
120 | // Disabled by a variant (T377787) |
121 | return false; |
122 | } |
123 | |
124 | return $this->config->get( 'GENewcomerTasksLinkRecommendationsEnabled' ) |
125 | && $this->config->get( 'GELinkRecommendationsFrontendEnabled' ) |
126 | && array_key_exists( LinkRecommendationTaskTypeHandler::TASK_TYPE_ID, |
127 | $this->configurationLoader->getTaskTypes() ); |
128 | } |
129 | |
130 | /** |
131 | * Check if image recommendations are enabled. When true, the image-recommendation task type |
132 | * should be made available to the user. |
133 | * @note This has to be equivalent to areImageRecommendationsEnabled in TaskTypesAbFilter.js |
134 | * @return bool |
135 | */ |
136 | public function areImageRecommendationsEnabled(): bool { |
137 | return $this->config->get( 'GENewcomerTasksImageRecommendationsEnabled' ) |
138 | && array_key_exists( ImageRecommendationTaskTypeHandler::TASK_TYPE_ID, |
139 | $this->configurationLoader->getTaskTypes() ); |
140 | } |
141 | |
142 | /** |
143 | * Check if section-level image recommendations are enabled. When true, the |
144 | * section-image-recommendation task type should be made available to the user. |
145 | * @note This has to be equivalent to areSectionImageRecommendationsEnabled in TaskTypesAbFilter.js |
146 | * @param UserIdentity $user |
147 | * @return bool |
148 | */ |
149 | public function areSectionImageRecommendationsEnabled( UserIdentity $user ): bool { |
150 | return $this->config->get( 'GENewcomerTasksSectionImageRecommendationsEnabled' ) |
151 | && array_key_exists( SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID, |
152 | $this->configurationLoader->getTaskTypes() ); |
153 | } |
154 | |
155 | /** |
156 | * Remove task types which the user is not supposed to see, given the link recommendation |
157 | * configuration and community configuration. |
158 | * @param string[] $taskTypes Task types IDs. |
159 | * @param UserIdentity $user |
160 | * @return string[] Filtered task types IDs. Array keys are not preserved. |
161 | */ |
162 | public function filterTaskTypes( array $taskTypes, UserIdentity $user ): array { |
163 | $conversionMap = $this->getConversionMap( $user ); |
164 | $taskTypes = array_filter( $taskTypes, |
165 | fn ( $taskTypeId ) => !array_key_exists( $taskTypeId, $conversionMap ) |
166 | ); |
167 | return $this->filterNonExistentTaskTypes( $taskTypes ); |
168 | } |
169 | |
170 | /** |
171 | * Get default task types when the user has no stored preference. |
172 | * @param UserIdentity $user |
173 | * @return string[] |
174 | */ |
175 | private function getDefaultTaskTypes( UserIdentity $user ): array { |
176 | // This doesn't do anything useful right now, but we want to preserve the ability |
177 | // to determine the default task types dynamically for A/B testing. |
178 | return SuggestedEdits::DEFAULT_TASK_TYPES; |
179 | } |
180 | |
181 | /** |
182 | * Get mapping of task types which the user is not supposed to see to a similar task type |
183 | * or false (meaning nothing should be shown instead). |
184 | * Identical to TaskTypesAbFilter.getConversionMap(). |
185 | * @param UserIdentity $user |
186 | * @return array A map of old task type ID => new task type ID or false. |
187 | * @phan-return array<string,string|false> |
188 | */ |
189 | private function getConversionMap( UserIdentity $user ): array { |
190 | $map = []; |
191 | if ( $this->areLinkRecommendationsEnabled( $user ) ) { |
192 | $map += [ 'links' => LinkRecommendationTaskTypeHandler::TASK_TYPE_ID ]; |
193 | } else { |
194 | $map += [ LinkRecommendationTaskTypeHandler::TASK_TYPE_ID => 'links' ]; |
195 | } |
196 | if ( !$this->areImageRecommendationsEnabled() ) { |
197 | $map += [ ImageRecommendationTaskTypeHandler::TASK_TYPE_ID => false ]; |
198 | } |
199 | if ( !$this->areSectionImageRecommendationsEnabled( $user ) ) { |
200 | $map += [ SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID => false ]; |
201 | } |
202 | return $map; |
203 | } |
204 | |
205 | /** |
206 | * Convert task types which the user is not supposed to see, given the link recommendation |
207 | * configuration, to the closest task type available to them. |
208 | * @param string[] $taskTypes Task types IDs. |
209 | * @param UserIdentity $user |
210 | * @return string[] Converted task types IDs. Array keys are not preserved. |
211 | */ |
212 | private function convertTaskTypes( array $taskTypes, UserIdentity $user ): array { |
213 | $map = $this->getConversionMap( $user ); |
214 | $taskTypes = array_map( static function ( string $taskType ) use ( $map ) { |
215 | return $map[$taskType] ?? $taskType; |
216 | }, $taskTypes ); |
217 | return array_unique( array_filter( $taskTypes ) ); |
218 | } |
219 | |
220 | /** |
221 | * Remove task types which have been disabled via community configuration. |
222 | * @param string[] $taskTypesToFilter Task types IDs. |
223 | * @return string[] Filtered task types IDs. |
224 | */ |
225 | private function filterNonExistentTaskTypes( array $taskTypesToFilter ) { |
226 | $allTaskTypes = $this->configurationLoader->getTaskTypes(); |
227 | return array_values( array_filter( $taskTypesToFilter, |
228 | static function ( $taskTypeId ) use ( $allTaskTypes ) { |
229 | return array_key_exists( $taskTypeId, $allTaskTypes ); |
230 | } |
231 | ) ); |
232 | } |
233 | |
234 | /** |
235 | * Read a user preference that is a list of strings. |
236 | * @param UserIdentity $user |
237 | * @param string $pref |
238 | * @return array|null User preferences as a list of strings, or null of the preference was |
239 | * missing or invalid. |
240 | */ |
241 | private function getJsonListOption( UserIdentity $user, string $pref ) { |
242 | $stored = $this->userOptionsLookup->getOption( $user, $pref ); |
243 | if ( $stored ) { |
244 | $stored = json_decode( $stored, true ); |
245 | } |
246 | // sanity check |
247 | if ( !is_array( $stored ) || array_filter( $stored, 'is_string' ) !== $stored ) { |
248 | return null; |
249 | } |
250 | return $stored; |
251 | } |
252 | |
253 | /** |
254 | * Read a user preference that is a string. |
255 | * @param UserIdentity $user |
256 | * @param string $pref |
257 | * @return string|null User preference as a string, or null if the preference is invalid |
258 | */ |
259 | private function getStringOption( UserIdentity $user, string $pref ) { |
260 | $stored = $this->userOptionsLookup->getOption( $user, $pref ); |
261 | |
262 | if ( !is_string( $stored ) ) { |
263 | return null; |
264 | } |
265 | return $stored; |
266 | } |
267 | |
268 | /** |
269 | * Check if the user is an internal pseudo-user who should see all task types. |
270 | * @param UserIdentity $user |
271 | * @return bool |
272 | */ |
273 | private function shouldUserSeeAllTaskTypes( UserIdentity $user ) { |
274 | return $user->getId() === 0 && $user->getName() === SuggestionsInfo::USER; |
275 | } |
276 | |
277 | } |