1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Models;
7
8use App\Models\Solo\Score;
9use Illuminate\Database\Eloquent\Casts\AsArrayObject;
10
11/**
12 * @property \Carbon\Carbon $created_at
13 * @property string|null $extras_order
14 * @property int $id
15 * @property \Carbon\Carbon $updated_at
16 * @property int|null $user_id
17 */
18class UserProfileCustomization extends Model
19{
20 const DEFAULTS = [
21 'audio_autoplay' => false,
22 'audio_muted' => false,
23 'audio_volume' => 0.45,
24 'beatmapset_card_size' => self::BEATMAPSET_CARD_SIZES[0],
25 'beatmapset_download' => self::BEATMAPSET_DOWNLOAD[0],
26 'beatmapset_show_nsfw' => false,
27 'beatmapset_title_show_original' => false,
28 'comments_show_deleted' => false,
29 'comments_sort' => Comment::DEFAULT_SORT,
30 'extras_order' => self::SECTIONS,
31 'forum_posts_show_deleted' => true,
32 'legacy_score_only' => false,
33 'profile_cover_expanded' => true,
34 'scoring_mode' => self::SCORING_MODES[0],
35 'user_list_filter' => self::USER_LIST['filters']['default'],
36 'user_list_sort' => self::USER_LIST['sorts']['default'],
37 'user_list_view' => self::USER_LIST['views']['default'],
38 ];
39
40 /**
41 * An array of all possible profile sections, also in their default order.
42 */
43 const SECTIONS = [
44 'me',
45 'recent_activity',
46 'top_ranks',
47 'medals',
48 'historical',
49 'beatmaps',
50 'kudosu',
51 ];
52
53 const BEATMAPSET_CARD_SIZES = ['normal', 'extra'];
54
55 const BEATMAPSET_DOWNLOAD = ['all', 'no_video', 'direct'];
56
57 public const array SCORING_MODES = ['standardised', 'classic'];
58
59 const USER_LIST = [
60 'filters' => ['all' => ['all', 'online', 'offline'], 'default' => 'all'],
61 'sorts' => ['all' => ['last_visit', 'rank', 'username'], 'default' => 'last_visit'],
62 'views' => ['all' => ['card', 'list', 'brick'], 'default' => 'card'],
63 ];
64
65 public $incrementing = false;
66
67 protected $casts = [
68 'options' => AsArrayObject::class,
69 ];
70 protected $primaryKey = 'user_id';
71
72 public static function forUser(?User $user): array|static
73 {
74 if ($user === null) {
75 return static::DEFAULTS;
76 }
77
78 $ret = $user->userProfileCustomization;
79
80 if ($ret === null) {
81 $ret = new static(['user_id' => $user->getKey()]);
82 $user->setRelation('userProfileCustomization', $ret);
83 }
84
85 return $ret;
86 }
87
88 public static function repairExtrasOrder(array $value): array
89 {
90 // read from inside out
91 return array_values(
92 // remove duplicate sections from previous merge
93 array_unique(
94 // ensure all sections are included
95 array_merge(
96 // remove invalid sections
97 array_intersect($value, static::SECTIONS),
98 static::SECTIONS
99 )
100 )
101 );
102 }
103
104 public function getAudioAutoplayAttribute()
105 {
106 return $this->options['audio_autoplay'] ?? static::DEFAULTS['audio_autoplay'];
107 }
108
109 public function setAudioAutoplayAttribute($value)
110 {
111 $this->setOption('audio_autoplay', get_bool($value));
112 }
113
114 public function getAudioMutedAttribute()
115 {
116 return $this->options['audio_muted'] ?? static::DEFAULTS['audio_muted'];
117 }
118
119 public function setAudioMutedAttribute($value)
120 {
121 $this->setOption('audio_muted', get_bool($value));
122 }
123
124 public function getAudioVolumeAttribute()
125 {
126 return $this->options['audio_volume'] ?? static::DEFAULTS['audio_volume'];
127 }
128
129 public function setAudioVolumeAttribute($value)
130 {
131 $this->setOption('audio_volume', get_float($value));
132 }
133
134 public function getBeatmapsetCardSizeAttribute()
135 {
136 return $this->options['beatmapset_card_size'] ?? static::DEFAULTS['beatmapset_card_size'];
137 }
138
139 public function setBeatmapsetCardSizeAttribute($value)
140 {
141 if ($value !== null && !in_array($value, static::BEATMAPSET_CARD_SIZES, true)) {
142 $value = null;
143 }
144
145 $this->setOption('beatmapset_card_size', $value);
146 }
147
148 public function getBeatmapsetDownloadAttribute()
149 {
150 return $this->options['beatmapset_download'] ?? static::DEFAULTS['beatmapset_download'];
151 }
152
153 public function setBeatmapsetDownloadAttribute($value)
154 {
155 if ($value !== null && !in_array($value, static::BEATMAPSET_DOWNLOAD, true)) {
156 $value = null;
157 }
158
159 $this->setOption('beatmapset_download', $value);
160 }
161
162 public function getBeatmapsetShowNsfwAttribute()
163 {
164 return $this->options['beatmapset_show_nsfw'] ?? static::DEFAULTS['beatmapset_show_nsfw'];
165 }
166
167 public function setBeatmapsetShowNsfwAttribute($value)
168 {
169 $this->setOption('beatmapset_show_nsfw', get_bool($value));
170 }
171
172 public function getBeatmapsetTitleShowOriginalAttribute()
173 {
174 return $this->options['beatmapset_title_show_original'] ?? static::DEFAULTS['beatmapset_title_show_original'];
175 }
176
177 public function setBeatmapsetTitleShowOriginalAttribute($value)
178 {
179 $this->setOption('beatmapset_title_show_original', get_bool($value));
180 }
181
182 public function getCommentsShowDeletedAttribute()
183 {
184 return $this->options['comments_show_deleted'] ?? static::DEFAULTS['comments_show_deleted'];
185 }
186
187 public function setCommentsShowDeletedAttribute($value)
188 {
189 $this->setOption('comments_show_deleted', get_bool($value));
190 }
191
192 public function getCommentsSortAttribute()
193 {
194 return $this->options['comments_sort'] ?? static::DEFAULTS['comments_sort'];
195 }
196
197 public function setCommentsSortAttribute($value)
198 {
199 if ($value !== null && !array_key_exists($value, Comment::SORTS)) {
200 $value = null;
201 }
202
203 $this->setOption('comments_sort', $value);
204 }
205
206 public function getForumPostsShowDeletedAttribute()
207 {
208 return $this->options['forum_posts_show_deleted'] ?? static::DEFAULTS['forum_posts_show_deleted'];
209 }
210
211 public function setForumPostsShowDeletedAttribute($value)
212 {
213 $this->setOption('forum_posts_show_deleted', get_bool($value));
214 }
215
216 public function getLegacyScoreOnlyAttribute(): bool
217 {
218 $option = $this->options['legacy_score_only'] ?? null;
219 if ($option === null) {
220 $lastScore = Score::where('user_id', $this->getKey())->last();
221 if ($lastScore === null) {
222 $option = static::DEFAULTS['legacy_score_only'];
223 } else {
224 $option = $lastScore->isLegacy();
225 $this->setOption('legacy_score_only', $option);
226
227 try {
228 $this->save();
229 } catch (\Throwable $e) {
230 if (!is_sql_unique_exception($e)) {
231 throw $e;
232 }
233 }
234 }
235 }
236
237 return $option;
238 }
239
240 public function setLegacyScoreOnlyAttribute($value): void
241 {
242 $this->setOption('legacy_score_only', get_bool($value));
243 }
244
245 public function getScoringModeAttribute(): string
246 {
247 return $this->options['scoring_mode'] ?? static::DEFAULTS['scoring_mode'];
248 }
249
250 public function setScoringModeAttribute($value): void
251 {
252 if ($value !== null && !in_array($value, static::SCORING_MODES, true)) {
253 $value = null;
254 }
255
256 $this->setOption('scoring_mode', $value);
257 }
258
259 public function getUserListFilterAttribute()
260 {
261 return $this->options['user_list_filter'] ?? static::DEFAULTS['user_list_filter'];
262 }
263
264 public function setUserListFilterAttribute($value)
265 {
266 if ($value !== null && !in_array($value, static::USER_LIST['filters']['all'], true)) {
267 $value = null;
268 }
269
270 $this->setOption('user_list_filter', $value);
271 }
272
273 public function getUserListSortAttribute()
274 {
275 return $this->options['user_list_sort'] ?? static::DEFAULTS['user_list_sort'];
276 }
277
278 public function setUserListSortAttribute($value)
279 {
280 if ($value !== null && !in_array($value, static::USER_LIST['sorts']['all'], true)) {
281 $value = null;
282 }
283
284 $this->setOption('user_list_sort', $value);
285 }
286
287 public function getUserListViewAttribute()
288 {
289 return $this->options['user_list_view'] ?? static::DEFAULTS['user_list_view'];
290 }
291
292 public function setUserListViewAttribute($value)
293 {
294 if ($value !== null && !in_array($value, static::USER_LIST['views']['all'], true)) {
295 $value = null;
296 }
297
298 $this->setOption('user_list_view', $value);
299 }
300
301 public function getExtrasOrderAttribute($value)
302 {
303 $newValue = $this->options['extras_order'] ?? null;
304
305 if ($newValue === null && $value !== null) {
306 $newValue = json_decode($value, true);
307 }
308
309 if ($newValue === null) {
310 return static::DEFAULTS['extras_order'];
311 }
312
313 return static::repairExtrasOrder($newValue);
314 }
315
316 public function setExtrasOrderAttribute($value)
317 {
318 $this->attributes['extras_order'] = null;
319 $this->setOption(
320 'extras_order',
321 $value === null ? null : static::repairExtrasOrder($value),
322 );
323 }
324
325 public function getProfileCoverExpandedAttribute()
326 {
327 return $this->options['profile_cover_expanded'] ?? static::DEFAULTS['profile_cover_expanded'];
328 }
329
330 public function setProfileCoverExpandedAttribute($value)
331 {
332 $this->setOption('profile_cover_expanded', get_bool($value));
333 }
334
335 private function setOption($key, $value)
336 {
337 $this->options ??= [];
338 $this->options[$key] = $value;
339 }
340}