the browser-facing portion of osu!

Merge branch 'master' into contest-judging

authored by

bakaneko and committed by
GitHub
17ebb94e 89feb582

+4378 -2904
+3
.env.example
··· 225 225 # CHAT_PUBLIC_BACKLOG_LIMIT_HOURS=24 226 226 227 227 # ALLOW_REGISTRATION=true 228 + # REGISTRATION_MODE_CLIENT=true 229 + # REGISTRATION_MODE_WEB=false 228 230 229 231 # USER_ALLOW_EMAIL_LOGIN=true 230 232 # USER_BYPASS_VERIFICATION=false ··· 308 310 # SCORES_EXPERIMENTAL_RANK_AS_DEFAULT=false 309 311 # SCORES_EXPERIMENTAL_RANK_AS_EXTRA=false 310 312 # SCORES_PROCESSING_QUEUE=osu-queue:score-statistics 313 + # SCORES_SUBMISSION_ENABLED=1 311 314 # SCORES_RANK_CACHE_LOCAL_SERVER=0 312 315 # SCORES_RANK_CACHE_MIN_USERS=35000 313 316 # SCORES_RANK_CACHE_SERVER_URL=
+1 -1
.github/workflows/lint.yml
··· 49 49 - name: Install js dependencies 50 50 run: yarn --frozen-lockfile 51 51 52 - - run: 'yarn lint --max-warnings 90 > /dev/null' 52 + - run: 'yarn lint --max-warnings 89 > /dev/null' 53 53 54 54 - run: ./bin/update_licence.sh -nf 55 55
+12
app/Exceptions/ClientCheckParseTokenException.php
··· 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 + 6 + declare(strict_types=1); 7 + 8 + namespace App\Exceptions; 9 + 10 + class ClientCheckParseTokenException extends \Exception 11 + { 12 + }
+6 -2
app/Http/Controllers/BeatmapPacksController.php
··· 5 5 6 6 namespace App\Http\Controllers; 7 7 8 + use App\Libraries\Search\ScoreSearchParams; 8 9 use App\Models\Beatmap; 9 10 use App\Models\BeatmapPack; 10 11 use App\Transformers\BeatmapPackTransformer; 11 - use Auth; 12 12 13 13 /** 14 14 * @group Beatmap Packs ··· 100 100 $pack = $query->where('tag', $idOrTag)->firstOrFail(); 101 101 $mode = Beatmap::modeStr($pack->playmode ?? 0); 102 102 $sets = $pack->beatmapsets; 103 - $userCompletionData = $pack->userCompletionData(Auth::user()); 103 + $currentUser = \Auth::user(); 104 + $userCompletionData = $pack->userCompletionData( 105 + $currentUser, 106 + ScoreSearchParams::showLegacyForUser($currentUser), 107 + ); 104 108 105 109 if (is_api_request()) { 106 110 return json_item(
+99 -118
app/Http/Controllers/BeatmapsController.php
··· 10 10 use App\Jobs\Notifications\BeatmapOwnerChange; 11 11 use App\Libraries\BeatmapDifficultyAttributes; 12 12 use App\Libraries\Score\BeatmapScores; 13 + use App\Libraries\Score\UserRank; 14 + use App\Libraries\Search\ScoreSearch; 15 + use App\Libraries\Search\ScoreSearchParams; 13 16 use App\Models\Beatmap; 14 17 use App\Models\BeatmapsetEvent; 15 18 use App\Models\Score\Best\Model as BestModel; ··· 49 52 } 50 53 51 54 return $query; 55 + } 56 + 57 + private static function beatmapScores(string $id, ?string $scoreTransformerType, ?bool $isLegacy): array 58 + { 59 + $beatmap = Beatmap::findOrFail($id); 60 + if ($beatmap->approved <= 0) { 61 + return ['scores' => []]; 62 + } 63 + 64 + $params = get_params(request()->all(), null, [ 65 + 'limit:int', 66 + 'mode', 67 + 'mods:string[]', 68 + 'type:string', 69 + ], ['null_missing' => true]); 70 + 71 + if ($params['mode'] !== null) { 72 + $rulesetId = Beatmap::MODES[$params['mode']] ?? null; 73 + if ($rulesetId === null) { 74 + throw new InvariantException('invalid mode specified'); 75 + } 76 + } 77 + $rulesetId ??= $beatmap->playmode; 78 + $mods = array_values(array_filter($params['mods'] ?? [])); 79 + $type = presence($params['type'], 'global'); 80 + $currentUser = \Auth::user(); 81 + 82 + static::assertSupporterOnlyOptions($currentUser, $type, $mods); 83 + 84 + $esFetch = new BeatmapScores([ 85 + 'beatmap_ids' => [$beatmap->getKey()], 86 + 'is_legacy' => $isLegacy, 87 + 'limit' => $params['limit'], 88 + 'mods' => $mods, 89 + 'ruleset_id' => $rulesetId, 90 + 'type' => $type, 91 + 'user' => $currentUser, 92 + ]); 93 + $scores = $esFetch->all()->loadMissing(['beatmap', 'user.country', 'user.userProfileCustomization']); 94 + $userScore = $esFetch->userBest(); 95 + $scoreTransformer = new ScoreTransformer($scoreTransformerType); 96 + 97 + $results = [ 98 + 'scores' => json_collection( 99 + $scores, 100 + $scoreTransformer, 101 + static::DEFAULT_SCORE_INCLUDES 102 + ), 103 + ]; 104 + 105 + if (isset($userScore)) { 106 + $results['user_score'] = [ 107 + 'position' => $esFetch->rank($userScore), 108 + 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), 109 + ]; 110 + // TODO: remove this old camelCased json field 111 + $results['userScore'] = $results['user_score']; 112 + } 113 + 114 + return $results; 52 115 } 53 116 54 117 public function __construct() ··· 280 343 /** 281 344 * Get Beatmap scores 282 345 * 283 - * Returns the top scores for a beatmap 346 + * Returns the top scores for a beatmap. Depending on user preferences, this may only show legacy scores. 284 347 * 285 348 * --- 286 349 * ··· 296 359 */ 297 360 public function scores($id) 298 361 { 299 - $beatmap = Beatmap::findOrFail($id); 300 - if ($beatmap->approved <= 0) { 301 - return ['scores' => []]; 302 - } 303 - 304 - $params = get_params(request()->all(), null, [ 305 - 'limit:int', 306 - 'mode:string', 307 - 'mods:string[]', 308 - 'type:string', 309 - ], ['null_missing' => true]); 310 - 311 - $mode = presence($params['mode']) ?? $beatmap->mode; 312 - $mods = array_values(array_filter($params['mods'] ?? [])); 313 - $type = presence($params['type']) ?? 'global'; 314 - $currentUser = auth()->user(); 315 - 316 - static::assertSupporterOnlyOptions($currentUser, $type, $mods); 317 - 318 - $query = static::baseScoreQuery($beatmap, $mode, $mods, $type); 319 - 320 - if ($currentUser !== null) { 321 - // own score shouldn't be filtered by visibleUsers() 322 - $userScore = (clone $query)->where('user_id', $currentUser->user_id)->first(); 323 - } 324 - 325 - $scoreTransformer = new ScoreTransformer(); 326 - 327 - $results = [ 328 - 'scores' => json_collection( 329 - $query->visibleUsers()->forListing($params['limit']), 330 - $scoreTransformer, 331 - static::DEFAULT_SCORE_INCLUDES 332 - ), 333 - ]; 334 - 335 - if (isset($userScore)) { 336 - $results['user_score'] = [ 337 - 'position' => $userScore->userRank(compact('type', 'mods')), 338 - 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), 339 - ]; 340 - // TODO: remove this old camelCased json field 341 - $results['userScore'] = $results['user_score']; 342 - } 343 - 344 - return $results; 362 + return static::beatmapScores( 363 + $id, 364 + null, 365 + // TODO: change to imported name after merge with other PRs 366 + \App\Libraries\Search\ScoreSearchParams::showLegacyForUser(\Auth::user()), 367 + ); 345 368 } 346 369 347 370 /** 348 - * Get Beatmap scores (temp) 371 + * Get Beatmap scores (non-legacy) 349 372 * 350 - * Returns the top scores for a beatmap from newer client. 351 - * 352 - * This is a temporary endpoint. 373 + * Returns the top scores for a beatmap. 353 374 * 354 375 * --- 355 376 * ··· 359 380 * 360 381 * @urlParam beatmap integer required Id of the [Beatmap](#beatmap). 361 382 * 383 + * @queryParam legacy_only Set to true to only return legacy scores. Example: 0 362 384 * @queryParam mode The [Ruleset](#ruleset) to get scores for. 363 385 * @queryParam mods An array of matching Mods, or none // TODO. 364 386 * @queryParam type Beatmap score ranking type // TODO. 365 387 */ 366 388 public function soloScores($id) 367 389 { 368 - $beatmap = Beatmap::findOrFail($id); 369 - if ($beatmap->approved <= 0) { 370 - return ['scores' => []]; 371 - } 372 - 373 - $params = get_params(request()->all(), null, [ 374 - 'limit:int', 375 - 'mode', 376 - 'mods:string[]', 377 - 'type:string', 378 - ], ['null_missing' => true]); 379 - 380 - if ($params['mode'] !== null) { 381 - $rulesetId = Beatmap::MODES[$params['mode']] ?? null; 382 - if ($rulesetId === null) { 383 - throw new InvariantException('invalid mode specified'); 384 - } 385 - } 386 - $rulesetId ??= $beatmap->playmode; 387 - $mods = array_values(array_filter($params['mods'] ?? [])); 388 - $type = presence($params['type'], 'global'); 389 - $currentUser = auth()->user(); 390 - 391 - static::assertSupporterOnlyOptions($currentUser, $type, $mods); 392 - 393 - $esFetch = new BeatmapScores([ 394 - 'beatmap_ids' => [$beatmap->getKey()], 395 - 'is_legacy' => false, 396 - 'limit' => $params['limit'], 397 - 'mods' => $mods, 398 - 'ruleset_id' => $rulesetId, 399 - 'type' => $type, 400 - 'user' => $currentUser, 401 - ]); 402 - $scores = $esFetch->all()->loadMissing(['beatmap', 'performance', 'user.country', 'user.userProfileCustomization']); 403 - $userScore = $esFetch->userBest(); 404 - $scoreTransformer = new ScoreTransformer(ScoreTransformer::TYPE_SOLO); 405 - 406 - $results = [ 407 - 'scores' => json_collection( 408 - $scores, 409 - $scoreTransformer, 410 - static::DEFAULT_SCORE_INCLUDES 411 - ), 412 - ]; 413 - 414 - if (isset($userScore)) { 415 - $results['user_score'] = [ 416 - 'position' => $esFetch->rank($userScore), 417 - 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), 418 - ]; 419 - // TODO: remove this old camelCased json field 420 - $results['userScore'] = $results['user_score']; 421 - } 422 - 423 - return $results; 390 + return static::beatmapScores($id, ScoreTransformer::TYPE_SOLO, null); 424 391 } 425 392 426 393 public function updateOwner($id) ··· 481 448 $mode = presence($params['mode'] ?? null, $beatmap->mode); 482 449 $mods = array_values(array_filter($params['mods'] ?? [])); 483 450 484 - $score = static::baseScoreQuery($beatmap, $mode, $mods) 485 - ->visibleUsers() 486 - ->where('user_id', $userId) 487 - ->firstOrFail(); 451 + $baseParams = ScoreSearchParams::fromArray([ 452 + 'beatmap_ids' => [$beatmap->getKey()], 453 + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), 454 + 'limit' => 1, 455 + 'mods' => $mods, 456 + 'ruleset_id' => Beatmap::MODES[$mode], 457 + 'sort' => 'score_desc', 458 + 'user_id' => (int) $userId, 459 + ]); 460 + $score = (new ScoreSearch($baseParams))->records()->first(); 461 + abort_if($score === null, 404); 462 + 463 + $rankParams = clone $baseParams; 464 + $rankParams->beforeScore = $score; 465 + $rankParams->userId = null; 466 + $rank = UserRank::getRank($rankParams); 488 467 489 468 return [ 490 - 'position' => $score->userRank(compact('mods')), 469 + 'position' => $rank, 491 470 'score' => json_item( 492 471 $score, 493 472 new ScoreTransformer(), ··· 518 497 { 519 498 $beatmap = Beatmap::scoreable()->findOrFail($beatmapId); 520 499 $mode = presence(get_string(request('mode'))) ?? $beatmap->mode; 521 - $scores = BestModel::getClass($mode) 522 - ::default() 523 - ->where([ 524 - 'beatmap_id' => $beatmap->getKey(), 525 - 'user_id' => $userId, 526 - ])->get(); 500 + $params = ScoreSearchParams::fromArray([ 501 + 'beatmap_ids' => [$beatmap->getKey()], 502 + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), 503 + 'ruleset_id' => Beatmap::MODES[$mode], 504 + 'sort' => 'score_desc', 505 + 'user_id' => (int) $userId, 506 + ]); 507 + $scores = (new ScoreSearch($params))->records(); 527 508 528 509 return [ 529 510 'scores' => json_collection($scores, new ScoreTransformer()),
+8 -7
app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php
··· 166 166 $room = Room::findOrFail($roomId); 167 167 $playlistItem = $room->playlist()->where('id', $playlistId)->firstOrFail(); 168 168 $user = auth()->user(); 169 - $params = request()->all(); 169 + $request = \Request::instance(); 170 + $params = $request->all(); 170 171 171 - $buildId = ClientCheck::findBuild($user, $params)?->getKey() 172 - ?? $GLOBALS['cfg']['osu']['client']['default_build_id']; 172 + $buildId = ClientCheck::parseToken($request)['buildId']; 173 173 174 174 $scoreToken = $room->startPlay($user, $playlistItem, $buildId); 175 175 ··· 181 181 */ 182 182 public function update($roomId, $playlistItemId, $tokenId) 183 183 { 184 + $request = \Request::instance(); 185 + $clientTokenData = ClientCheck::parseToken($request); 184 186 $scoreLink = \DB::transaction(function () use ($roomId, $playlistItemId, $tokenId) { 185 187 $room = Room::findOrFail($roomId); 186 188 ··· 202 204 }); 203 205 204 206 $score = $scoreLink->score; 205 - $transformer = ScoreTransformer::newSolo(); 206 207 if ($score->wasRecentlyCreated) { 207 - $scoreJson = json_item($score, $transformer); 208 - $score::queueForProcessing($scoreJson); 208 + ClientCheck::queueToken($clientTokenData, $score->getKey()); 209 + $score->queueForProcessing(); 209 210 } 210 211 211 212 return json_item( 212 213 $scoreLink, 213 - $transformer, 214 + ScoreTransformer::newSolo(), 214 215 [ 215 216 ...ScoreTransformer::MULTIPLAYER_BASE_INCLUDES, 216 217 'position',
+8 -4
app/Http/Controllers/ScoreTokensController.php
··· 22 22 23 23 public function store($beatmapId) 24 24 { 25 + if (!$GLOBALS['cfg']['osu']['scores']['submission_enabled']) { 26 + abort(422, 'score submission is disabled'); 27 + } 28 + 25 29 $beatmap = Beatmap::increasesStatistics()->findOrFail($beatmapId); 26 30 $user = auth()->user(); 27 - $rawParams = request()->all(); 28 - $params = get_params($rawParams, null, [ 31 + $request = \Request::instance(); 32 + $params = get_params($request->all(), null, [ 29 33 'beatmap_hash', 30 34 'ruleset_id:int', 31 35 ]); ··· 43 47 } 44 48 } 45 49 46 - $build = ClientCheck::findBuild($user, $rawParams); 50 + $buildId = ClientCheck::parseToken($request)['buildId']; 47 51 48 52 try { 49 53 $scoreToken = ScoreToken::create([ 50 54 'beatmap_id' => $beatmap->getKey(), 51 - 'build_id' => $build?->getKey() ?? $GLOBALS['cfg']['osu']['client']['default_build_id'], 55 + 'build_id' => $buildId, 52 56 'ruleset_id' => $params['ruleset_id'], 53 57 'user_id' => $user->getKey(), 54 58 ]);
+8 -6
app/Http/Controllers/Solo/ScoresController.php
··· 6 6 namespace App\Http\Controllers\Solo; 7 7 8 8 use App\Http\Controllers\Controller as BaseController; 9 + use App\Libraries\ClientCheck; 9 10 use App\Models\ScoreToken; 10 11 use App\Models\Solo\Score; 11 12 use App\Transformers\ScoreTransformer; ··· 20 21 21 22 public function store($beatmapId, $tokenId) 22 23 { 23 - $score = DB::transaction(function () use ($beatmapId, $tokenId) { 24 + $request = \Request::instance(); 25 + $clientTokenData = ClientCheck::parseToken($request); 26 + $score = DB::transaction(function () use ($beatmapId, $request, $tokenId) { 24 27 $user = auth()->user(); 25 28 $scoreToken = ScoreToken::where([ 26 29 'beatmap_id' => $beatmapId, ··· 29 32 30 33 // return existing score otherwise (assuming duplicated submission) 31 34 if ($scoreToken->score_id === null) { 32 - $params = Score::extractParams(\Request::all(), $scoreToken); 35 + $params = Score::extractParams($request->all(), $scoreToken); 33 36 $score = Score::createFromJsonOrExplode($params); 34 - $score->createLegacyEntryOrExplode(); 35 37 $scoreToken->fill(['score_id' => $score->getKey()])->saveOrExplode(); 36 38 } else { 37 39 // assume score exists and is valid ··· 41 43 return $score; 42 44 }); 43 45 44 - $scoreJson = json_item($score, new ScoreTransformer(ScoreTransformer::TYPE_SOLO)); 45 46 if ($score->wasRecentlyCreated) { 46 - $score::queueForProcessing($scoreJson); 47 + ClientCheck::queueToken($clientTokenData, $score->getKey()); 48 + $score->queueForProcessing(); 47 49 } 48 50 49 - return $scoreJson; 51 + return json_item($score, new ScoreTransformer(ScoreTransformer::TYPE_SOLO)); 50 52 } 51 53 }
+52 -22
app/Http/Controllers/UsersController.php
··· 9 9 use App\Exceptions\UserProfilePageLookupException; 10 10 use App\Exceptions\ValidationException; 11 11 use App\Http\Middleware\RequestCost; 12 + use App\Libraries\ClientCheck; 12 13 use App\Libraries\RateLimiter; 13 14 use App\Libraries\Search\ForumSearch; 14 15 use App\Libraries\Search\ForumSearchRequestParams; 16 + use App\Libraries\Search\ScoreSearchParams; 15 17 use App\Libraries\User\FindForProfilePage; 16 18 use App\Libraries\UserRegistration; 17 19 use App\Models\Beatmap; ··· 19 21 use App\Models\Country; 20 22 use App\Models\IpBan; 21 23 use App\Models\Log; 24 + use App\Models\Solo\Score as SoloScore; 22 25 use App\Models\User; 23 26 use App\Models\UserAccountHistory; 24 27 use App\Models\UserNotFound; ··· 33 36 use NoCaptcha; 34 37 use Request; 35 38 use Sentry\State\Scope; 39 + use Symfony\Component\HttpKernel\Exception\HttpException; 36 40 37 41 /** 38 42 * @group Users ··· 103 107 parent::__construct(); 104 108 } 105 109 110 + private static function storeClientDisabledError() 111 + { 112 + return response([ 113 + 'error' => osu_trans('users.store.from_web'), 114 + 'url' => route('users.create'), 115 + ], 403); 116 + } 117 + 106 118 public function card($id) 107 119 { 108 120 try { ··· 116 128 117 129 public function create() 118 130 { 119 - if ($GLOBALS['cfg']['osu']['user']['registration_mode'] !== 'web') { 131 + if (!$GLOBALS['cfg']['osu']['user']['registration_mode']['web']) { 120 132 return abort(403, osu_trans('users.store.from_client')); 121 133 } 122 134 ··· 176 188 'monthly_playcounts' => json_collection($this->user->monthlyPlaycounts, new UserMonthlyPlaycountTransformer()), 177 189 'recent' => $this->getExtraSection( 178 190 'scoresRecent', 179 - $this->user->scores($this->mode, true)->includeFails(false)->count() 191 + $this->user->soloScores()->recent($this->mode, false)->count(), 180 192 ), 181 193 'replays_watched_counts' => json_collection($this->user->replaysWatchedCounts, new UserReplaysWatchedCountTransformer()), 182 194 ]; ··· 191 203 return [ 192 204 'best' => $this->getExtraSection( 193 205 'scoresBest', 194 - count($this->user->beatmapBestScoreIds($this->mode)) 206 + count($this->user->beatmapBestScoreIds($this->mode, ScoreSearchParams::showLegacyForUser(\Auth::user()))) 195 207 ), 196 208 'firsts' => $this->getExtraSection( 197 209 'scoresFirsts', ··· 210 222 211 223 public function store() 212 224 { 213 - if ($GLOBALS['cfg']['osu']['user']['registration_mode'] !== 'client') { 214 - return response([ 215 - 'error' => osu_trans('users.store.from_web'), 216 - 'url' => route('users.create'), 217 - ], 403); 225 + if (!$GLOBALS['cfg']['osu']['user']['registration_mode']['client']) { 226 + return static::storeClientDisabledError(); 218 227 } 219 228 220 - if (!starts_with(Request::header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) { 229 + $request = \Request::instance(); 230 + 231 + if (!starts_with($request->header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) { 221 232 return error_popup(osu_trans('users.store.from_client'), 403); 222 233 } 223 234 224 - return $this->storeUser(request()->all()); 235 + try { 236 + ClientCheck::parseToken($request); 237 + } catch (HttpException $e) { 238 + return static::storeClientDisabledError(); 239 + } 240 + 241 + return $this->storeUser($request->all()); 225 242 } 226 243 227 244 public function storeWeb() 228 245 { 229 - if ($GLOBALS['cfg']['osu']['user']['registration_mode'] !== 'web') { 246 + if (!$GLOBALS['cfg']['osu']['user']['registration_mode']['web']) { 230 247 return error_popup(osu_trans('users.store.from_client'), 403); 231 248 } 232 249 ··· 540 557 * 541 558 * See [Get User](#get-user). 542 559 * 560 + * `session_verified` attribute is included. 543 561 * Additionally, `statistics_rulesets` is included, containing statistics for all rulesets. 544 562 * 545 563 * @urlParam mode string [Ruleset](#ruleset). User default mode will be used if not specified. Example: osu ··· 548 566 */ 549 567 public function me($mode = null) 550 568 { 551 - $user = auth()->user(); 569 + $user = \Auth::user(); 552 570 $currentMode = $mode ?? $user->playmode; 553 571 554 572 if (!Beatmap::isModeValid($currentMode)) { ··· 561 579 $user, 562 580 (new UserTransformer())->setMode($currentMode), 563 581 [ 582 + 'session_verified', 564 583 ...$this->showUserIncludes(), 565 584 ...array_map( 566 585 fn (string $ruleset) => "statistics_rulesets.{$ruleset}", ··· 787 806 case 'scoresBest': 788 807 $transformer = new ScoreTransformer(); 789 808 $includes = [...ScoreTransformer::USER_PROFILE_INCLUDES, 'weight']; 790 - $collection = $this->user->beatmapBestScores($this->mode, $perPage, $offset, ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); 809 + $collection = $this->user->beatmapBestScores( 810 + $this->mode, 811 + $perPage, 812 + $offset, 813 + ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD, 814 + ScoreSearchParams::showLegacyForUser(\Auth::user()), 815 + ); 791 816 $userRelationColumn = 'user'; 792 817 break; 793 818 case 'scoresFirsts': 794 819 $transformer = new ScoreTransformer(); 795 820 $includes = ScoreTransformer::USER_PROFILE_INCLUDES; 796 - $query = $this->user->scoresFirst($this->mode, true) 797 - ->visibleUsers() 798 - ->reorderBy('score_id', 'desc') 821 + $scoreQuery = $this->user->scoresFirst($this->mode, true)->unorder(); 822 + $userFirstsQuery = $scoreQuery->select($scoreQuery->qualifyColumn('score_id')); 823 + $query = SoloScore 824 + ::whereIn('legacy_score_id', $userFirstsQuery) 825 + ->where('ruleset_id', Beatmap::MODES[$this->mode]) 826 + ->default() 827 + ->reorderBy('id', 'desc') 799 828 ->with(ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); 800 829 $userRelationColumn = 'user'; 801 830 break; ··· 814 843 case 'scoresRecent': 815 844 $transformer = new ScoreTransformer(); 816 845 $includes = ScoreTransformer::USER_PROFILE_INCLUDES; 817 - $query = $this->user->scores($this->mode, true) 818 - ->includeFails($options['includeFails'] ?? false) 819 - ->with([...ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD, 'best']); 846 + $query = $this->user->soloScores() 847 + ->recent($this->mode, $options['includeFails'] ?? false) 848 + ->reorderBy('unix_updated_at', 'desc') 849 + ->with(ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); 820 850 $userRelationColumn = 'user'; 821 851 break; 822 852 } ··· 982 1012 ); 983 1013 } 984 1014 985 - if ($GLOBALS['cfg']['osu']['user']['registration_mode'] === 'web') { 1015 + if (is_json_request()) { 1016 + return json_item($user->fresh(), new CurrentUserTransformer()); 1017 + } else { 986 1018 $this->login($user); 987 1019 session()->flash('popup', osu_trans('users.store.saved')); 988 1020 989 1021 return ujs_redirect(route('home')); 990 - } else { 991 - return json_item($user->fresh(), new CurrentUserTransformer()); 992 1022 } 993 1023 } catch (ValidationException $e) { 994 1024 return ModelNotSavedException::makeResponse($e, [
+1
app/Http/Kernel.php
··· 24 24 Middleware\SetLocaleApi::class, 25 25 Middleware\CheckUserBanStatus::class, 26 26 Middleware\UpdateUserLastvisit::class, 27 + Middleware\VerifyUserAlways::class, 27 28 ], 28 29 'web' => [ 29 30 Middleware\StripCookies::class,
+8 -3
app/Http/Middleware/AuthApi.php
··· 5 5 6 6 namespace App\Http\Middleware; 7 7 8 + use App\Libraries\SessionVerification; 8 9 use Closure; 9 10 use Illuminate\Auth\AuthenticationException; 10 11 use Laravel\Passport\ClientRepository; ··· 95 96 } 96 97 97 98 if ($user !== null) { 98 - auth()->setUser($user); 99 + \Auth::setUser($user); 99 100 $user->withAccessToken($token); 100 - // this should match osu-notification-server OAuthVerifier 101 - $user->markSessionVerified(); 101 + 102 + if ($token->isVerified()) { 103 + $user->markSessionVerified(); 104 + } else { 105 + SessionVerification\Helper::issue($token, $user, true); 106 + } 102 107 } 103 108 104 109 return $token;
+2 -1
app/Http/Middleware/UpdateUserLastvisit.php
··· 5 5 6 6 namespace App\Http\Middleware; 7 7 8 + use App\Libraries\SessionVerification; 8 9 use App\Models\Country; 9 10 use Carbon\Carbon; 10 11 use Closure; ··· 30 31 if ($shouldUpdate) { 31 32 $isInactive = $user->isInactive(); 32 33 if ($isInactive) { 33 - $isVerified = $user->isSessionVerified(); 34 + $isVerified = SessionVerification\Helper::currentSession()->isVerified(); 34 35 } 35 36 36 37 if (!$isInactive || $isVerified) {
+1
app/Http/Middleware/VerifyUser.php
··· 20 20 'notifications_controller@endpoint' => true, 21 21 'sessions_controller@destroy' => true, 22 22 'sessions_controller@store' => true, 23 + 'users_controller@me' => true, 23 24 'wiki_controller@image' => true, 24 25 'wiki_controller@show' => true, 25 26 'wiki_controller@sitemap' => true,
-2
app/Jobs/RemoveBeatmapsetSoloScores.php
··· 11 11 use App\Models\Beatmap; 12 12 use App\Models\Beatmapset; 13 13 use App\Models\Solo\Score; 14 - use App\Models\Solo\ScorePerformance; 15 14 use DB; 16 15 use Illuminate\Bus\Queueable; 17 16 use Illuminate\Contracts\Queue\ShouldQueue; ··· 68 67 $scoresQuery->update(['preserve' => false]); 69 68 $this->scoreSearch->queueForIndex($this->schemas, $ids); 70 69 DB::transaction(function () use ($ids, $scoresQuery): void { 71 - ScorePerformance::whereKey($ids)->delete(); 72 70 $scoresQuery->delete(); 73 71 }); 74 72 }
+86 -19
app/Libraries/ClientCheck.php
··· 3 3 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 4 // See the LICENCE file in the repository root for full licence text. 5 5 6 + declare(strict_types=1); 7 + 6 8 namespace App\Libraries; 7 9 10 + use App\Exceptions\ClientCheckParseTokenException; 8 11 use App\Models\Build; 12 + use Illuminate\Http\Request; 9 13 10 14 class ClientCheck 11 15 { 12 - public static function findBuild($user, $params): ?Build 16 + public static function parseToken(Request $request): array 13 17 { 14 - $assertValid = $GLOBALS['cfg']['osu']['client']['check_version'] && $user->findUserGroup(app('groups')->byIdentifier('admin'), true) === null; 18 + $token = $request->header('x-token'); 19 + $assertValid = $GLOBALS['cfg']['osu']['client']['check_version']; 20 + $ret = [ 21 + 'buildId' => $GLOBALS['cfg']['osu']['client']['default_build_id'], 22 + 'token' => null, 23 + ]; 24 + 25 + try { 26 + if ($token === null) { 27 + throw new ClientCheckParseTokenException('missing token header'); 28 + } 29 + 30 + $input = static::splitToken($token); 31 + 32 + $build = Build::firstWhere([ 33 + 'hash' => $input['clientHash'], 34 + 'allow_ranking' => true, 35 + ]); 36 + 37 + if ($build === null) { 38 + throw new ClientCheckParseTokenException('invalid client hash'); 39 + } 40 + 41 + $ret['buildId'] = $build->getKey(); 15 42 16 - $clientHash = presence(get_string($params['version_hash'] ?? null)); 17 - if ($clientHash === null) { 18 - if ($assertValid) { 19 - abort(422, 'missing client version'); 20 - } else { 21 - return null; 43 + $computed = hash_hmac( 44 + 'sha1', 45 + $input['clientData'], 46 + static::getKey($build), 47 + true, 48 + ); 49 + 50 + if (!hash_equals($computed, $input['expected'])) { 51 + throw new ClientCheckParseTokenException('invalid verification hash'); 22 52 } 53 + 54 + $now = time(); 55 + static $maxTime = 15 * 60; 56 + if (abs($now - $input['clientTime']) > $maxTime) { 57 + throw new ClientCheckParseTokenException('expired token'); 58 + } 59 + 60 + $ret['token'] = $token; 61 + // to be included in queue 62 + $ret['body'] = base64_encode($request->getContent()); 63 + $ret['url'] = $request->getRequestUri(); 64 + } catch (ClientCheckParseTokenException $e) { 65 + abort_if($assertValid, 422, $e->getMessage()); 23 66 } 24 67 25 - // temporary measure to allow android builds to submit without access to the underlying dll to hash 26 - if (strlen($clientHash) !== 32) { 27 - $clientHash = md5($clientHash); 68 + return $ret; 69 + } 70 + 71 + public static function queueToken(?array $tokenData, int $scoreId): void 72 + { 73 + if ($tokenData['token'] === null) { 74 + return; 28 75 } 29 76 30 - $build = Build::firstWhere([ 31 - 'hash' => hex2bin($clientHash), 32 - 'allow_ranking' => true, 33 - ]); 77 + \LaravelRedis::lpush($GLOBALS['cfg']['osu']['client']['token_queue'], json_encode([ 78 + 'body' => $tokenData['body'], 79 + 'id' => $scoreId, 80 + 'token' => $tokenData['token'], 81 + 'url' => $tokenData['url'], 82 + ])); 83 + } 34 84 35 - if ($build === null && $assertValid) { 36 - abort(422, 'invalid client hash'); 37 - } 85 + private static function getKey(Build $build): string 86 + { 87 + return $GLOBALS['cfg']['osu']['client']['token_keys'][$build->platform()] 88 + ?? $GLOBALS['cfg']['osu']['client']['token_keys']['default'] 89 + ?? ''; 90 + } 38 91 39 - return $build; 92 + private static function splitToken(string $token): array 93 + { 94 + $data = substr($token, -82); 95 + $clientTimeHex = substr($data, 32, 8); 96 + $clientTime = strlen($clientTimeHex) === 8 97 + ? unpack('V', hex2bin($clientTimeHex))[1] 98 + : 0; 99 + 100 + return [ 101 + 'clientData' => substr($data, 0, 40), 102 + 'clientHash' => hex2bin(substr($data, 0, 32)), 103 + 'clientTime' => $clientTime, 104 + 'expected' => hex2bin(substr($data, 40, 40)), 105 + 'version' => substr($data, 80, 2), 106 + ]; 40 107 } 41 108 }
+1 -3
app/Libraries/Elasticsearch/Search.php
··· 22 22 /** 23 23 * A tag to use when logging timing of fetches. 24 24 * FIXME: context-based tagging would be nicer. 25 - * 26 - * @var string|null 27 25 */ 28 - public $loggingTag; 26 + public ?string $loggingTag; 29 27 30 28 protected $aggregations; 31 29 protected $index;
+45
app/Libraries/OAuth/EncodeToken.php
··· 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 + 6 + declare(strict_types=1); 7 + 8 + namespace App\Libraries\OAuth; 9 + 10 + use App\Models\OAuth\Token; 11 + use Defuse\Crypto\Crypto; 12 + use Firebase\JWT\JWT; 13 + use Laravel\Passport\Passport; 14 + use Laravel\Passport\RefreshToken; 15 + 16 + class EncodeToken 17 + { 18 + public static function encodeAccessToken(Token $token): string 19 + { 20 + $privateKey = $GLOBALS['cfg']['passport']['private_key'] 21 + ?? file_get_contents(Passport::keyPath('oauth-private.key')); 22 + 23 + return JWT::encode([ 24 + 'aud' => $token->client_id, 25 + 'exp' => $token->expires_at->timestamp, 26 + 'iat' => $token->created_at->timestamp, // issued at 27 + 'jti' => $token->getKey(), 28 + 'nbf' => $token->created_at->timestamp, // valid after 29 + 'sub' => $token->user_id, 30 + 'scopes' => $token->scopes, 31 + ], $privateKey, 'RS256'); 32 + } 33 + 34 + public static function encodeRefreshToken(RefreshToken $refreshToken, Token $accessToken): string 35 + { 36 + return Crypto::encryptWithPassword(json_encode([ 37 + 'client_id' => (string) $accessToken->client_id, 38 + 'refresh_token_id' => $refreshToken->getKey(), 39 + 'access_token_id' => $accessToken->getKey(), 40 + 'scopes' => $accessToken->scopes, 41 + 'user_id' => $accessToken->user_id, 42 + 'expire_time' => $refreshToken->expires_at->timestamp, 43 + ]), \Crypt::getKey()); 44 + } 45 + }
+40
app/Libraries/OAuth/RefreshTokenGrant.php
··· 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 + 6 + declare(strict_types=1); 7 + 8 + namespace App\Libraries\OAuth; 9 + 10 + use App\Models\OAuth\Token; 11 + use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant; 12 + use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; 13 + use Psr\Http\Message\ServerRequestInterface; 14 + 15 + class RefreshTokenGrant extends BaseRefreshTokenGrant 16 + { 17 + private ?array $oldRefreshToken = null; 18 + 19 + public function respondToAccessTokenRequest( 20 + ServerRequestInterface $request, 21 + ResponseTypeInterface $responseType, 22 + \DateInterval $accessTokenTTL 23 + ) { 24 + $refreshTokenData = parent::respondToAccessTokenRequest($request, $responseType, $accessTokenTTL); 25 + 26 + // Copy previous verification state 27 + $accessToken = (new \ReflectionProperty($refreshTokenData, 'accessToken'))->getValue($refreshTokenData); 28 + Token::where('id', $accessToken->getIdentifier())->update([ 29 + 'verified' => Token::select('verified')->find($this->oldRefreshToken['access_token_id'])->verified, 30 + ]); 31 + $this->oldRefreshToken = null; 32 + 33 + return $refreshTokenData; 34 + } 35 + 36 + protected function validateOldRefreshToken(ServerRequestInterface $request, $clientId) 37 + { 38 + return $this->oldRefreshToken = parent::validateOldRefreshToken($request, $clientId); 39 + } 40 + }
+6 -2
app/Libraries/Score/FetchDedupedScores.php
··· 15 15 private int $limit; 16 16 private array $result; 17 17 18 - public function __construct(private string $dedupeColumn, private ScoreSearchParams $params) 19 - { 18 + public function __construct( 19 + private string $dedupeColumn, 20 + private ScoreSearchParams $params, 21 + private ?string $searchLoggingTag = null 22 + ) { 20 23 $this->limit = $this->params->size; 21 24 } 22 25 ··· 24 27 { 25 28 $this->params->size = $this->limit + 50; 26 29 $search = new ScoreSearch($this->params); 30 + $search->loggingTag = $this->searchLoggingTag; 27 31 28 32 $nextCursor = null; 29 33 $hasNext = true;
+24 -25
app/Libraries/Search/BeatmapsetSearch.php
··· 13 13 use App\Models\Beatmap; 14 14 use App\Models\Beatmapset; 15 15 use App\Models\Follow; 16 - use App\Models\Score; 16 + use App\Models\Solo; 17 17 use App\Models\User; 18 + use Ds\Set; 18 19 19 20 class BeatmapsetSearch extends RecordSearch 20 21 { ··· 423 424 424 425 private function getPlayedBeatmapIds(?array $rank = null) 425 426 { 426 - $unionQuery = null; 427 + $query = Solo\Score 428 + ::where('user_id', $this->params->user->getKey()) 429 + ->whereIn('ruleset_id', $this->getSelectedModes()); 427 430 428 - $select = $rank === null ? 'beatmap_id' : ['beatmap_id', 'score', 'rank']; 431 + if ($rank === null) { 432 + return $query->distinct('beatmap_id')->pluck('beatmap_id'); 433 + } 429 434 430 - foreach ($this->getSelectedModes() as $mode) { 431 - $newQuery = Score\Best\Model::getClassByRulesetId($mode) 432 - ::forUser($this->params->user) 433 - ->select($select); 435 + $topScores = []; 436 + $scoreField = ScoreSearchParams::showLegacyForUser($this->params->user) 437 + ? 'legacy_total_score' 438 + : 'total_score'; 439 + foreach ($query->get() as $score) { 440 + $prevScore = $topScores[$score->beatmap_id] ?? null; 434 441 435 - if ($unionQuery === null) { 436 - $unionQuery = $newQuery; 437 - } else { 438 - $unionQuery->union($newQuery); 442 + $scoreValue = $score->$scoreField; 443 + if ($scoreValue !== null && ($prevScore === null || $prevScore->$scoreField < $scoreValue)) { 444 + $topScores[$score->beatmap_id] = $score; 439 445 } 440 446 } 441 447 442 - if ($rank === null) { 443 - return model_pluck($unionQuery, 'beatmap_id'); 444 - } else { 445 - $allScores = $unionQuery->get(); 446 - $beatmapRank = collect(); 447 - 448 - foreach ($allScores as $score) { 449 - $prevScore = $beatmapRank[$score->beatmap_id] ?? null; 450 - 451 - if ($prevScore === null || $prevScore->score < $score->score) { 452 - $beatmapRank[$score->beatmap_id] = $score; 453 - } 448 + $ret = []; 449 + $rankSet = new Set($rank); 450 + foreach ($topScores as $beatmapId => $score) { 451 + if ($rankSet->contains($score->rank)) { 452 + $ret[] = $beatmapId; 454 453 } 454 + } 455 455 456 - return $beatmapRank->whereInStrict('rank', $rank)->pluck('beatmap_id')->all(); 457 - } 456 + return $ret; 458 457 } 459 458 460 459 private function getSelectedModes()
+11 -6
app/Libraries/Search/ScoreSearch.php
··· 48 48 if ($this->params->userId !== null) { 49 49 $query->filter(['term' => ['user_id' => $this->params->userId]]); 50 50 } 51 + if ($this->params->excludeConverts) { 52 + $query->filter(['term' => ['convert' => false]]); 53 + } 51 54 if ($this->params->excludeMods !== null && count($this->params->excludeMods) > 0) { 52 55 foreach ($this->params->excludeMods as $excludedMod) { 53 56 $query->mustNot(['term' => ['mods' => $excludedMod]]); ··· 67 70 68 71 $beforeTotalScore = $this->params->beforeTotalScore; 69 72 if ($beforeTotalScore === null && $this->params->beforeScore !== null) { 70 - $beforeTotalScore = $this->params->beforeScore->isLegacy() 71 - ? $this->params->beforeScore->data->legacyTotalScore 72 - : $this->params->beforeScore->data->totalScore; 73 + $beforeTotalScore = $this->params->isLegacy 74 + ? $this->params->beforeScore->legacy_total_score 75 + : $this->params->beforeScore->total_score; 73 76 } 74 77 if ($beforeTotalScore !== null) { 75 78 $scoreQuery = (new BoolQuery())->shouldMatch(1); 79 + $scoreField = $this->params->isLegacy ? 'legacy_total_score' : 'total_score'; 76 80 $scoreQuery->should((new BoolQuery())->filter(['range' => [ 77 - 'total_score' => ['gt' => $beforeTotalScore], 81 + $scoreField => ['gt' => $beforeTotalScore], 78 82 ]])); 79 83 if ($this->params->beforeScore !== null) { 80 84 $scoreQuery->should((new BoolQuery()) 81 85 ->filter(['range' => ['id' => ['lt' => $this->params->beforeScore->getKey()]]]) 82 - ->filter(['term' => ['total_score' => $beforeTotalScore]])); 86 + ->filter(['term' => [$scoreField => $beforeTotalScore]])); 83 87 } 84 88 85 89 $query->must($scoreQuery); ··· 142 146 $allMods = $this->params->rulesetId === null 143 147 ? $modsHelper->allIds 144 148 : new Set(array_keys($modsHelper->mods[$this->params->rulesetId])); 145 - $allMods->remove('PF', 'SD', 'MR'); 149 + // CL is currently considered a "preference" mod 150 + $allMods->remove('CL', 'PF', 'SD', 'MR'); 146 151 147 152 $allSearchMods = []; 148 153 foreach ($mods as $mod) {
+36 -5
app/Libraries/Search/ScoreSearchParams.php
··· 21 21 public ?array $beatmapIds = null; 22 22 public ?Score $beforeScore = null; 23 23 public ?int $beforeTotalScore = null; 24 + public bool $excludeConverts = false; 24 25 public ?array $excludeMods = null; 25 26 public ?bool $isLegacy = null; 26 27 public ?array $mods = null; ··· 36 37 { 37 38 $params = new static(); 38 39 $params->beatmapIds = $rawParams['beatmap_ids'] ?? null; 40 + $params->excludeConverts = $rawParams['exclude_converts'] ?? $params->excludeConverts; 39 41 $params->excludeMods = $rawParams['exclude_mods'] ?? null; 40 42 $params->isLegacy = $rawParams['is_legacy'] ?? null; 41 43 $params->mods = $rawParams['mods'] ?? null; ··· 55 57 } 56 58 57 59 /** 58 - * This returns value for isLegacy based on user preference 60 + * This returns value for isLegacy based on user preference, request type, and `legacy_only` parameter 59 61 */ 60 - public static function showLegacyForUser(?User $user): null | true 61 - { 62 + public static function showLegacyForUser( 63 + ?User $user = null, 64 + ?bool $legacyOnly = null, 65 + ?bool $isApiRequest = null 66 + ): null | true { 67 + $isApiRequest ??= is_api_request(); 68 + // `null` is actual parameter value for the other two parameters so 69 + // only try filling them up if not passed at all. 70 + $argLen = func_num_args(); 71 + if ($argLen < 2) { 72 + $legacyOnly = get_bool(Request('legacy_only')); 73 + 74 + if ($argLen < 1) { 75 + $user = \Auth::user(); 76 + } 77 + } 78 + 79 + if ($legacyOnly !== null) { 80 + return $legacyOnly ? true : null; 81 + } 82 + 83 + if ($isApiRequest) { 84 + return null; 85 + } 86 + 62 87 return $user?->userProfileCustomization?->legacy_score_only ?? UserProfileCustomization::DEFAULT_LEGACY_ONLY_ATTRIBUTE 63 88 ? true 64 89 : null; ··· 93 118 { 94 119 switch ($sort) { 95 120 case 'score_desc': 121 + $sortColumn = $this->isLegacy ? 'legacy_total_score' : 'total_score'; 96 122 $this->sorts = [ 97 - new Sort('is_legacy', 'asc'), 98 - new Sort('total_score', 'desc'), 123 + new Sort($sortColumn, 'desc'), 124 + new Sort('id', 'asc'), 125 + ]; 126 + break; 127 + case 'pp_desc': 128 + $this->sorts = [ 129 + new Sort('pp', 'desc'), 99 130 new Sort('id', 'asc'), 100 131 ]; 101 132 break;
+11 -7
app/Libraries/SessionVerification/Controller.php
··· 21 21 $user = Helper::currentUserOrFail(); 22 22 $email = $user->user_email; 23 23 24 - $session = \Session::instance(); 25 - if (State::fromSession($session) === null) { 26 - Helper::logAttempt('input', 'new'); 24 + $session = Helper::currentSession(); 25 + Helper::issue($session, $user, true); 27 26 28 - Helper::issue($session, $user); 27 + if (is_api_request()) { 28 + return response(null, $statusCode); 29 29 } 30 30 31 31 if (\Request::ajax()) { ··· 43 43 44 44 public static function reissue() 45 45 { 46 - $session = \Session::instance(); 46 + $session = Helper::currentSession(); 47 47 if ($session->isVerified()) { 48 - return response(null, 204); 48 + return response(null, 422); 49 49 } 50 50 51 51 Helper::issue($session, Helper::currentUserOrFail()); ··· 55 55 56 56 public static function verify() 57 57 { 58 + $session = Helper::currentSession(); 59 + if ($session->isVerified()) { 60 + return response(null, 204); 61 + } 62 + 58 63 $key = strtr(get_string(\Request::input('verification_key')) ?? '', [' ' => '']); 59 64 $user = Helper::currentUserOrFail(); 60 - $session = \Session::instance(); 61 65 $state = State::fromSession($session); 62 66 63 67 try {
+14 -1
app/Libraries/SessionVerification/Helper.php
··· 15 15 16 16 class Helper 17 17 { 18 + public static function currentSession(): ?SessionVerificationInterface 19 + { 20 + return is_api_request() ? oauth_token() : \Session::instance(); 21 + } 22 + 18 23 public static function currentUserOrFail(): User 19 24 { 20 25 $user = \Auth::user(); ··· 23 28 return $user; 24 29 } 25 30 26 - public static function issue(SessionVerificationInterface $session, User $user): void 31 + public static function issue(SessionVerificationInterface $session, User $user, bool $initial = false): void 27 32 { 33 + if ($initial) { 34 + if (State::fromSession($session) === null) { 35 + static::logAttempt('input', 'new'); 36 + } else { 37 + return; 38 + } 39 + } 40 + 28 41 if (!is_valid_email_format($user->user_email)) { 29 42 return; 30 43 }
+50 -58
app/Models/BeatmapPack.php
··· 5 5 6 6 namespace App\Models; 7 7 8 + use App\Libraries\Search\ScoreSearch; 9 + use App\Libraries\Search\ScoreSearchParams; 8 10 use App\Models\Traits\WithDbCursorHelper; 9 - use Exception; 11 + use Ds\Set; 10 12 11 13 /** 12 14 * @property string $author ··· 92 94 return 'tag'; 93 95 } 94 96 95 - public function userCompletionData($user) 97 + public function userCompletionData($user, ?bool $isLegacy) 96 98 { 97 99 if ($user !== null) { 98 100 $userId = $user->getKey(); 99 - $beatmapsetIds = $this->items()->pluck('beatmapset_id')->all(); 100 - $query = Beatmap::select('beatmapset_id')->distinct()->whereIn('beatmapset_id', $beatmapsetIds); 101 - 102 - if ($this->playmode === null) { 103 - static $scoreRelations; 104 - 105 - // generate list of beatmap->score relation names for each modes 106 - // store int mode as well as it'll be used for filtering the scores 107 - if (!isset($scoreRelations)) { 108 - $scoreRelations = []; 109 - foreach (Beatmap::MODES as $modeStr => $modeInt) { 110 - $scoreRelations[] = [ 111 - 'playmode' => $modeInt, 112 - 'relation' => camel_case("scores_best_{$modeStr}"), 113 - ]; 114 - } 115 - } 116 - 117 - // outer where function 118 - // The idea is SELECT ... WHERE ... AND (<has osu scores> OR <has taiko scores> OR ...). 119 - $query->where(function ($q) use ($scoreRelations, $userId) { 120 - foreach ($scoreRelations as $scoreRelation) { 121 - // The <has <mode> scores> mentioned above is generated here. 122 - // As it's "playmode = <mode> AND EXISTS (<<mode> score for user>)", 123 - // wrap them so it's not flat "playmode = <mode> AND EXISTS ... OR playmode = <mode> AND EXISTS ...". 124 - $q->orWhere(function ($qq) use ($scoreRelation, $userId) { 125 - $qq 126 - // this playmode filter ensures the scores are limited to non-convert maps 127 - ->where('playmode', '=', $scoreRelation['playmode']) 128 - ->whereHas($scoreRelation['relation'], function ($scoreQuery) use ($userId) { 129 - $scoreQuery->where('user_id', '=', $userId); 130 - 131 - if ($this->no_diff_reduction) { 132 - $scoreQuery->withoutMods(app('mods')->difficultyReductionIds->toArray()); 133 - } 134 - }); 135 - }); 136 - } 137 - }); 138 - } else { 139 - $modeStr = Beatmap::modeStr($this->playmode); 140 - 141 - if ($modeStr === null) { 142 - throw new Exception("beatmapset pack {$this->getKey()} has invalid playmode: {$this->playmode}"); 143 - } 144 - 145 - $scoreRelation = camel_case("scores_best_{$modeStr}"); 146 - 147 - $query->whereHas($scoreRelation, function ($query) use ($userId) { 148 - $query->where('user_id', '=', $userId); 149 101 150 - if ($this->no_diff_reduction) { 151 - $query->withoutMods(app('mods')->difficultyReductionIds->toArray()); 152 - } 153 - }); 102 + $beatmaps = Beatmap 103 + ::whereIn('beatmapset_id', $this->items()->select('beatmapset_id')) 104 + ->select(['beatmap_id', 'beatmapset_id', 'playmode']) 105 + ->get(); 106 + $beatmapsetIdsByBeatmapId = []; 107 + foreach ($beatmaps as $beatmap) { 108 + $beatmapsetIdsByBeatmapId[$beatmap->beatmap_id] = $beatmap->beatmapset_id; 109 + } 110 + $params = [ 111 + 'beatmap_ids' => array_keys($beatmapsetIdsByBeatmapId), 112 + 'exclude_converts' => $this->playmode === null, 113 + 'is_legacy' => $isLegacy, 114 + 'limit' => 0, 115 + 'ruleset_id' => $this->playmode, 116 + 'user_id' => $userId, 117 + ]; 118 + if ($this->no_diff_reduction) { 119 + $params['exclude_mods'] = app('mods')->difficultyReductionIds->toArray(); 154 120 } 155 121 156 - $completedBeatmapsetIds = $query->pluck('beatmapset_id')->all(); 157 - $completed = count($completedBeatmapsetIds) === count($beatmapsetIds); 122 + static $aggName = 'by_beatmap'; 123 + 124 + $search = new ScoreSearch(ScoreSearchParams::fromArray($params)); 125 + $search->size(0); 126 + $search->setAggregations([$aggName => [ 127 + 'terms' => [ 128 + 'field' => 'beatmap_id', 129 + 'size' => max(1, count($params['beatmap_ids'])), 130 + ], 131 + 'aggs' => [ 132 + 'scores' => [ 133 + 'top_hits' => [ 134 + 'size' => 1, 135 + ], 136 + ], 137 + ], 138 + ]]); 139 + $response = $search->response(); 140 + $search->assertNoError(); 141 + $completedBeatmapIds = array_map( 142 + fn (array $hit): int => (int) $hit['key'], 143 + $response->aggregations($aggName)['buckets'], 144 + ); 145 + $completedBeatmapsetIds = (new Set(array_map( 146 + fn (int $beatmapId): int => $beatmapsetIdsByBeatmapId[$beatmapId], 147 + $completedBeatmapIds, 148 + )))->toArray(); 149 + $completed = count($completedBeatmapsetIds) === count(array_unique($beatmapsetIdsByBeatmapId)); 158 150 } 159 151 160 152 return [
+10
app/Models/Build.php
··· 198 198 // no image 199 199 } 200 200 201 + public function platform(): string 202 + { 203 + $version = $this->version; 204 + $suffixPos = strpos($version, '-'); 205 + 206 + return $suffixPos === false 207 + ? '' 208 + : substr($version, $suffixPos + 1); 209 + } 210 + 201 211 public function url() 202 212 { 203 213 return build_url($this);
+3 -3
app/Models/Multiplayer/PlaylistItemUserHighScore.php
··· 71 71 { 72 72 $placeholder = new static([ 73 73 'score_id' => $scoreLink->getKey(), 74 - 'total_score' => $scoreLink->score->data->totalScore, 74 + 'total_score' => $scoreLink->score->total_score, 75 75 ]); 76 76 77 77 static $typeOptions = [ ··· 117 117 $score = $scoreLink->score; 118 118 119 119 $this->fill([ 120 - 'accuracy' => $score->data->accuracy, 120 + 'accuracy' => $score->accuracy, 121 121 'pp' => $score->pp, 122 122 'score_id' => $scoreLink->getKey(), 123 - 'total_score' => $score->data->totalScore, 123 + 'total_score' => $score->total_score, 124 124 ])->save(); 125 125 } 126 126 }
+1 -1
app/Models/Multiplayer/ScoreLink.php
··· 109 109 $query = PlaylistItemUserHighScore 110 110 ::where('playlist_item_id', $this->playlist_item_id) 111 111 ->cursorSort('score_asc', [ 112 - 'total_score' => $score->data->totalScore, 112 + 'total_score' => $score->total_score, 113 113 'score_id' => $this->getKey(), 114 114 ]); 115 115
+4 -4
app/Models/Multiplayer/UserScoreAggregate.php
··· 83 83 $scoreLink->playlist_item_id, 84 84 ); 85 85 86 - if ($score->data->passed && $score->data->totalScore > $highestScore->total_score) { 86 + if ($score->passed && $score->total_score > $highestScore->total_score) { 87 87 $this->updateUserTotal($scoreLink, $highestScore); 88 88 $highestScore->updateWithScoreLink($scoreLink); 89 89 } ··· 134 134 $scoreLinks = ScoreLink 135 135 ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id)) 136 136 ->where('user_id', $this->user_id) 137 - ->with('score.performance') 137 + ->with('score') 138 138 ->get(); 139 139 foreach ($scoreLinks as $scoreLink) { 140 140 $this->addScoreLink( ··· 221 221 222 222 $current = $currentScoreLink->score; 223 223 224 - $this->total_score += $current->data->totalScore; 225 - $this->accuracy += $current->data->accuracy; 224 + $this->total_score += $current->total_score; 225 + $this->accuracy += $current->accuracy; 226 226 $this->pp += $current->pp; 227 227 $this->completed++; 228 228 $this->last_score_id = $currentScoreLink->getKey();
+51 -4
app/Models/OAuth/Token.php
··· 7 7 8 8 use App\Events\UserSessionEvent; 9 9 use App\Exceptions\InvalidScopeException; 10 + use App\Interfaces\SessionVerificationInterface; 10 11 use App\Models\Traits\FasterAttributes; 11 12 use App\Models\User; 12 13 use Ds\Set; ··· 14 15 use Laravel\Passport\RefreshToken; 15 16 use Laravel\Passport\Token as PassportToken; 16 17 17 - class Token extends PassportToken 18 + class Token extends PassportToken implements SessionVerificationInterface 18 19 { 19 20 // PassportToken doesn't have factory 20 21 use HasFactory, FasterAttributes; 21 22 23 + protected $casts = [ 24 + 'expires_at' => 'datetime', 25 + 'revoked' => 'boolean', 26 + 'scopes' => 'array', 27 + 'verified' => 'boolean', 28 + ]; 29 + 22 30 private ?Set $scopeSet; 23 31 32 + public static function findForVerification(string $id): ?static 33 + { 34 + return static::find($id); 35 + } 36 + 24 37 public function refreshToken() 25 38 { 26 39 return $this->hasOne(RefreshToken::class, 'access_token_id'); ··· 49 62 'name', 50 63 'user_id' => $this->getRawAttribute($key), 51 64 52 - 'revoked' => (bool) $this->getRawAttribute($key), 53 - 'scopes' => json_decode($this->getRawAttribute($key), true), 65 + 'revoked', 66 + 'verified' => $this->getNullableBool($key), 67 + 68 + 'scopes' => json_decode($this->getRawAttribute($key) ?? 'null', true), 54 69 55 70 'created_at', 56 71 'expires_at', ··· 62 77 }; 63 78 } 64 79 80 + public function getKeyForEvent(): string 81 + { 82 + return "oauth:{$this->getKey()}"; 83 + } 84 + 65 85 /** 66 86 * Resource owner for the token. 67 87 * ··· 90 110 return $clientUserId !== null && $clientUserId === $this->user_id; 91 111 } 92 112 113 + public function isVerified(): bool 114 + { 115 + return $this->verified; 116 + } 117 + 118 + public function markVerified(): void 119 + { 120 + $this->update(['verified' => true]); 121 + } 122 + 93 123 public function revokeRecursive() 94 124 { 95 125 $result = $this->revoke(); ··· 103 133 $saved = parent::revoke(); 104 134 105 135 if ($saved && $this->user_id !== null) { 106 - UserSessionEvent::newLogout($this->user_id, ["oauth:{$this->getKey()}"])->broadcast(); 136 + UserSessionEvent::newLogout($this->user_id, [$this->getKeyForEvent()])->broadcast(); 107 137 } 108 138 109 139 return $saved; ··· 124 154 $this->attributes['scopes'] = $this->castAttributeAsJson('scopes', $value); 125 155 } 126 156 157 + public function userId(): ?int 158 + { 159 + return $this->user_id; 160 + } 161 + 127 162 public function validate(): void 128 163 { 129 164 static $scopesRequireDelegation = new Set(['chat.write', 'chat.write_manage', 'delegate']); ··· 185 220 { 186 221 // Forces error if passport tries to issue an invalid client_credentials token. 187 222 $this->validate(); 223 + if (!$this->exists) { 224 + $this->setVerifiedState(); 225 + } 188 226 189 227 return parent::save($options); 190 228 } ··· 192 230 private function scopeSet(): Set 193 231 { 194 232 return $this->scopeSet ??= new Set($this->scopes ?? []); 233 + } 234 + 235 + private function setVerifiedState(): void 236 + { 237 + // client credential doesn't have user attached and auth code is 238 + // already verified during grant process 239 + $this->verified ??= $GLOBALS['cfg']['osu']['user']['bypass_verification'] 240 + || $this->user === null 241 + || !$this->client->password_client; 195 242 } 196 243 }
+5 -1
app/Models/Score/Best/Model.php
··· 83 83 'date_json' => $this->getJsonTimeFast($key), 84 84 85 85 'best' => $this, 86 - 'data' => $this->getData(), 87 86 'enabled_mods' => $this->getEnabledModsAttribute($this->getRawAttribute('enabled_mods')), 88 87 'pass' => true, 89 88 89 + 'best_id' => $this->getKey(), 90 + 'has_replay' => $this->replay, 91 + 90 92 'beatmap', 91 93 'replayViewCount', 92 94 'reportedIn', 93 95 'user' => $this->getRelationValue($key), 96 + 97 + default => $this->getNewScoreAttribute($key), 94 98 }; 95 99 } 96 100
+33 -20
app/Models/Score/Model.php
··· 5 5 6 6 namespace App\Models\Score; 7 7 8 + use App\Enums\Ruleset; 8 9 use App\Exceptions\ClassNotFoundException; 9 10 use App\Libraries\Mods; 10 11 use App\Models\Beatmap; ··· 146 147 147 148 'date_json' => $this->getJsonTimeFast($key), 148 149 149 - 'data' => $this->getData(), 150 150 'enabled_mods' => $this->getEnabledModsAttribute($this->getRawAttribute('enabled_mods')), 151 151 152 + 'best_id' => $this->getRawAttribute('high_score_id'), 153 + 'has_replay' => $this->best?->replay, 154 + 'pp' => $this->best?->pp, 155 + 152 156 'beatmap', 153 157 'best', 154 158 'replayViewCount', 155 159 'user' => $this->getRelationValue($key), 160 + 161 + default => $this->getNewScoreAttribute($key), 162 + }; 163 + } 164 + 165 + public function getNewScoreAttribute(string $key) 166 + { 167 + return match ($key) { 168 + 'accuracy' => $this->accuracy(), 169 + 'build_id' => null, 170 + 'data' => $this->getData(), 171 + 'ended_at_json' => $this->date_json, 172 + 'legacy_perfect' => $this->perfect, 173 + 'legacy_score_id' => $this->getKey(), 174 + 'legacy_total_score' => $this->score, 175 + 'max_combo' => $this->maxcombo, 176 + 'passed' => $this->pass, 177 + 'ruleset_id' => Ruleset::tryFromName($this->getMode())->value, 178 + 'started_at_json' => null, 179 + 'total_score' => $this->score, 156 180 }; 157 181 } 158 182 ··· 161 185 return snake_case(get_class_basename(static::class)); 162 186 } 163 187 164 - protected function getData() 188 + public function getData(): ScoreData 165 189 { 166 190 $mods = array_map(fn ($m) => ['acronym' => $m, 'settings' => []], $this->enabled_mods); 191 + 167 192 $statistics = [ 168 193 'miss' => $this->countmiss, 169 194 'great' => $this->count300, 170 195 ]; 171 - $ruleset = $this->getMode(); 196 + $ruleset = Ruleset::tryFromName($this->getMode()); 172 197 switch ($ruleset) { 173 - case 'osu': 198 + case Ruleset::osu: 174 199 $statistics['ok'] = $this->count100; 175 200 $statistics['meh'] = $this->count50; 176 201 break; 177 - case 'taiko': 202 + case Ruleset::taiko: 178 203 $statistics['ok'] = $this->count100; 179 204 break; 180 - case 'fruits': 205 + case Ruleset::catch: 181 206 $statistics['large_tick_hit'] = $this->count100; 182 207 $statistics['small_tick_hit'] = $this->count50; 183 208 $statistics['small_tick_miss'] = $this->countkatu; 184 209 break; 185 - case 'mania': 210 + case Ruleset::mania: 186 211 $statistics['perfect'] = $this->countgeki; 187 212 $statistics['good'] = $this->countkatu; 188 213 $statistics['ok'] = $this->count100; ··· 190 215 break; 191 216 } 192 217 193 - return new ScoreData([ 194 - 'accuracy' => $this->accuracy(), 195 - 'beatmap_id' => $this->beatmap_id, 196 - 'ended_at' => $this->date_json, 197 - 'max_combo' => $this->maxcombo, 198 - 'mods' => $mods, 199 - 'passed' => $this->pass, 200 - 'rank' => $this->rank, 201 - 'ruleset_id' => Beatmap::modeInt($ruleset), 202 - 'statistics' => $statistics, 203 - 'total_score' => $this->score, 204 - 'user_id' => $this->user_id, 205 - ]); 218 + return new ScoreData(compact('mods', 'statistics')); 206 219 } 207 220 }
+142 -75
app/Models/Solo/Score.php
··· 7 7 8 8 namespace App\Models\Solo; 9 9 10 + use App\Enums\ScoreRank; 11 + use App\Exceptions\InvariantException; 10 12 use App\Libraries\Score\UserRank; 11 13 use App\Libraries\Search\ScoreSearchParams; 12 14 use App\Models\Beatmap; 15 + use App\Models\Beatmapset; 13 16 use App\Models\Model; 14 17 use App\Models\Multiplayer\ScoreLink as MultiplayerScoreLink; 15 18 use App\Models\Score as LegacyScore; 16 19 use App\Models\ScoreToken; 17 20 use App\Models\Traits; 18 21 use App\Models\User; 19 - use Carbon\Carbon; 20 22 use Illuminate\Database\Eloquent\Builder; 21 23 use LaravelRedis; 22 24 use Storage; 23 25 24 26 /** 27 + * @property float $accuracy 25 28 * @property int $beatmap_id 26 - * @property \Carbon\Carbon|null $created_at 27 - * @property string|null $created_at_json 29 + * @property int $build_id 28 30 * @property ScoreData $data 31 + * @property \Carbon\Carbon|null $ended_at 32 + * @property string|null $ended_at_json 29 33 * @property bool $has_replay 30 34 * @property int $id 35 + * @property int $legacy_score_id 36 + * @property int $legacy_total_score 37 + * @property int $max_combo 38 + * @property bool $passed 39 + * @property float $pp 31 40 * @property bool $preserve 41 + * @property string $rank 32 42 * @property bool $ranked 33 43 * @property int $ruleset_id 44 + * @property \Carbon\Carbon|null $started_at 45 + * @property string|null $started_at_json 46 + * @property int $total_score 34 47 * @property int $unix_updated_at 35 48 * @property User $user 36 49 * @property int $user_id ··· 43 56 44 57 protected $casts = [ 45 58 'data' => ScoreData::class, 59 + 'ended_at' => 'datetime', 46 60 'has_replay' => 'boolean', 61 + 'passed' => 'boolean', 47 62 'preserve' => 'boolean', 63 + 'ranked' => 'boolean', 64 + 'started_at' => 'datetime', 48 65 ]; 49 66 50 - public static function createFromJsonOrExplode(array $params) 67 + public static function createFromJsonOrExplode(array $params): static 51 68 { 52 - $score = new static([ 53 - 'beatmap_id' => $params['beatmap_id'], 54 - 'ruleset_id' => $params['ruleset_id'], 55 - 'user_id' => $params['user_id'], 56 - 'data' => $params, 57 - ]); 69 + $params['data'] = [ 70 + 'maximum_statistics' => $params['maximum_statistics'] ?? [], 71 + 'mods' => $params['mods'] ?? [], 72 + 'statistics' => $params['statistics'] ?? [], 73 + ]; 74 + unset( 75 + $params['maximum_statistics'], 76 + $params['mods'], 77 + $params['statistics'], 78 + ); 79 + 80 + $score = new static($params); 58 81 59 - $score->data->assertCompleted(); 82 + $score->assertCompleted(); 60 83 61 84 // this should potentially just be validation rather than applying this logic here, but 62 85 // older lazer builds potentially submit incorrect details here (and we still want to 63 86 // accept their scores. 64 - if (!$score->data->passed) { 65 - $score->data->rank = 'F'; 87 + if (!$score->passed) { 88 + $score->rank = 'F'; 66 89 } 67 90 68 91 $score->saveOrExplode(); ··· 70 93 return $score; 71 94 } 72 95 73 - public static function extractParams(array $params, ScoreToken|MultiplayerScoreLink $scoreToken): array 96 + public static function extractParams(array $rawParams, ScoreToken|MultiplayerScoreLink $scoreToken): array 74 97 { 75 - return [ 76 - ...get_params($params, null, [ 77 - 'accuracy:float', 78 - 'max_combo:int', 79 - 'maximum_statistics:array', 80 - 'passed:bool', 81 - 'rank:string', 82 - 'statistics:array', 83 - 'total_score:int', 84 - ]), 85 - 'beatmap_id' => $scoreToken->beatmap_id, 86 - 'build_id' => $scoreToken->build_id, 87 - 'ended_at' => json_time(Carbon::now()), 88 - 'mods' => app('mods')->parseInputArray($scoreToken->ruleset_id, get_arr($params['mods'] ?? null) ?? []), 89 - 'ruleset_id' => $scoreToken->ruleset_id, 90 - 'started_at' => $scoreToken->created_at_json, 91 - 'user_id' => $scoreToken->user_id, 92 - ]; 93 - } 98 + $params = get_params($rawParams, null, [ 99 + 'accuracy:float', 100 + 'max_combo:int', 101 + 'maximum_statistics:array', 102 + 'mods:array', 103 + 'passed:bool', 104 + 'rank:string', 105 + 'statistics:array', 106 + 'total_score:int', 107 + ]); 94 108 95 - /** 96 - * Queue the item for score processing 97 - * 98 - * @param array $scoreJson JSON of the score generated using ScoreTransformer of type Solo 99 - */ 100 - public static function queueForProcessing(array $scoreJson): void 101 - { 102 - LaravelRedis::lpush($GLOBALS['cfg']['osu']['scores']['processing_queue'], json_encode([ 103 - 'Score' => [ 104 - 'beatmap_id' => $scoreJson['beatmap_id'], 105 - 'id' => $scoreJson['id'], 106 - 'ruleset_id' => $scoreJson['ruleset_id'], 107 - 'user_id' => $scoreJson['user_id'], 108 - // TODO: processor is currently order dependent and requires 109 - // this to be located at the end 110 - 'data' => json_encode($scoreJson), 111 - ], 112 - ])); 109 + $params['maximum_statistics'] ??= []; 110 + $params['statistics'] ??= []; 111 + 112 + $params['mods'] = app('mods')->parseInputArray($scoreToken->ruleset_id, $params['mods'] ?? []); 113 + 114 + $params['beatmap_id'] = $scoreToken->beatmap_id; 115 + $params['build_id'] = $scoreToken->build_id; 116 + $params['ended_at'] = new \DateTime(); 117 + $params['ruleset_id'] = $scoreToken->ruleset_id; 118 + $params['started_at'] = $scoreToken->created_at; 119 + $params['user_id'] = $scoreToken->user_id; 120 + 121 + $beatmap = $scoreToken->beatmap; 122 + $params['ranked'] = $beatmap !== null && in_array($beatmap->approved, [ 123 + Beatmapset::STATES['approved'], 124 + Beatmapset::STATES['ranked'], 125 + ], true); 126 + 127 + return $params; 113 128 } 114 129 115 130 public function beatmap() 116 131 { 117 132 return $this->belongsTo(Beatmap::class, 'beatmap_id'); 118 - } 119 - 120 - public function performance() 121 - { 122 - return $this->hasOne(ScorePerformance::class, 'score_id'); 123 133 } 124 134 125 135 public function user() ··· 132 142 return $query->whereHas('beatmap.beatmapset'); 133 143 } 134 144 145 + public function scopeForRuleset(Builder $query, string $ruleset): Builder 146 + { 147 + return $query->where('ruleset_id', Beatmap::MODES[$ruleset]); 148 + } 149 + 150 + public function scopeIncludeFails(Builder $query, bool $includeFails): Builder 151 + { 152 + return $includeFails 153 + ? $query 154 + : $query->where('passed', true); 155 + } 156 + 135 157 /** 136 158 * This should match the one used in osu-elastic-indexer. 137 159 */ ··· 142 164 ->whereHas('user', fn (Builder $q): Builder => $q->default()); 143 165 } 144 166 167 + public function scopeRecent(Builder $query, string $ruleset, bool $includeFails): Builder 168 + { 169 + return $query 170 + ->default() 171 + ->forRuleset($ruleset) 172 + ->includeFails($includeFails) 173 + // 2 days (2 * 24 * 3600) 174 + ->where('unix_updated_at', '>', time() - 172_800); 175 + } 176 + 145 177 public function getAttribute($key) 146 178 { 147 179 return match ($key) { 180 + 'accuracy', 148 181 'beatmap_id', 182 + 'build_id', 149 183 'id', 184 + 'legacy_score_id', 185 + 'legacy_total_score', 186 + 'max_combo', 187 + 'pp', 150 188 'ruleset_id', 189 + 'total_score', 151 190 'unix_updated_at', 152 191 'user_id' => $this->getRawAttribute($key), 153 192 193 + 'rank' => $this->getRawAttribute($key) ?? 'F', 194 + 154 195 'data' => $this->getClassCastableAttributeValue($key, $this->getRawAttribute($key)), 155 196 156 197 'has_replay', 157 - 'preserve', 158 - 'ranked' => (bool) $this->getRawAttribute($key), 198 + 'passed', 199 + 'preserve' => (bool) $this->getRawAttribute($key), 159 200 160 - 'created_at' => $this->getTimeFast($key), 161 - 'created_at_json' => $this->getJsonTimeFast($key), 201 + 'ranked' => (bool) ($this->getRawAttribute($key) ?? true), 162 202 163 - 'pp' => $this->performance?->pp, 203 + 'ended_at', 204 + 'started_at' => $this->getTimeFast($key), 205 + 206 + 'ended_at_json', 207 + 'started_at_json' => $this->getJsonTimeFast($key), 208 + 209 + 'best_id' => null, 210 + 'legacy_perfect' => null, 164 211 165 212 'beatmap', 166 213 'performance', ··· 169 216 }; 170 217 } 171 218 172 - public function createLegacyEntryOrExplode() 219 + public function assertCompleted(): void 173 220 { 174 - $score = $this->makeLegacyEntry(); 221 + if (ScoreRank::tryFrom($this->rank ?? '') === null) { 222 + throw new InvariantException("'{$this->rank}' is not a valid rank."); 223 + } 175 224 176 - $score->saveOrExplode(); 225 + foreach (['total_score', 'accuracy', 'max_combo', 'passed'] as $field) { 226 + if (!present($this->$field)) { 227 + throw new InvariantException("field missing: '{$field}'"); 228 + } 229 + } 177 230 178 - return $score; 231 + if ($this->data->statistics->isEmpty()) { 232 + throw new InvariantException("field cannot be empty: 'statistics'"); 233 + } 179 234 } 180 235 181 236 public function getMode(): string ··· 191 246 192 247 public function isLegacy(): bool 193 248 { 194 - return $this->data->buildId === null; 249 + return $this->legacy_score_id !== null; 195 250 } 196 251 197 252 public function legacyScore(): ?LegacyScore\Best\Model 198 253 { 199 - $id = $this->data->legacyScoreId; 254 + $id = $this->legacy_score_id; 200 255 201 256 return $id === null 202 257 ? null ··· 214 269 'beatmapset_id' => $this->beatmap?->beatmapset_id ?? 0, 215 270 'countmiss' => $statistics->miss, 216 271 'enabled_mods' => app('mods')->idsToBitset(array_column($data->mods, 'acronym')), 217 - 'maxcombo' => $data->maxCombo, 218 - 'pass' => $data->passed, 219 - 'perfect' => $data->passed && $statistics->miss + $statistics->large_tick_miss === 0, 220 - 'rank' => $data->rank, 221 - 'score' => $data->totalScore, 272 + 'maxcombo' => $this->max_combo, 273 + 'pass' => $this->passed, 274 + 'perfect' => $this->passed && $statistics->miss + $statistics->large_tick_miss === 0, 275 + 'rank' => $this->rank, 276 + 'score' => $this->total_score, 222 277 'scorechecksum' => "\0", 223 278 'user_id' => $this->user_id, 224 279 ]); ··· 251 306 return $score; 252 307 } 253 308 309 + public function queueForProcessing(): void 310 + { 311 + LaravelRedis::lpush($GLOBALS['cfg']['osu']['scores']['processing_queue'], json_encode([ 312 + 'Score' => $this->getAttributes(), 313 + ])); 314 + } 315 + 254 316 public function trashed(): bool 255 317 { 256 318 return false; ··· 263 325 264 326 public function userRank(?array $params = null): int 265 327 { 266 - return UserRank::getRank(ScoreSearchParams::fromArray(array_merge($params ?? [], [ 328 + // Non-legacy score always has its rank checked against all score types. 329 + if (!$this->isLegacy()) { 330 + $params['is_legacy'] = null; 331 + } 332 + 333 + return UserRank::getRank(ScoreSearchParams::fromArray([ 334 + ...($params ?? []), 267 335 'beatmap_ids' => [$this->beatmap_id], 268 336 'before_score' => $this, 269 - 'is_legacy' => $this->isLegacy(), 270 337 'ruleset_id' => $this->ruleset_id, 271 338 'user' => $this->user, 272 - ]))); 339 + ])); 273 340 } 274 341 275 342 protected function newReportableExtraParams(): array
+3 -81
app/Models/Solo/ScoreData.php
··· 7 7 8 8 namespace App\Models\Solo; 9 9 10 - use App\Enums\ScoreRank; 11 - use App\Exceptions\InvariantException; 12 10 use Illuminate\Contracts\Database\Eloquent\Castable; 13 11 use Illuminate\Contracts\Database\Eloquent\CastsAttributes; 14 12 use JsonSerializable; 15 13 16 14 class ScoreData implements Castable, JsonSerializable 17 15 { 18 - public float $accuracy; 19 - public int $beatmapId; 20 - public ?int $buildId; 21 - public string $endedAt; 22 - public ?int $legacyScoreId; 23 - public ?int $legacyTotalScore; 24 - public int $maxCombo; 25 16 public ScoreDataStatistics $maximumStatistics; 26 17 public array $mods; 27 - public bool $passed; 28 - public string $rank; 29 - public int $rulesetId; 30 - public ?string $startedAt; 31 18 public ScoreDataStatistics $statistics; 32 - public int $totalScore; 33 - public int $userId; 34 19 35 20 public function __construct(array $data) 36 21 { ··· 51 36 } 52 37 } 53 38 54 - $this->accuracy = $data['accuracy'] ?? 0; 55 - $this->beatmapId = $data['beatmap_id']; 56 - $this->buildId = $data['build_id'] ?? null; 57 - $this->endedAt = $data['ended_at']; 58 - $this->legacyScoreId = $data['legacy_score_id'] ?? null; 59 - $this->legacyTotalScore = $data['legacy_total_score'] ?? null; 60 - $this->maxCombo = $data['max_combo'] ?? 0; 61 39 $this->maximumStatistics = new ScoreDataStatistics($data['maximum_statistics'] ?? []); 62 40 $this->mods = $mods; 63 - $this->passed = $data['passed'] ?? false; 64 - $this->rank = $data['rank'] ?? 'F'; 65 - $this->rulesetId = $data['ruleset_id']; 66 - $this->startedAt = $data['started_at'] ?? null; 67 41 $this->statistics = new ScoreDataStatistics($data['statistics'] ?? []); 68 - $this->totalScore = $data['total_score'] ?? 0; 69 - $this->userId = $data['user_id']; 70 42 } 71 43 72 44 public static function castUsing(array $arguments) ··· 75 47 { 76 48 public function get($model, $key, $value, $attributes) 77 49 { 78 - $dataJson = json_decode($value, true); 79 - $dataJson['beatmap_id'] ??= $attributes['beatmap_id']; 80 - $dataJson['ended_at'] ??= $model->created_at_json; 81 - $dataJson['ruleset_id'] ??= $attributes['ruleset_id']; 82 - $dataJson['user_id'] ??= $attributes['user_id']; 83 - 84 - return new ScoreData($dataJson); 50 + return new ScoreData(json_decode($value, true)); 85 51 } 86 52 87 53 public function set($model, $key, $value, $attributes) 88 54 { 89 55 if (!($value instanceof ScoreData)) { 90 - $value = new ScoreData([ 91 - 'beatmap_id' => $attributes['beatmap_id'] ?? null, 92 - 'ended_at' => $attributes['created_at'] ?? null, 93 - 'ruleset_id' => $attributes['ruleset_id'] ?? null, 94 - 'user_id' => $attributes['user_id'] ?? null, 95 - ...$value, 96 - ]); 56 + $value = new ScoreData($value); 97 57 } 98 58 99 59 return ['data' => json_encode($value)]; ··· 101 61 }; 102 62 } 103 63 104 - public function assertCompleted(): void 105 - { 106 - if (ScoreRank::tryFrom($this->rank) === null) { 107 - throw new InvariantException("'{$this->rank}' is not a valid rank."); 108 - } 109 - 110 - foreach (['totalScore', 'accuracy', 'maxCombo', 'passed'] as $field) { 111 - if (!present($this->$field)) { 112 - throw new InvariantException("field missing: '{$field}'"); 113 - } 114 - } 115 - 116 - if ($this->statistics->isEmpty()) { 117 - throw new InvariantException("field cannot be empty: 'statistics'"); 118 - } 119 - } 120 - 121 64 public function jsonSerialize(): array 122 65 { 123 - $ret = [ 124 - 'accuracy' => $this->accuracy, 125 - 'beatmap_id' => $this->beatmapId, 126 - 'build_id' => $this->buildId, 127 - 'ended_at' => $this->endedAt, 128 - 'legacy_score_id' => $this->legacyScoreId, 129 - 'legacy_total_score' => $this->legacyTotalScore, 130 - 'max_combo' => $this->maxCombo, 66 + return [ 131 67 'maximum_statistics' => $this->maximumStatistics, 132 68 'mods' => $this->mods, 133 - 'passed' => $this->passed, 134 - 'rank' => $this->rank, 135 - 'ruleset_id' => $this->rulesetId, 136 - 'started_at' => $this->startedAt, 137 69 'statistics' => $this->statistics, 138 - 'total_score' => $this->totalScore, 139 - 'user_id' => $this->userId, 140 70 ]; 141 - 142 - foreach ($ret as $field => $value) { 143 - if ($value === null) { 144 - unset($ret[$field]); 145 - } 146 - } 147 - 148 - return $ret; 149 71 } 150 72 }
-21
app/Models/Solo/ScorePerformance.php
··· 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 - 6 - namespace App\Models\Solo; 7 - 8 - use App\Models\Model; 9 - 10 - /** 11 - * @property int $score_id 12 - * @property float|null $pp 13 - */ 14 - class ScorePerformance extends Model 15 - { 16 - public $incrementing = false; 17 - public $timestamps = false; 18 - 19 - protected $primaryKey = 'score_id'; 20 - protected $table = 'score_performance'; 21 - }
+7
app/Models/Traits/FasterAttributes.php
··· 16 16 return $this->attributes[$key] ?? null; 17 17 } 18 18 19 + protected function getNullableBool(string $key) 20 + { 21 + $raw = $this->getRawAttribute($key); 22 + 23 + return $raw === null ? null : (bool) $raw; 24 + } 25 + 19 26 /** 20 27 * Fast Time Attribute to Json Transformer 21 28 *
+36 -57
app/Models/Traits/UserScoreable.php
··· 5 5 6 6 namespace App\Models\Traits; 7 7 8 - use App\Libraries\Elasticsearch\BoolQuery; 9 - use App\Libraries\Elasticsearch\SearchResponse; 10 - use App\Libraries\Search\BasicSearch; 11 - use App\Models\Score\Best; 8 + use App\Libraries\Score\FetchDedupedScores; 9 + use App\Libraries\Search\ScoreSearchParams; 10 + use App\Models\Beatmap; 11 + use App\Models\Solo\Score; 12 + use Illuminate\Database\Eloquent\Collection; 12 13 13 14 trait UserScoreable 14 15 { 15 - private $beatmapBestScoreIds = []; 16 + private array $beatmapBestScoreIds = []; 17 + private array $beatmapBestScores = []; 16 18 17 - public function aggregatedScoresBest(string $mode, int $size): SearchResponse 19 + public function aggregatedScoresBest(string $mode, null | true $legacyOnly, int $size): array 18 20 { 19 - $index = $GLOBALS['cfg']['osu']['elasticsearch']['prefix']."high_scores_{$mode}"; 20 - 21 - $search = new BasicSearch($index, "aggregatedScoresBest_{$mode}"); 22 - $search->connectionName = 'scores'; 23 - $search 24 - ->size(0) // don't care about hits 25 - ->query( 26 - (new BoolQuery()) 27 - ->filter(['term' => ['user_id' => $this->getKey()]]) 28 - ) 29 - ->setAggregations([ 30 - 'by_beatmaps' => [ 31 - 'terms' => [ 32 - 'field' => 'beatmap_id', 33 - // sort by sub-aggregation max_pp, with score_id as tie breaker 34 - 'order' => [['max_pp' => 'desc'], ['min_score_id' => 'asc']], 35 - 'size' => $size, 36 - ], 37 - 'aggs' => [ 38 - 'top_scores' => [ 39 - 'top_hits' => [ 40 - 'size' => 1, 41 - 'sort' => [['pp' => ['order' => 'desc']]], 42 - ], 43 - ], 44 - // top_hits aggregation is not useable for sorting, so we need an extra aggregation to sort on. 45 - 'max_pp' => ['max' => ['field' => 'pp']], 46 - 'min_score_id' => ['min' => ['field' => 'score_id']], 47 - ], 48 - ], 49 - ]); 50 - 51 - $response = $search->response(); 52 - $search->assertNoError(); 53 - 54 - return $response; 21 + return (new FetchDedupedScores('beatmap_id', ScoreSearchParams::fromArray([ 22 + 'is_legacy' => $legacyOnly, 23 + 'limit' => $size, 24 + 'ruleset_id' => Beatmap::MODES[$mode], 25 + 'sort' => 'pp_desc', 26 + 'user_id' => $this->getKey(), 27 + ]), "aggregatedScoresBest_{$mode}"))->all(); 55 28 } 56 29 57 - public function beatmapBestScoreIds(string $mode) 30 + public function beatmapBestScoreIds(string $mode, null | true $legacyOnly) 58 31 { 59 - if (!isset($this->beatmapBestScoreIds[$mode])) { 32 + $key = $mode.'-'.($legacyOnly ? '1' : '0'); 33 + 34 + if (!isset($this->beatmapBestScoreIds[$key])) { 60 35 // aggregations do not support regular pagination. 61 36 // always fetching 100 to cache; we're not supporting beyond 100, either. 62 - $this->beatmapBestScoreIds[$mode] = cache_remember_mutexed( 63 - "search-cache:beatmapBestScores:{$this->getKey()}:{$mode}", 37 + $this->beatmapBestScoreIds[$key] = cache_remember_mutexed( 38 + "search-cache:beatmapBestScoresSolo:{$this->getKey()}:{$key}", 64 39 $GLOBALS['cfg']['osu']['scores']['es_cache_duration'], 65 40 [], 66 - function () use ($mode) { 67 - // FIXME: should return some sort of error on error 68 - $buckets = $this->aggregatedScoresBest($mode, 100)->aggregations('by_beatmaps')['buckets'] ?? []; 41 + function () use ($key, $legacyOnly, $mode) { 42 + $this->beatmapBestScores[$key] = $this->aggregatedScoresBest($mode, $legacyOnly, 100); 69 43 70 - return array_map(function ($bucket) { 71 - return array_get($bucket, 'top_scores.hits.hits.0._id'); 72 - }, $buckets); 44 + return array_column($this->beatmapBestScores[$key], 'id'); 73 45 }, 74 46 function () { 75 47 // TODO: propagate a more useful message back to the client ··· 79 51 ); 80 52 } 81 53 82 - return $this->beatmapBestScoreIds[$mode]; 54 + return $this->beatmapBestScoreIds[$key]; 83 55 } 84 56 85 - public function beatmapBestScores(string $mode, int $limit, int $offset = 0, $with = []) 57 + public function beatmapBestScores(string $mode, int $limit, int $offset, array $with, null | true $legacyOnly): Collection 86 58 { 87 - $ids = array_slice($this->beatmapBestScoreIds($mode), $offset, $limit); 88 - $clazz = Best\Model::getClass($mode); 59 + $ids = $this->beatmapBestScoreIds($mode, $legacyOnly); 60 + $key = $mode.'-'.($legacyOnly ? '1' : '0'); 61 + 62 + if (isset($this->beatmapBestScores[$key])) { 63 + $results = new Collection(array_slice($this->beatmapBestScores[$key], $offset, $limit)); 64 + } else { 65 + $ids = array_slice($ids, $offset, $limit); 66 + $results = Score::whereKey($ids)->orderByField('id', $ids)->default()->get(); 67 + } 89 68 90 - $results = $clazz::whereIn('score_id', $ids)->orderByField('score_id', $ids)->with($with)->get(); 69 + $results->load($with); 91 70 92 71 // fill in positions for weighting 93 72 // also preload the user relation
+6
app/Models/User.php
··· 916 916 'scoresMania', 917 917 'scoresOsu', 918 918 'scoresTaiko', 919 + 'soloScores', 919 920 'statisticsFruits', 920 921 'statisticsMania', 921 922 'statisticsMania4k', ··· 1445 1446 $relation = 'scoresBest'.studly_case($mode); 1446 1447 1447 1448 return $returnQuery ? $this->$relation() : $this->$relation; 1449 + } 1450 + 1451 + public function soloScores(): HasMany 1452 + { 1453 + return $this->hasMany(Solo\Score::class); 1448 1454 } 1449 1455 1450 1456 public function topicWatches()
+29
app/Providers/PassportServiceProvider.php
··· 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 + 6 + declare(strict_types=1); 7 + 8 + namespace App\Providers; 9 + 10 + use App\Libraries\OAuth\RefreshTokenGrant; 11 + use Laravel\Passport\Bridge\RefreshTokenRepository; 12 + use Laravel\Passport\Passport; 13 + use Laravel\Passport\PassportServiceProvider as BasePassportServiceProvider; 14 + 15 + class PassportServiceProvider extends BasePassportServiceProvider 16 + { 17 + /** 18 + * Overrides RefreshTokenGrant to copy verified attribute of the token 19 + */ 20 + protected function makeRefreshTokenGrant() 21 + { 22 + $repository = $this->app->make(RefreshTokenRepository::class); 23 + 24 + $grant = new RefreshTokenGrant($repository); 25 + $grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn()); 26 + 27 + return $grant; 28 + } 29 + }
+41 -30
app/Transformers/ScoreTransformer.php
··· 7 7 8 8 namespace App\Transformers; 9 9 10 + use App\Libraries\Search\ScoreSearchParams; 10 11 use App\Models\Beatmap; 11 12 use App\Models\DeletedUser; 12 13 use App\Models\LegacyMatch; ··· 22 23 const MULTIPLAYER_BASE_INCLUDES = ['user.country', 'user.cover']; 23 24 // warning: the preload is actually for PlaylistItemUserHighScore, not for Score 24 25 const MULTIPLAYER_BASE_PRELOAD = [ 25 - 'scoreLink.score.performance', 26 + 'scoreLink.score', 26 27 'scoreLink.user.country', 27 28 'scoreLink.user.userProfileCustomization', 28 29 ]; ··· 90 91 91 92 public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) 92 93 { 93 - if ($score instanceof ScoreModel) { 94 - $legacyPerfect = $score->perfect; 95 - $best = $score->best; 94 + $extraAttributes = []; 96 95 97 - if ($best !== null) { 98 - $bestId = $best->getKey(); 99 - $pp = $best->pp; 100 - $hasReplay = $best->replay; 101 - } 102 - } else { 103 - if ($score instanceof MultiplayerScoreLink) { 104 - $multiplayerAttributes = [ 105 - 'playlist_item_id' => $score->playlist_item_id, 106 - 'room_id' => $score->playlistItem->room_id, 107 - 'solo_score_id' => $score->score_id, 108 - ]; 109 - $score = $score->score; 110 - } 96 + if ($score instanceof MultiplayerScoreLink) { 97 + $extraAttributes['playlist_item_id'] = $score->playlist_item_id; 98 + $extraAttributes['room_id'] = $score->playlistItem->room_id; 99 + $extraAttributes['solo_score_id'] = $score->score_id; 100 + $score = $score->score; 101 + } 111 102 112 - $pp = $score->pp; 113 - $hasReplay = $score->has_replay; 103 + if ($score instanceof SoloScore) { 104 + $extraAttributes['ranked'] = $score->ranked; 114 105 } 115 106 116 - $hasReplay ??= false; 107 + $hasReplay = $score->has_replay; 117 108 118 109 return [ 110 + ...$extraAttributes, 119 111 ...$score->data->jsonSerialize(), 120 - ...($multiplayerAttributes ?? []), 121 - 'best_id' => $bestId ?? null, 112 + 'beatmap_id' => $score->beatmap_id, 113 + 'best_id' => $score->best_id, 114 + 'id' => $score->getKey(), 115 + 'rank' => $score->rank, 116 + 'type' => $score->getMorphClass(), 117 + 'user_id' => $score->user_id, 118 + 'accuracy' => $score->accuracy, 119 + 'build_id' => $score->build_id, 120 + 'ended_at' => $score->ended_at_json, 122 121 'has_replay' => $hasReplay, 123 - 'id' => $score->getKey(), 124 - 'legacy_perfect' => $legacyPerfect ?? null, 125 - 'pp' => $pp ?? null, 122 + 'legacy_perfect' => $score->legacy_perfect, 123 + 'legacy_score_id' => $score->legacy_score_id, 124 + 'legacy_total_score' => $score->legacy_total_score, 125 + 'max_combo' => $score->max_combo, 126 + 'passed' => $score->passed, 127 + 'pp' => $score->pp, 128 + 'ruleset_id' => $score->ruleset_id, 129 + 'started_at' => $score->started_at_json, 130 + 'total_score' => $score->total_score, 126 131 // TODO: remove this redundant field sometime after 2024-02 127 132 'replay' => $hasReplay, 128 - 'type' => $score->getMorphClass(), 129 133 ]; 134 + 135 + return $ret; 130 136 } 131 137 132 138 public function transformLegacy(LegacyMatch\Score|ScoreModel|SoloScore $score) ··· 146 152 $soloScore = $score; 147 153 $score = $soloScore->makeLegacyEntry(); 148 154 $score->score_id = $soloScore->getKey(); 149 - $createdAt = $soloScore->created_at_json; 155 + $createdAt = $soloScore->ended_at_json; 150 156 $type = $soloScore->getMorphClass(); 151 157 $pp = $soloScore->pp; 152 158 } else { ··· 248 254 249 255 public function includeRankCountry(ScoreBest|SoloScore $score) 250 256 { 251 - return $this->primitive($score->userRank(['type' => 'country'])); 257 + return $this->primitive($score->userRank([ 258 + 'type' => 'country', 259 + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), 260 + ])); 252 261 } 253 262 254 263 public function includeRankGlobal(ScoreBest|SoloScore $score) 255 264 { 256 - return $this->primitive($score->userRank([])); 265 + return $this->primitive($score->userRank([ 266 + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), 267 + ])); 257 268 } 258 269 259 270 public function includeUser(LegacyMatch\Score|MultiplayerScoreLink|ScoreModel|SoloScore $score)
+12 -2
app/Transformers/UserCompactTransformer.php
··· 6 6 namespace App\Transformers; 7 7 8 8 use App\Libraries\MorphMap; 9 + use App\Libraries\Search\ScoreSearchParams; 9 10 use App\Models\Beatmap; 10 11 use App\Models\User; 11 12 use App\Models\UserProfileCustomization; ··· 86 87 'scores_first_count', 87 88 'scores_pinned_count', 88 89 'scores_recent_count', 90 + 'session_verified', 89 91 'statistics', 90 92 'statistics_rulesets', 91 93 'support_level', ··· 387 389 388 390 public function includeScoresBestCount(User $user) 389 391 { 390 - return $this->primitive(count($user->beatmapBestScoreIds($this->mode))); 392 + return $this->primitive(count($user->beatmapBestScoreIds( 393 + $this->mode, 394 + ScoreSearchParams::showLegacyForUser(\Auth::user()), 395 + ))); 391 396 } 392 397 393 398 public function includeScoresFirstCount(User $user) ··· 402 407 403 408 public function includeScoresRecentCount(User $user) 404 409 { 405 - return $this->primitive($user->scores($this->mode, true)->includeFails(false)->count()); 410 + return $this->primitive($user->soloScores()->recent($this->mode, false)->count()); 411 + } 412 + 413 + public function includeSessionVerified(User $user) 414 + { 415 + return $this->primitive($user->token()?->isVerified() ?? false); 406 416 } 407 417 408 418 public function includeStatistics(User $user)
+1 -1
app/helpers.php
··· 539 539 540 540 function oauth_token(): ?App\Models\OAuth\Token 541 541 { 542 - return request()->attributes->get(App\Http\Middleware\AuthApi::REQUEST_OAUTH_TOKEN_KEY); 542 + return Request::instance()->attributes->get(App\Http\Middleware\AuthApi::REQUEST_OAUTH_TOKEN_KEY); 543 543 } 544 544 545 545 function osu_trans($key = null, $replace = [], $locale = null)
+1
config/app.php
··· 186 186 App\Providers\EventServiceProvider::class, 187 187 // Override default migrate:fresh 188 188 App\Providers\MigrationServiceProvider::class, 189 + App\Providers\PassportServiceProvider::class, 189 190 App\Providers\RouteServiceProvider::class, 190 191 // Override the session id naming (for redis key namespacing) 191 192 App\Providers\SessionServiceProvider::class,
+2 -2
config/octane.php
··· 188 188 'composer.lock*', 189 189 'config', 190 190 'database', 191 - 'public/**/*.php', 192 191 'public/assets/manifest.json*', 193 - 'resources/**/*.php', 192 + 'resources/lang', 193 + 'resources/views', 194 194 'routes', 195 195 ], 196 196
+17 -1
config/osu.php
··· 5 5 $profileScoresNotice = markdown_plain($profileScoresNotice); 6 6 } 7 7 8 + $clientTokenKeys = []; 9 + foreach (explode(',', env('CLIENT_TOKEN_KEYS') ?? '') as $entry) { 10 + if ($entry !== '') { 11 + [$platform, $encodedKey] = explode('=', $entry, 2); 12 + $clientTokenKeys[$platform] = hex2bin($encodedKey); 13 + } 14 + } 15 + 8 16 // osu config~ 9 17 return [ 10 18 'achievement' => [ ··· 93 101 'client' => [ 94 102 'check_version' => get_bool(env('CLIENT_CHECK_VERSION')) ?? true, 95 103 'default_build_id' => get_int(env('DEFAULT_BUILD_ID')) ?? 0, 104 + 'token_keys' => $clientTokenKeys, 105 + 'token_queue' => env('CLIENT_TOKEN_QUEUE') ?? 'token-queue', 96 106 'user_agent' => env('CLIENT_USER_AGENT', 'osu!'), 97 107 ], 98 108 'elasticsearch' => [ ··· 173 183 'experimental_rank_as_default' => get_bool(env('SCORES_EXPERIMENTAL_RANK_AS_DEFAULT')) ?? false, 174 184 'experimental_rank_as_extra' => get_bool(env('SCORES_EXPERIMENTAL_RANK_AS_EXTRA')) ?? false, 175 185 'processing_queue' => presence(env('SCORES_PROCESSING_QUEUE')) ?? 'osu-queue:score-statistics', 186 + 'submission_enabled' => get_bool(env('SCORES_SUBMISSION_ENABLED')) ?? true, 187 + 176 188 'rank_cache' => [ 177 189 'local_server' => get_bool(env('SCORES_RANK_CACHE_LOCAL_SERVER')) ?? false, 178 190 'min_users' => get_int(env('SCORES_RANK_CACHE_MIN_USERS')) ?? 35000, ··· 258 270 'key_length' => 8, 259 271 'tries' => 8, 260 272 ], 261 - 'registration_mode' => presence(env('REGISTRATION_MODE')) ?? 'client', 262 273 'super_friendly' => array_map('intval', explode(' ', env('SUPER_FRIENDLY', '3'))), 263 274 'ban_persist_days' => get_int(env('BAN_PERSIST_DAYS')) ?? 28, 264 275 265 276 'country_change' => [ 266 277 'max_mixed_months' => get_int(env('USER_COUNTRY_CHANGE_MAX_MIXED_MONTHS')) ?? 2, 267 278 'min_months' => get_int(env('USER_COUNTRY_CHANGE_MIN_MONTHS')) ?? 6, 279 + ], 280 + 281 + 'registration_mode' => [ 282 + 'client' => get_bool(env('REGISTRATION_MODE_CLIENT')) ?? true, 283 + 'web' => get_bool(env('REGISTRATION_MODE_WEB')) ?? false, 268 284 ], 269 285 ], 270 286 'user_report_notification' => [
+1 -1
database/factories/BuildFactory.php
··· 16 16 { 17 17 return [ 18 18 'date' => fn () => $this->faker->dateTimeBetween('-5 years'), 19 - 'hash' => fn () => md5($this->faker->word(), true), 19 + 'hash' => fn () => md5(rand(), true), 20 20 'stream_id' => fn () => array_rand_val($GLOBALS['cfg']['osu']['changelog']['update_streams']), 21 21 'users' => rand(100, 10000), 22 22
+2 -1
database/factories/OAuth/TokenFactory.php
··· 23 23 'expires_at' => fn () => now()->addDays(), 24 24 'id' => str_random(40), 25 25 'revoked' => false, 26 - 'scopes' => ['public'], 26 + 'scopes' => ['identify', 'public'], 27 27 'user_id' => User::factory(), 28 + 'verified' => true, 28 29 ]; 29 30 } 30 31 }
+12 -12
database/factories/Solo/ScoreFactory.php
··· 7 7 8 8 namespace Database\Factories\Solo; 9 9 10 + use App\Enums\ScoreRank; 10 11 use App\Models\Beatmap; 11 12 use App\Models\Solo\Score; 12 13 use App\Models\User; ··· 19 20 public function definition(): array 20 21 { 21 22 return [ 23 + 'accuracy' => fn (): float => $this->faker->randomFloat(1, 0, 1), 22 24 'beatmap_id' => Beatmap::factory()->ranked(), 25 + 'ended_at' => new \DateTime(), 26 + 'pp' => fn (): float => $this->faker->randomFloat(4, 0, 1000), 27 + 'rank' => fn () => array_rand_val(ScoreRank::cases())->value, 28 + 'total_score' => fn (): int => $this->faker->randomNumber(7), 23 29 'user_id' => User::factory(), 24 30 25 31 // depends on beatmap_id ··· 27 33 28 34 // depends on all other attributes 29 35 'data' => fn (array $attr): array => $this->makeData()($attr), 36 + 37 + 'legacy_total_score' => fn (array $attr): int => isset($attr['legacy_score_id']) ? $attr['total_score'] : 0, 30 38 ]; 31 39 } 32 40 ··· 41 49 { 42 50 return fn (array $attr): array => array_map( 43 51 fn ($value) => is_callable($value) ? $value($attr) : $value, 44 - array_merge([ 45 - 'accuracy' => fn (): float => $this->faker->randomFloat(1, 0, 1), 46 - 'beatmap_id' => $attr['beatmap_id'], 47 - 'ended_at' => fn (): string => json_time(now()), 48 - 'max_combo' => fn (): int => rand(1, Beatmap::find($attr['beatmap_id'])->countNormal), 52 + [ 53 + 'statistics' => ['great' => 1], 49 54 'mods' => [], 50 - 'passed' => true, 51 - 'rank' => fn (): string => array_rand_val(['A', 'S', 'B', 'SH', 'XH', 'X']), 52 - 'ruleset_id' => $attr['ruleset_id'], 53 - 'started_at' => fn (): string => json_time(now()->subSeconds(600)), 54 - 'total_score' => fn (): int => $this->faker->randomNumber(7), 55 - 'user_id' => $attr['user_id'], 56 - ], $overrides ?? []), 55 + ...($overrides ?? []), 56 + ], 57 57 ); 58 58 } 59 59 }
+27
database/migrations/2023_12_18_104437_add_verified_column_to_oauth_access_tokens.php
··· 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 + 6 + declare(strict_types=1); 7 + 8 + use Illuminate\Database\Migrations\Migration; 9 + use Illuminate\Database\Schema\Blueprint; 10 + use Illuminate\Support\Facades\Schema; 11 + 12 + return new class extends Migration 13 + { 14 + public function up(): void 15 + { 16 + Schema::table('oauth_access_tokens', function (Blueprint $table) { 17 + $table->boolean('verified')->default(true); 18 + }); 19 + } 20 + 21 + public function down(): void 22 + { 23 + Schema::table('oauth_access_tokens', function (Blueprint $table) { 24 + $table->dropColumn('verified'); 25 + }); 26 + } 27 + };
+94
database/migrations/2024_01_12_115738_update_scores_table_final.php
··· 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 + 6 + declare(strict_types=1); 7 + 8 + use Illuminate\Database\Migrations\Migration; 9 + use Illuminate\Support\Facades\Schema; 10 + 11 + return new class extends Migration 12 + { 13 + private static function resetView(): void 14 + { 15 + DB::statement('DROP VIEW scores'); 16 + DB::statement('CREATE VIEW scores AS SELECT * FROM solo_scores'); 17 + } 18 + 19 + public function up(): void 20 + { 21 + Schema::drop('solo_scores'); 22 + DB::statement("CREATE TABLE `solo_scores` ( 23 + `id` bigint unsigned NOT NULL AUTO_INCREMENT, 24 + `user_id` int unsigned NOT NULL, 25 + `ruleset_id` smallint unsigned NOT NULL, 26 + `beatmap_id` mediumint unsigned NOT NULL, 27 + `has_replay` tinyint NOT NULL DEFAULT '0', 28 + `preserve` tinyint NOT NULL DEFAULT '0', 29 + `ranked` tinyint NOT NULL DEFAULT '1', 30 + `rank` char(2) NOT NULL DEFAULT '', 31 + `passed` tinyint NOT NULL DEFAULT '0', 32 + `accuracy` float NOT NULL DEFAULT '0', 33 + `max_combo` int unsigned NOT NULL DEFAULT '0', 34 + `total_score` int unsigned NOT NULL DEFAULT '0', 35 + `data` json NOT NULL, 36 + `pp` float DEFAULT NULL, 37 + `legacy_score_id` bigint unsigned DEFAULT NULL, 38 + `legacy_total_score` int unsigned NOT NULL DEFAULT '0', 39 + `started_at` timestamp NULL DEFAULT NULL, 40 + `ended_at` timestamp NOT NULL, 41 + `unix_updated_at` int unsigned NOT NULL DEFAULT (unix_timestamp()), 42 + `build_id` smallint unsigned DEFAULT NULL, 43 + PRIMARY KEY (`id`,`preserve`,`unix_updated_at`), 44 + KEY `user_ruleset_index` (`user_id`,`ruleset_id`), 45 + KEY `beatmap_user_index` (`beatmap_id`,`user_id`), 46 + KEY `legacy_score_lookup` (`ruleset_id`,`legacy_score_id`) 47 + )"); 48 + 49 + DB::statement('DROP VIEW score_legacy_id_map'); 50 + Schema::drop('solo_scores_legacy_id_map'); 51 + 52 + DB::statement('DROP VIEW score_performance'); 53 + Schema::drop('solo_scores_performance'); 54 + 55 + static::resetView(); 56 + } 57 + 58 + public function down(): void 59 + { 60 + Schema::drop('solo_scores'); 61 + DB::statement("CREATE TABLE `solo_scores` ( 62 + `id` bigint unsigned NOT NULL AUTO_INCREMENT, 63 + `user_id` int unsigned NOT NULL, 64 + `beatmap_id` mediumint unsigned NOT NULL, 65 + `ruleset_id` smallint unsigned NOT NULL, 66 + `data` json NOT NULL, 67 + `has_replay` tinyint DEFAULT '0', 68 + `preserve` tinyint NOT NULL DEFAULT '0', 69 + `ranked` tinyint NOT NULL DEFAULT '1', 70 + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 71 + `unix_updated_at` int unsigned NOT NULL DEFAULT (unix_timestamp()), 72 + PRIMARY KEY (`id`,`preserve`,`unix_updated_at`), 73 + KEY `user_ruleset_index` (`user_id`,`ruleset_id`), 74 + KEY `beatmap_user_index` (`beatmap_id`,`user_id`) 75 + )"); 76 + 77 + DB::statement('CREATE TABLE `solo_scores_legacy_id_map` ( 78 + `ruleset_id` smallint unsigned NOT NULL, 79 + `old_score_id` bigint unsigned NOT NULL, 80 + `score_id` bigint unsigned NOT NULL, 81 + PRIMARY KEY (`ruleset_id`,`old_score_id`) 82 + )'); 83 + DB::statement('CREATE VIEW score_legacy_id_map AS SELECT * FROM solo_scores_legacy_id_map'); 84 + 85 + DB::statement('CREATE TABLE `solo_scores_performance` ( 86 + `score_id` bigint unsigned NOT NULL, 87 + `pp` float DEFAULT NULL, 88 + PRIMARY KEY (`score_id`) 89 + )'); 90 + DB::statement('CREATE VIEW score_performance AS SELECT * FROM solo_scores_performance'); 91 + 92 + static::resetView(); 93 + } 94 + };
+2 -2
docker-compose.yml
··· 154 154 - "${NGINX_PORT:-8080}:80" 155 155 156 156 score-indexer: 157 - image: pppy/osu-elastic-indexer:99cd549c5c5c959ff6b2728b76af603dda4c85cb 157 + image: pppy/osu-elastic-indexer:master 158 158 command: ["queue", "watch"] 159 159 depends_on: 160 160 redis: ··· 168 168 SCHEMA: "${SCHEMA:-1}" 169 169 170 170 score-indexer-test: 171 - image: pppy/osu-elastic-indexer:99cd549c5c5c959ff6b2728b76af603dda4c85cb 171 + image: pppy/osu-elastic-indexer:master 172 172 command: ["queue", "watch"] 173 173 depends_on: 174 174 redis:
+1
phpunit.xml
··· 21 21 <env name="CHAT_PRIVATE_LIMIT" value="2"/> 22 22 23 23 <env name="ADMIN_FORUM_ID" value="0"/> 24 + <env name="CLIENT_CHECK_VERSION" value="0"/> 24 25 <env name="DOUBLE_POST_ALLOWED_FORUM_IDS" value="0"/> 25 26 <env name="FEATURE_FORUM_ID" value="0"/> 26 27 <env name="HELP_FORUM_ID" value="0"/>
+257
public/images/layout/osu-lazer-logo-triangles.svg
··· 1 + <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 + <svg 3 + width="1000" 4 + height="1000" 5 + viewBox="0 0 1000 1000" 6 + fill="none" 7 + version="1.1" 8 + id="svg204" 9 + sodipodi:docname="osu-lazer-logo-full.svg" 10 + inkscape:version="1.2.2 (732a01da63, 2022-12-09)" 11 + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 12 + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 13 + xmlns="http://www.w3.org/2000/svg" 14 + xmlns:svg="http://www.w3.org/2000/svg"> 15 + <sodipodi:namedview 16 + id="namedview206" 17 + pagecolor="#ffffff" 18 + bordercolor="#000000" 19 + borderopacity="0.25" 20 + inkscape:showpageshadow="2" 21 + inkscape:pageopacity="0.0" 22 + inkscape:pagecheckerboard="0" 23 + inkscape:deskcolor="#d1d1d1" 24 + showgrid="false" 25 + inkscape:zoom="0.837" 26 + inkscape:cx="473.71565" 27 + inkscape:cy="499.40263" 28 + inkscape:window-width="1920" 29 + inkscape:window-height="1017" 30 + inkscape:window-x="-8" 31 + inkscape:window-y="-8" 32 + inkscape:window-maximized="1" 33 + inkscape:current-layer="svg204" /> 34 + <circle 35 + cx="500" 36 + cy="500" 37 + r="450" 38 + fill="#FF66AB" 39 + id="circle105" /> 40 + <circle 41 + cx="500" 42 + cy="500" 43 + r="450" 44 + fill="url(#paint0_linear_34_23)" 45 + fill-opacity="0.2" 46 + id="circle107" /> 47 + <mask 48 + id="mask0_34_23" 49 + style="mask-type:alpha" 50 + maskUnits="userSpaceOnUse" 51 + x="50" 52 + y="50" 53 + width="900" 54 + height="900"> 55 + <circle 56 + cx="500" 57 + cy="500" 58 + r="450" 59 + fill="#FF66AB" 60 + id="circle109" /> 61 + <circle 62 + cx="500" 63 + cy="500" 64 + r="450" 65 + fill="url(#paint1_linear_34_23)" 66 + fill-opacity="0.2" 67 + id="circle111" /> 68 + </mask> 69 + <g 70 + mask="url(#mask0_34_23)" 71 + id="g116"> 72 + <path 73 + fill-rule="evenodd" 74 + clip-rule="evenodd" 75 + d="M394.438 139.1L273.829 348.001L220.638 255.872L37.1974 573.6H121.472L-0.821655 785.418H332.083L289.75 858.74H656.631L564.127 698.518H744.632L724.25 733.822H1091.13L1047.23 657.784H1235.06L1051.62 340.056L957.704 502.72L907.69 416.094L872.586 476.896L723.028 217.853L562.888 495.222L540.721 456.828H577.878L394.438 139.1ZM749.828 698.518L732.044 729.322H1083.34L1042.03 657.784H868.178L955.106 507.22L907.69 425.094L875.184 481.396L906.468 535.581H843.9L803.707 605.197L857.587 698.518H749.828ZM801.109 609.697L849.792 694.018H752.426L801.109 609.697ZM798.511 605.197L747.23 694.018H561.529L530.014 639.434L530.395 638.775H645.768L588.082 538.859L589.974 535.581H758.319L798.511 605.197ZM801.109 600.697L838.704 535.581H763.515L801.109 600.697ZM760.917 531.081H841.302L869.988 481.396L723.028 226.853L565.486 499.722L583.591 531.081H587.376L674.146 380.791L760.917 531.081ZM592.572 531.081H755.721L674.146 389.791L592.572 531.081ZM582.886 538.859L580.993 535.581H539.587L560.29 499.722L535.525 456.828H389.131L362.895 502.269L404.078 573.6H321.713L286.682 634.275H419.346L473.191 541.012L527.036 634.275H527.797L582.886 538.859ZM532.993 634.275H637.974L585.484 543.359L532.993 634.275ZM524.818 639.434L524.437 638.775H421.944L351.669 760.494L366.059 785.418H337.279L297.544 854.24H648.837L558.931 698.518H490.706L524.818 639.434ZM556.333 694.018L527.416 643.934L498.5 694.018H556.333ZM578.395 531.081L562.888 504.222L547.382 531.081H578.395ZM846.498 531.081L872.586 485.896L898.674 531.081H846.498ZM220.638 264.872L271.23 352.501L210.997 456.828H331.464L357.699 502.269L319.114 569.1H241.167L182.619 467.69L124.07 569.1H44.9916L220.638 264.872ZM273.829 357.001L218.791 452.328H328.866L273.829 357.001ZM276.427 352.501L334.062 452.328H386.533L462.328 321.047L538.123 452.328H570.084L394.438 148.1L276.427 352.501ZM360.297 497.769L336.66 456.828H383.935L360.297 497.769ZM462.328 330.047L532.927 452.328H391.729L462.328 330.047ZM126.668 573.6L6.97257 780.918H334.681L346.473 760.494L238.569 573.6H126.668ZM235.971 569.1H129.266L182.619 476.69L235.971 569.1ZM243.765 573.6L280.141 636.604L316.516 573.6H243.765ZM360.297 506.769L324.311 569.1H396.284L360.297 506.769ZM349.071 755.994L281.394 638.775H416.747L349.071 755.994ZM349.071 764.994L339.877 780.918H358.265L349.071 764.994ZM424.542 634.275L473.191 550.012L521.839 634.275H424.542ZM957.704 511.72L875.972 653.284H1039.44L957.704 511.72ZM1044.63 653.284L960.302 507.22L1051.62 349.056L1227.26 653.284H1044.63Z" 76 + fill="url(#paint2_linear_34_23)" 77 + id="path114" /> 78 + </g> 79 + <defs 80 + id="defs202"> 81 + <filter 82 + id="filter0_d_34_23" 83 + x="170.75" 84 + y="354.714" 85 + width="658.571" 86 + height="283.661" 87 + filterUnits="userSpaceOnUse" 88 + color-interpolation-filters="sRGB"> 89 + <feFlood 90 + flood-opacity="0" 91 + result="BackgroundImageFix" 92 + id="feFlood136" /> 93 + <feColorMatrix 94 + in="SourceAlpha" 95 + type="matrix" 96 + values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" 97 + result="hardAlpha" 98 + id="feColorMatrix138" /> 99 + <feOffset 100 + dy="10.7249" 101 + id="feOffset140" /> 102 + <feGaussianBlur 103 + stdDeviation="10.7249" 104 + id="feGaussianBlur142" /> 105 + <feComposite 106 + in2="hardAlpha" 107 + operator="out" 108 + id="feComposite144" /> 109 + <feColorMatrix 110 + type="matrix" 111 + values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" 112 + id="feColorMatrix146" /> 113 + <feBlend 114 + mode="normal" 115 + in2="BackgroundImageFix" 116 + result="effect1_dropShadow_34_23" 117 + id="feBlend148" /> 118 + <feBlend 119 + mode="normal" 120 + in="SourceGraphic" 121 + in2="effect1_dropShadow_34_23" 122 + result="shape" 123 + id="feBlend150" /> 124 + </filter> 125 + <filter 126 + id="filter1_d_34_23" 127 + x="32" 128 + y="41" 129 + width="936" 130 + height="936" 131 + filterUnits="userSpaceOnUse" 132 + color-interpolation-filters="sRGB"> 133 + <feFlood 134 + flood-opacity="0" 135 + result="BackgroundImageFix" 136 + id="feFlood153" /> 137 + <feColorMatrix 138 + in="SourceAlpha" 139 + type="matrix" 140 + values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" 141 + result="hardAlpha" 142 + id="feColorMatrix155" /> 143 + <feOffset 144 + dy="9" 145 + id="feOffset157" /> 146 + <feGaussianBlur 147 + stdDeviation="9" 148 + id="feGaussianBlur159" /> 149 + <feComposite 150 + in2="hardAlpha" 151 + operator="out" 152 + id="feComposite161" /> 153 + <feColorMatrix 154 + type="matrix" 155 + values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" 156 + id="feColorMatrix163" /> 157 + <feBlend 158 + mode="normal" 159 + in2="BackgroundImageFix" 160 + result="effect1_dropShadow_34_23" 161 + id="feBlend165" /> 162 + <feBlend 163 + mode="normal" 164 + in="SourceGraphic" 165 + in2="effect1_dropShadow_34_23" 166 + result="shape" 167 + id="feBlend167" /> 168 + </filter> 169 + <filter 170 + id="filter2_d_34_23" 171 + x="236.45" 172 + y="475.775" 173 + width="98.3216" 174 + height="97.9616" 175 + filterUnits="userSpaceOnUse" 176 + color-interpolation-filters="sRGB"> 177 + <feFlood 178 + flood-opacity="0" 179 + result="BackgroundImageFix" 180 + id="feFlood170" /> 181 + <feColorMatrix 182 + in="SourceAlpha" 183 + type="matrix" 184 + values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" 185 + result="hardAlpha" 186 + id="feColorMatrix172" /> 187 + <feOffset 188 + dy="10.7249" 189 + id="feOffset174" /> 190 + <feGaussianBlur 191 + stdDeviation="10.7249" 192 + id="feGaussianBlur176" /> 193 + <feComposite 194 + in2="hardAlpha" 195 + operator="out" 196 + id="feComposite178" /> 197 + <feColorMatrix 198 + type="matrix" 199 + values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" 200 + id="feColorMatrix180" /> 201 + <feBlend 202 + mode="normal" 203 + in2="BackgroundImageFix" 204 + result="effect1_dropShadow_34_23" 205 + id="feBlend182" /> 206 + <feBlend 207 + mode="normal" 208 + in="SourceGraphic" 209 + in2="effect1_dropShadow_34_23" 210 + result="shape" 211 + id="feBlend184" /> 212 + </filter> 213 + <linearGradient 214 + id="paint0_linear_34_23" 215 + x1="500" 216 + y1="50" 217 + x2="500" 218 + y2="950" 219 + gradientUnits="userSpaceOnUse"> 220 + <stop 221 + stop-opacity="0" 222 + id="stop187" /> 223 + <stop 224 + offset="1" 225 + id="stop189" /> 226 + </linearGradient> 227 + <linearGradient 228 + id="paint1_linear_34_23" 229 + x1="500" 230 + y1="50" 231 + x2="500" 232 + y2="950" 233 + gradientUnits="userSpaceOnUse"> 234 + <stop 235 + stop-opacity="0" 236 + id="stop192" /> 237 + <stop 238 + offset="1" 239 + id="stop194" /> 240 + </linearGradient> 241 + <linearGradient 242 + id="paint2_linear_34_23" 243 + x1="617.118" 244 + y1="139.1" 245 + x2="617.118" 246 + y2="858.74" 247 + gradientUnits="userSpaceOnUse"> 248 + <stop 249 + stop-color="#F964A7" 250 + id="stop197" /> 251 + <stop 252 + offset="1" 253 + stop-color="#B6346F" 254 + id="stop199" /> 255 + </linearGradient> 256 + </defs> 257 + </svg>
+46
public/images/layout/osu-lazer-logo-white.svg
··· 1 + <svg width="1000" height="1000" viewBox="0 0 1000 1000" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <g filter="url(#filter0_d_34_81)"> 3 + <path d="M780.16 365.439C792.756 365.439 801.393 374.796 801.393 387.751V507.592C801.393 520.548 792.756 529.905 780.16 529.905C767.204 529.905 758.927 520.548 758.927 507.592V387.751C758.927 374.796 767.204 365.439 780.16 365.439ZM780.16 606.2C764.685 606.2 752.449 593.964 752.449 578.489C752.449 563.374 764.685 551.138 780.16 551.138C795.635 551.138 807.871 563.374 807.871 578.489C807.871 593.964 795.635 606.2 780.16 606.2Z" fill="white"/> 4 + <path d="M702.605 423.379C715.561 423.379 723.838 432.736 723.838 445.332V524.866C723.838 583.527 687.672 606.199 643.766 606.199C599.501 606.199 563.334 583.527 563.334 524.866V445.332C563.334 432.736 571.611 423.379 584.567 423.379C597.163 423.379 605.8 432.736 605.8 445.332V521.987C605.8 554.017 619.294 566.612 643.766 566.612C667.878 566.612 681.372 554.017 681.372 521.987V445.332C681.372 432.736 690.01 423.379 702.605 423.379Z" fill="white"/> 5 + <path d="M442.923 471.964C442.923 483.84 455.159 488.159 478.551 493.557C510.941 501.474 541.891 509.752 541.891 549.699C541.891 588.566 513.1 606.2 471.714 606.2C437.525 606.2 412.693 594.324 401.177 580.648C392.18 569.852 393.259 560.495 401.896 552.218C412.693 541.781 422.05 546.1 428.168 551.498C437.885 560.495 449.761 569.852 472.793 569.852C490.067 569.852 500.864 564.094 500.864 552.578C500.864 541.061 489.348 537.103 459.118 528.825C429.247 520.548 402.616 512.631 402.616 477.002C402.616 437.055 435.006 420.501 470.994 420.501C491.507 420.501 514.899 425.899 529.295 441.374C535.413 447.492 540.091 456.129 529.295 468.005C518.498 479.162 510.581 475.923 503.023 470.165C496.185 465.126 484.309 456.489 466.315 456.489C454.079 456.489 442.923 460.448 442.923 471.964Z" fill="white"/> 6 + <path d="M286.129 606.2C231.427 606.2 192.2 566.613 192.2 513.35C192.2 459.728 231.427 420.501 286.129 420.501C340.831 420.501 380.058 459.728 380.058 513.35C380.058 566.613 340.831 606.2 286.129 606.2ZM286.129 566.613C317.799 566.613 337.592 543.581 337.592 513.35C337.592 483.12 317.799 459.728 286.129 459.728C254.46 459.728 234.666 483.12 234.666 513.35C234.666 543.581 254.46 566.613 286.129 566.613Z" fill="white"/> 7 + </g> 8 + <g filter="url(#filter1_d_34_81)"> 9 + <path d="M950 500C950 748.528 748.528 950 500 950C251.472 950 50 748.528 50 500C50 251.472 251.472 50 500 50C748.528 50 950 251.472 950 500ZM95 500C95 723.675 276.325 905 500 905C723.675 905 905 723.675 905 500C905 276.325 723.675 95 500 95C276.325 95 95 276.325 95 500Z" fill="white"/> 10 + </g> 11 + <g filter="url(#filter2_d_34_81)"> 12 + <path d="M257.9 513.851C257.9 529.326 270.136 541.562 285.611 541.562C301.086 541.562 313.322 529.326 313.322 513.851C313.322 498.736 301.086 486.5 285.611 486.5C270.136 486.5 257.9 498.736 257.9 513.851Z" fill="white"/> 13 + </g> 14 + <defs> 15 + <filter id="filter0_d_34_81" x="170.75" y="354.714" width="658.571" height="283.661" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> 16 + <feFlood flood-opacity="0" result="BackgroundImageFix"/> 17 + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> 18 + <feOffset dy="10.7249"/> 19 + <feGaussianBlur stdDeviation="10.7249"/> 20 + <feComposite in2="hardAlpha" operator="out"/> 21 + <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> 22 + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_34_81"/> 23 + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_34_81" result="shape"/> 24 + </filter> 25 + <filter id="filter1_d_34_81" x="32" y="41" width="936" height="936" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> 26 + <feFlood flood-opacity="0" result="BackgroundImageFix"/> 27 + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> 28 + <feOffset dy="9"/> 29 + <feGaussianBlur stdDeviation="9"/> 30 + <feComposite in2="hardAlpha" operator="out"/> 31 + <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> 32 + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_34_81"/> 33 + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_34_81" result="shape"/> 34 + </filter> 35 + <filter id="filter2_d_34_81" x="236.45" y="475.775" width="98.3216" height="97.9616" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> 36 + <feFlood flood-opacity="0" result="BackgroundImageFix"/> 37 + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> 38 + <feOffset dy="10.7249"/> 39 + <feGaussianBlur stdDeviation="10.7249"/> 40 + <feComposite in2="hardAlpha" operator="out"/> 41 + <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> 42 + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_34_81"/> 43 + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_34_81" result="shape"/> 44 + </filter> 45 + </defs> 46 + </svg>
+2 -2
resources/css/bem/nav2.less
··· 60 60 transition: all 100ms ease-in-out; 61 61 will-change: opacity, transform; 62 62 63 - background-image: url('~@images/layout/osu-logo-white.svg'); 63 + background-image: var(--nav-logo); 64 64 65 65 .@{_top}__logo-link:hover & { 66 66 // be careful of weird snapping at the end of animation on Firefox (with 1.1, ~60px). ··· 68 68 } 69 69 70 70 &--bg { 71 - background-image: url('~@images/layout/osu-logo-triangles.svg'); 71 + background-image: var(--nav-logo-bg); 72 72 opacity: 0; 73 73 74 74 .@{_top}__logo-link:hover & {
+1 -1
resources/css/bem/navbar-mobile.less
··· 33 33 &__logo { 34 34 flex: none; 35 35 display: block; 36 - background-image: url('~@images/layout/osu-logo-white.svg'); 36 + background-image: var(--nav-logo); 37 37 background-size: contain; 38 38 background-repeat: no-repeat; 39 39 background-position: center;
+8
resources/css/bem/osu-layout.less
··· 13 13 transition: filter 200ms ease-in-out, opacity 200ms ease-in-out; // for fading in after &--masked is removed 14 14 15 15 &--body { 16 + --nav-logo: url('~@images/layout/osu-logo-white.svg'); 17 + --nav-logo-bg: url('~@images/layout/osu-logo-triangles.svg'); 18 + 16 19 background-color: @osu-colour-b6; 17 20 } 18 21 ··· 33 36 content: " "; 34 37 z-index: @z-index--body-background; 35 38 } 39 + } 40 + 41 + &--body-lazer { 42 + --nav-logo: url('~@images/layout/osu-lazer-logo-white.svg'); 43 + --nav-logo-bg: url('~@images/layout/osu-lazer-logo-triangles.svg'); 36 44 } 37 45 38 46 &--full {
+6
resources/css/bem/simple-menu.less
··· 135 135 } 136 136 } 137 137 138 + &__extra { 139 + background-color: hsl(var(--hsl-b5)); 140 + padding: @_padding-vertical @_gutter; 141 + margin: -@_padding-vertical -@_gutter @_padding-vertical; 142 + } 143 + 138 144 &__form { 139 145 margin: -@_padding-vertical -@_gutter; 140 146 }
+31 -80
resources/js/beatmap-discussions-history/main.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context'; 5 - import { BeatmapsetsContext } from 'beatmap-discussions/beatmapsets-context'; 6 4 import { Discussion } from 'beatmap-discussions/discussion'; 7 - import { DiscussionsContext } from 'beatmap-discussions/discussions-context'; 8 5 import BeatmapsetCover from 'components/beatmapset-cover'; 9 6 import BeatmapsetDiscussionsBundleJson from 'interfaces/beatmapset-discussions-bundle-json'; 10 - import { keyBy } from 'lodash'; 11 - import { computed, makeObservable } from 'mobx'; 12 - import { observer } from 'mobx-react'; 13 - import { deletedUserJson } from 'models/user'; 14 7 import * as React from 'react'; 8 + import BeatmapsetDiscussionsBundleStore from 'stores/beatmapset-discussions-bundle-store'; 15 9 import { makeUrl } from 'utils/beatmapset-discussion-helper'; 16 10 import { trans } from 'utils/lang'; 17 11 18 - interface Props { 19 - bundle: BeatmapsetDiscussionsBundleJson; 20 - } 21 - 22 - @observer 23 - export default class Main extends React.Component<Props> { 24 - @computed 25 - private get beatmaps() { 26 - return keyBy(this.props.bundle.beatmaps, 'id'); 27 - } 28 - 29 - @computed 30 - private get beatmapsets() { 31 - return keyBy(this.props.bundle.beatmapsets, 'id'); 32 - } 33 - 34 - @computed 35 - private get discussions() { 36 - return keyBy(this.props.bundle.included_discussions, 'id'); 37 - } 38 - 39 - @computed 40 - private get users() { 41 - const values = keyBy(this.props.bundle.users, 'id'); 42 - // eslint-disable-next-line id-blacklist 43 - values.null = values.undefined = deletedUserJson; 44 - 45 - return values; 46 - } 47 - 48 - constructor(props: Props) { 49 - super(props); 50 - 51 - makeObservable(this); 52 - } 12 + export default class Main extends React.Component<BeatmapsetDiscussionsBundleJson> { 13 + private readonly store = new BeatmapsetDiscussionsBundleStore(this.props); 53 14 54 15 render() { 55 16 return ( 56 - <DiscussionsContext.Provider value={this.discussions}> 57 - <BeatmapsetsContext.Provider value={this.beatmapsets}> 58 - <BeatmapsContext.Provider value={this.beatmaps}> 59 - <div className='modding-profile-list modding-profile-list--index'> 60 - {this.props.bundle.discussions.length === 0 ? ( 61 - <div className='modding-profile-list__empty'> 62 - {trans('beatmap_discussions.index.none_found')} 63 - </div> 64 - ) : (this.props.bundle.discussions.map((discussion) => { 65 - // TODO: handle in child component? Refactored state might not have beatmapset here (and uses Map) 66 - const beatmapset = this.beatmapsets[discussion.beatmapset_id]; 17 + <div className='modding-profile-list modding-profile-list--index'> 18 + {this.props.discussions.length === 0 ? ( 19 + <div className='modding-profile-list__empty'> 20 + {trans('beatmap_discussions.index.none_found')} 21 + </div> 22 + ) : (this.props.discussions.map((discussion) => { 23 + // TODO: handle in child component? Refactored state might not have beatmapset here (and uses Map) 24 + const beatmapset = this.store.beatmapsets.get(discussion.beatmapset_id); 67 25 68 - return beatmapset != null && ( 69 - <div key={discussion.id} className='modding-profile-list__row'> 70 - <a 71 - className='modding-profile-list__thumbnail' 72 - href={makeUrl({ discussion })} 73 - > 74 - <BeatmapsetCover 75 - beatmapset={beatmapset} 76 - size='list' 77 - /> 78 - </a> 79 - <Discussion 80 - beatmapset={beatmapset} 81 - currentBeatmap={discussion.beatmap_id != null ? this.beatmaps[discussion.beatmap_id] : null} 82 - discussion={discussion} 83 - isTimelineVisible={false} 84 - preview 85 - readonly 86 - showDeleted 87 - users={this.users} 88 - /> 89 - </div> 90 - ); 91 - }))} 26 + return beatmapset != null && ( 27 + <div key={discussion.id} className='modding-profile-list__row'> 28 + <a 29 + className='modding-profile-list__thumbnail' 30 + href={makeUrl({ discussion })} 31 + > 32 + <BeatmapsetCover 33 + beatmapset={beatmapset} 34 + size='list' 35 + /> 36 + </a> 37 + <Discussion 38 + discussion={discussion} 39 + discussionsState={null} 40 + isTimelineVisible={false} 41 + store={this.store} 42 + /> 92 43 </div> 93 - </BeatmapsContext.Provider> 94 - </BeatmapsetsContext.Provider> 95 - </DiscussionsContext.Provider> 44 + ); 45 + }))} 46 + </div> 96 47 ); 97 48 } 98 49 }
+42 -43
resources/js/beatmap-discussions/beatmap-list.tsx
··· 3 3 4 4 import BeatmapListItem from 'components/beatmap-list-item'; 5 5 import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 6 - import BeatmapsetJson from 'interfaces/beatmapset-json'; 7 6 import UserJson from 'interfaces/user-json'; 8 - import { deletedUser } from 'models/user'; 7 + import { action, computed, makeObservable, observable } from 'mobx'; 8 + import { observer } from 'mobx-react'; 9 + import { deletedUserJson } from 'models/user'; 9 10 import * as React from 'react'; 11 + import { makeUrl } from 'utils/beatmapset-discussion-helper'; 10 12 import { blackoutToggle } from 'utils/blackout'; 11 13 import { classWithModifiers } from 'utils/css'; 12 14 import { formatNumber } from 'utils/html'; 13 15 import { nextVal } from 'utils/seq'; 16 + import DiscussionsState from './discussions-state'; 14 17 15 18 interface Props { 16 - beatmaps: BeatmapExtendedJson[]; 17 - beatmapset: BeatmapsetJson; 18 - createLink: (beatmap: BeatmapExtendedJson) => string; 19 - currentBeatmap: BeatmapExtendedJson; 20 - getCount: (beatmap: BeatmapExtendedJson) => number | undefined; 21 - onSelectBeatmap: (beatmapId: number) => void; 22 - users: Partial<Record<number, UserJson>>; 19 + discussionsState: DiscussionsState; 20 + users: Map<number | null | undefined, UserJson>; 23 21 } 24 22 25 - interface State { 26 - showingSelector: boolean; 27 - } 23 + @observer 24 + export default class BeatmapList extends React.Component<Props> { 25 + private readonly eventId = `beatmapset-discussions-show-beatmap-list-${nextVal()}`; 26 + @observable private showingSelector = false; 28 27 29 - export default class BeatmapList extends React.PureComponent<Props, State> { 30 - private readonly eventId = `beatmapset-discussions-show-beatmap-list-${nextVal()}`; 28 + @computed 29 + private get beatmaps() { 30 + return this.props.discussionsState.groupedBeatmaps.get(this.props.discussionsState.currentBeatmap.mode) ?? []; 31 + } 31 32 32 33 constructor(props: Props) { 33 34 super(props); 34 35 35 - this.state = { 36 - showingSelector: false, 37 - }; 36 + makeObservable(this); 38 37 } 39 38 40 39 componentDidMount() { 41 40 $(document).on(`click.${this.eventId}`, this.onDocumentClick); 42 - $(document).on(`turbolinks:before-cache.${this.eventId}`, this.hideSelector); 43 - this.syncBlackout(); 41 + $(document).on(`turbolinks:before-cache.${this.eventId}`, this.handleBeforeCache); 42 + blackoutToggle(this.showingSelector, 0.5); 44 43 } 45 44 46 45 componentWillUnmount() { ··· 49 48 50 49 render() { 51 50 return ( 52 - <div className={classWithModifiers('beatmap-list', { selecting: this.state.showingSelector })}> 51 + <div className={classWithModifiers('beatmap-list', { selecting: this.showingSelector })}> 53 52 <div className='beatmap-list__body'> 54 53 <a 55 54 className='beatmap-list__item beatmap-list__item--selected beatmap-list__item--large js-beatmap-list-selector' 56 - href={this.props.createLink(this.props.currentBeatmap)} 55 + href={makeUrl({ beatmap: this.props.discussionsState.currentBeatmap })} 57 56 onClick={this.toggleSelector} 58 57 > 59 - <BeatmapListItem beatmap={this.props.currentBeatmap} mapper={null} modifiers='large' /> 58 + <BeatmapListItem beatmap={this.props.discussionsState.currentBeatmap} mapper={null} modifiers='large' /> 60 59 <div className='beatmap-list__item-selector-button'> 61 60 <span className='fas fa-chevron-down' /> 62 61 </div> ··· 64 63 65 64 <div className='beatmap-list__selector-container'> 66 65 <div className='beatmap-list__selector'> 67 - {this.props.beatmaps.map(this.beatmapListItem)} 66 + {this.beatmaps.map(this.beatmapListItem)} 68 67 </div> 69 68 </div> 70 69 </div> ··· 73 72 } 74 73 75 74 private readonly beatmapListItem = (beatmap: BeatmapExtendedJson) => { 76 - const count = this.props.getCount(beatmap); 75 + const count = this.props.discussionsState.unresolvedDiscussionCounts.byBeatmap[beatmap.id]; 77 76 78 77 return ( 79 78 <div 80 79 key={beatmap.id} 81 - className={classWithModifiers('beatmap-list__item', { current: beatmap.id === this.props.currentBeatmap.id })} 80 + className={classWithModifiers('beatmap-list__item', { current: beatmap.id === this.props.discussionsState.currentBeatmap.id })} 82 81 data-id={beatmap.id} 83 82 onClick={this.selectBeatmap} 84 83 > 85 84 <BeatmapListItem 86 85 beatmap={beatmap} 87 - beatmapUrl={this.props.createLink(beatmap)} 88 - beatmapset={this.props.beatmapset} 89 - mapper={this.props.users[beatmap.user_id] ?? deletedUser} 86 + beatmapUrl={makeUrl({ beatmap })} 87 + beatmapset={this.props.discussionsState.beatmapset} 88 + mapper={this.props.users.get(beatmap.user_id) ?? deletedUserJson} 90 89 showNonGuestMapper={false} 91 90 /> 92 91 {count != null && ··· 98 97 ); 99 98 }; 100 99 101 - private readonly hideSelector = () => { 102 - if (this.state.showingSelector) { 103 - this.setSelector(false); 104 - } 100 + @action 101 + private readonly handleBeforeCache = () => { 102 + this.setShowingSelector(false); 105 103 }; 106 104 105 + @action 107 106 private readonly onDocumentClick = (e: JQuery.ClickEvent) => { 108 107 if (e.button !== 0) return; 109 108 ··· 111 110 return; 112 111 } 113 112 114 - this.hideSelector(); 113 + this.setShowingSelector(false); 115 114 }; 116 115 116 + @action 117 117 private readonly selectBeatmap = (e: React.MouseEvent<HTMLElement>) => { 118 118 if (e.button !== 0) return; 119 119 e.preventDefault(); 120 120 121 121 const beatmapId = parseInt(e.currentTarget.dataset.id ?? '', 10); 122 - this.props.onSelectBeatmap(beatmapId); 123 - }; 124 122 125 - private readonly setSelector = (state: boolean) => { 126 - if (this.state.showingSelector !== state) { 127 - this.setState({ showingSelector: state }, this.syncBlackout); 128 - } 123 + this.props.discussionsState.currentBeatmapId = beatmapId; 124 + this.props.discussionsState.changeDiscussionPage('timeline'); 129 125 }; 130 126 131 - private readonly syncBlackout = () => { 132 - blackoutToggle(this.state.showingSelector, 0.5); 133 - }; 127 + @action 128 + private setShowingSelector(state: boolean) { 129 + this.showingSelector = state; 130 + blackoutToggle(state, 0.5); 131 + } 134 132 133 + @action 135 134 private readonly toggleSelector = (e: React.MouseEvent<HTMLElement>) => { 136 135 if (e.button !== 0) return; 137 136 e.preventDefault(); 138 137 139 - this.setSelector(!this.state.showingSelector); 138 + this.setShowingSelector(!this.showingSelector); 140 139 }; 141 140 }
+6 -6
resources/js/beatmap-discussions/beatmap-owner-editor.tsx
··· 5 5 import UserAvatar from 'components/user-avatar'; 6 6 import UserLink from 'components/user-link'; 7 7 import BeatmapJson from 'interfaces/beatmap-json'; 8 - import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 8 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 9 9 import UserJson from 'interfaces/user-json'; 10 10 import { route } from 'laroute'; 11 11 import { action, computed, makeObservable, observable, runInAction } from 'mobx'; ··· 16 16 import { classWithModifiers } from 'utils/css'; 17 17 import { transparentGif } from 'utils/html'; 18 18 import { trans } from 'utils/lang'; 19 - 20 - type BeatmapsetWithDiscussionJson = BeatmapsetExtendedJson; 19 + import DiscussionsState from './discussions-state'; 21 20 22 21 interface XhrCollection { 23 - updateOwner: JQuery.jqXHR<BeatmapsetWithDiscussionJson>; 22 + updateOwner: JQuery.jqXHR<BeatmapsetWithDiscussionsJson>; 24 23 userLookup: JQuery.jqXHR<UserJson>; 25 24 } 26 25 27 26 interface Props { 28 27 beatmap: BeatmapJson; 29 28 beatmapsetUser: UserJson; 29 + discussionsState: DiscussionsState; 30 30 user: UserJson; 31 31 userByName: Map<string, UserJson>; 32 32 } ··· 245 245 data: { beatmap: { user_id: userId } }, 246 246 method: 'PUT', 247 247 }); 248 - this.xhr.updateOwner.done((data) => runInAction(() => { 249 - $.publish('beatmapsetDiscussions:update', { beatmapset: data }); 248 + this.xhr.updateOwner.done((beatmapset) => runInAction(() => { 249 + this.props.discussionsState.update({ beatmapset }); 250 250 this.editing = false; 251 251 })).fail(onErrorWithCallback(() => { 252 252 this.updateOwner(userId);
-9
resources/js/beatmap-discussions/beatmaps-context.ts
··· 1 - // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - // See the LICENCE file in the repository root for full licence text. 3 - 4 - import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 5 - import * as React from 'react'; 6 - 7 - const defaultValue: Partial<Record<number, BeatmapExtendedJson>> = {}; 8 - 9 - export const BeatmapsContext = React.createContext(defaultValue);
+6 -3
resources/js/beatmap-discussions/beatmaps-owner-editor.tsx
··· 10 10 import { group as groupBeatmaps } from 'utils/beatmap-helper'; 11 11 import { trans } from 'utils/lang'; 12 12 import BeatmapOwnerEditor from './beatmap-owner-editor'; 13 + import DiscussionsState from './discussions-state'; 13 14 14 15 interface Props { 15 16 beatmapset: BeatmapsetExtendedJson; 17 + discussionsState: DiscussionsState; 16 18 onClose: () => void; 17 - users: Partial<Record<number, UserJson>>; 19 + users: Map<number | null | undefined, UserJson>; 18 20 } 19 21 20 22 @observer ··· 26 28 27 29 // this will be outdated on new props but it's fine 28 30 // as there's separate process handling unknown users 29 - for (const user of Object.values(props.users)) { 31 + for (const user of this.props.users.values()) { 30 32 if (user != null) { 31 33 this.userByName.set(normaliseUsername(user.username), user); 32 34 } ··· 61 63 key={beatmap.id} 62 64 beatmap={beatmap} 63 65 beatmapsetUser={beatmapsetUser} 66 + discussionsState={this.props.discussionsState} 64 67 user={this.getUser(beatmap.user_id)} 65 68 userByName={this.userByName} 66 69 /> ··· 82 85 } 83 86 84 87 private getUser(userId: number) { 85 - return this.props.users[userId] ?? deletedUserJson; 88 + return this.props.users.get(userId) ?? deletedUserJson; 86 89 } 87 90 }
-9
resources/js/beatmap-discussions/beatmapsets-context.ts
··· 1 - // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - // See the LICENCE file in the repository root for full licence text. 3 - 4 - import BeatmapetExtendedJson from 'interfaces/beatmapset-extended-json'; 5 - import * as React from 'react'; 6 - 7 - const defaultValue: Partial<Record<number, BeatmapetExtendedJson>> = {}; 8 - 9 - export const BeatmapsetsContext = React.createContext(defaultValue);
+2 -2
resources/js/beatmap-discussions/chart.tsx
··· 7 7 import { classWithModifiers } from 'utils/css'; 8 8 9 9 interface Props { 10 - discussions: Partial<Record<string, BeatmapsetDiscussionJson>>; 10 + discussions: BeatmapsetDiscussionJson[]; 11 11 duration: number; 12 12 } 13 13 ··· 22 22 const items: React.ReactNode[] = []; 23 23 24 24 if (props.duration !== 0) { 25 - Object.values(props.discussions).forEach((discussion) => { 25 + props.discussions.forEach((discussion) => { 26 26 if (discussion == null || discussion.timestamp == null) return; 27 27 28 28 let className = classWithModifiers('beatmapset-discussions-chart__item', [
+2 -3
resources/js/beatmap-discussions/discussion-mode.ts
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - // In display order on discussion page tabs 5 - export const discussionPages = ['reviews', 'generalAll', 'general', 'timeline', 'events'] as const; 6 - export type DiscussionPage = (typeof discussionPages)[number]; 4 + import DiscussionPage from './discussion-page'; 7 5 8 6 type DiscussionMode = Exclude<DiscussionPage, 'events'>; 7 + export const discussionModes: Readonly<DiscussionMode[]> = ['reviews', 'generalAll', 'general', 'timeline'] as const; 9 8 10 9 export default DiscussionMode;
+14
resources/js/beatmap-discussions/discussion-page.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + // In display order on discussion page tabs 5 + export const discussionPages = ['reviews', 'generalAll', 'general', 'timeline', 'events'] as const; 6 + type DiscussionPage = (typeof discussionPages)[number]; 7 + 8 + const discussionPageSet = new Set<unknown>(discussionPages); 9 + 10 + export function isDiscussionPage(value: unknown): value is DiscussionPage { 11 + return discussionPageSet.has(value); 12 + } 13 + 14 + export default DiscussionPage;
+7 -4
resources/js/beatmap-discussions/discussion-vote-buttons.tsx
··· 3 3 4 4 import UserListPopup, { createTooltip } from 'components/user-list-popup'; 5 5 import { BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; 6 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 6 7 import UserJson from 'interfaces/user-json'; 7 8 import { route } from 'laroute'; 8 9 import { action, computed, makeObservable, observable } from 'mobx'; ··· 14 15 import { classWithModifiers } from 'utils/css'; 15 16 import { trans } from 'utils/lang'; 16 17 import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay'; 18 + import DiscussionsState from './discussions-state'; 17 19 18 20 const voteTypes = ['up', 'down'] as const; 19 21 type VoteType = typeof voteTypes[number]; ··· 21 23 interface Props { 22 24 cannotVote: boolean; 23 25 discussion: BeatmapsetDiscussionJsonForShow; 24 - users: Partial<Record<number | string, UserJson>>; 26 + discussionsState: DiscussionsState; 27 + users: Map<number | null | undefined, UserJson>; 25 28 } 26 29 27 30 @observer 28 31 export default class DiscussionVoteButtons extends React.Component<Props> { 29 32 private readonly tooltips: Partial<Record<VoteType, JQuery>> = {}; 30 - @observable private voteXhr: JQuery.jqXHR<BeatmapsetDiscussionJsonForShow> | null = null; 33 + @observable private voteXhr: JQuery.jqXHR<BeatmapsetWithDiscussionsJson> | null = null; 31 34 32 35 @computed 33 36 private get canDownvote() { ··· 68 71 ? trans(`beatmaps.discussions.votes.none.${type}`) 69 72 : `${trans(`beatmaps.discussions.votes.latest.${type}`)}:`; 70 73 71 - const users = this.props.discussion.votes.voters[type].map((id) => this.props.users[id] ?? { id }); 74 + const users = this.props.discussion.votes.voters[type].map((id) => this.props.users.get(id) ?? { id }); 72 75 73 76 return renderToStaticMarkup(<UserListPopup count={count} title={title} users={users} />); 74 77 } ··· 89 92 }); 90 93 91 94 this.voteXhr 92 - .done((beatmapset) => $.publish('beatmapsetDiscussions:update', { beatmapset })) 95 + .done((beatmapset) => this.props.discussionsState.update({ beatmapset })) 93 96 .fail(onError) 94 97 .always(action(() => { 95 98 hideLoadingOverlay();
+69 -59
resources/js/beatmap-discussions/discussion.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 5 4 import BeatmapsetDiscussionJson, { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; 6 5 import BeatmapsetDiscussionPostJson from 'interfaces/beatmapset-discussion-post-json'; 7 - import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 8 - import UserJson from 'interfaces/user-json'; 6 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 9 7 import { findLast } from 'lodash'; 10 8 import { action, computed, makeObservable } from 'mobx'; 11 9 import { observer } from 'mobx-react'; ··· 18 16 import { trans } from 'utils/lang'; 19 17 import { DiscussionType, discussionTypeIcons } from './discussion-type'; 20 18 import DiscussionVoteButtons from './discussion-vote-buttons'; 21 - import DiscussionsStateContext from './discussions-state-context'; 19 + import DiscussionsState from './discussions-state'; 22 20 import { NewReply } from './new-reply'; 23 21 import Post from './post'; 24 22 import SystemPost from './system-post'; ··· 26 24 27 25 const bn = 'beatmap-discussion'; 28 26 29 - interface PropsBase { 30 - beatmapset: BeatmapsetExtendedJson; 31 - currentBeatmap: BeatmapExtendedJson | null; 27 + interface BaseProps { 32 28 isTimelineVisible: boolean; 33 29 parentDiscussion?: BeatmapsetDiscussionJson | null; 34 - readonly: boolean; 35 - readPostIds?: Set<number>; 36 - showDeleted: boolean; 37 - users: Partial<Record<number | string, UserJson>>; 30 + store: BeatmapsetDiscussionsStore; 38 31 } 39 32 40 - // preview version is used on pages other than the main discussions page. 41 - type Props = PropsBase & ({ 42 - // BeatmapsetDiscussionJsonForShow is because editing still returns 43 - // BeatmapsetDiscussionJsonForShow which gets merged into the parent discussions blob. 44 - discussion: BeatmapsetDiscussionJsonForBundle | BeatmapsetDiscussionJsonForShow; 45 - preview: true; 33 + type Props = BaseProps & ({ 34 + discussion: BeatmapsetDiscussionJsonForBundle; 35 + discussionsState: null; // TODO: make optional? 46 36 } | { 47 37 discussion: BeatmapsetDiscussionJsonForShow; 48 - preview: false; 38 + discussionsState: DiscussionsState; 49 39 }); 50 40 51 41 function DiscussionTypeIcon({ type }: { type: DiscussionType | 'resolved' }) { ··· 64 54 65 55 @observer 66 56 export class Discussion extends React.Component<Props> { 67 - static contextType = DiscussionsStateContext; 68 - static defaultProps = { 69 - preview: false, 70 - readonly: false, 71 - }; 72 - 73 - declare context: React.ContextType<typeof DiscussionsStateContext>; 74 57 private lastResolvedState = false; 75 58 76 - constructor(props: Props) { 77 - super(props); 78 - makeObservable(this); 59 + private get beatmapset() { 60 + return this.props.discussionsState?.beatmapset; 61 + } 62 + 63 + private get currentBeatmap() { 64 + return this.props.discussionsState?.currentBeatmap; 79 65 } 80 66 81 67 @computed 82 68 private get canBeRepliedTo() { 83 - return !downloadLimited(this.props.beatmapset) 84 - && (!this.props.beatmapset.discussion_locked || canModeratePosts()) 85 - && (this.props.discussion.beatmap_id == null || this.props.currentBeatmap?.deleted_at == null); 69 + return this.beatmapset != null 70 + && !downloadLimited(this.beatmapset) 71 + && (!this.beatmapset.discussion_locked || canModeratePosts()) 72 + && (this.props.discussion.beatmap_id == null || this.currentBeatmap?.deleted_at == null); 86 73 } 87 74 88 75 @computed 89 76 private get collapsed() { 90 - return this.context.discussionCollapsed.get(this.props.discussion.id) ?? this.context.discussionDefaultCollapsed; 77 + return this.props.discussionsState?.discussionCollapsed.get(this.props.discussion.id) ?? this.props.discussionsState?.discussionDefaultCollapsed ?? false; 91 78 } 92 79 93 80 @computed 94 81 private get highlighted() { 95 - return this.context.highlightedDiscussionId === this.props.discussion.id; 82 + return this.props.discussionsState?.highlightedDiscussionId === this.props.discussion.id; 83 + } 84 + 85 + private get readonly() { 86 + return this.props.discussionsState == null; 87 + } 88 + 89 + private get readPostIds() { 90 + return this.props.discussionsState?.readPostIds; 96 91 } 97 92 98 93 @computed 99 - private get resolvedSystemPostId() { 94 + private get resolvedStateChangedPostId() { 100 95 // TODO: handling resolved status in bundles....? 101 - if (this.props.preview) return -1; 96 + if (this.props.discussionsState == null) return -1; 102 97 103 - const systemPost = findLast(this.props.discussion.posts, (post) => post != null && post.system && post.message.type === 'resolved'); 98 + const systemPost = findLast(this.props.discussion.posts, (post) => post.system && post.message.type === 'resolved'); 104 99 return systemPost?.id ?? -1; 100 + } 101 + 102 + private get showDeleted() { 103 + return this.props.discussionsState?.showDeleted ?? true; 104 + } 105 + 106 + private get users() { 107 + return this.props.store.users; 108 + } 109 + 110 + constructor(props: Props) { 111 + super(props); 112 + makeObservable(this); 105 113 } 106 114 107 115 render() { 108 116 if (!this.isVisible(this.props.discussion)) return null; 109 117 const firstPost = startingPost(this.props.discussion); 110 - // TODO: check if possible to have null post... 118 + // firstPost shouldn't be null anymore; 119 + // just simpler to allow startingPost to return undefined and adding a null check in render. 111 120 if (firstPost == null) return null; 112 121 113 122 const lineClasses = classWithModifiers(`${bn}__line`, { resolved: this.props.discussion.resolved }); 114 123 115 124 this.lastResolvedState = false; 116 125 117 - const user = this.props.users[this.props.discussion.user_id] ?? deletedUserJson; 126 + const user = this.users.get(this.props.discussion.user_id) ?? deletedUserJson; 118 127 const group = badgeGroup({ 119 - beatmapset: this.props.beatmapset, 120 - currentBeatmap: this.props.currentBeatmap, 128 + beatmapset: this.beatmapset, 129 + currentBeatmap: this.currentBeatmap, 121 130 discussion: this.props.discussion, 122 131 user, 123 132 }); ··· 126 135 deleted: this.props.discussion.deleted_at != null, 127 136 highlighted: this.highlighted, 128 137 'horizontal-desktop': this.props.discussion.message_type !== 'review', 129 - preview: this.props.preview, 138 + preview: this.readonly, 130 139 review: this.props.discussion.message_type === 'review', 131 140 timeline: this.props.discussion.timestamp != null, 132 141 unread: !this.isRead(firstPost), ··· 166 175 167 176 @action 168 177 private readonly handleCollapseClick = () => { 169 - this.context.discussionCollapsed.set(this.props.discussion.id, !this.collapsed); 178 + this.props.discussionsState?.discussionCollapsed.set(this.props.discussion.id, !this.collapsed); 170 179 }; 171 180 172 181 @action 173 182 private readonly handleSetHighlight = (e: React.MouseEvent<HTMLDivElement>) => { 174 - if (e.defaultPrevented) return; 175 - this.context.highlightedDiscussionId = this.props.discussion.id; 183 + if (e.defaultPrevented || this.props.discussionsState == null) return; 184 + this.props.discussionsState.highlightedDiscussionId = this.props.discussion.id; 176 185 }; 177 186 178 187 private isOwner(object: { user_id: number }) { ··· 180 189 } 181 190 182 191 private isRead(post: BeatmapsetDiscussionPostJson) { 183 - return this.props.readPostIds?.has(post.id) || this.isOwner(post) || this.props.preview; 192 + return this.readPostIds?.has(post.id) || this.isOwner(post) || this.readonly; 184 193 } 185 194 186 195 private isVisible(object: BeatmapsetDiscussionJson | BeatmapsetDiscussionPostJson) { 187 - return object != null && (this.props.showDeleted || object.deleted_at == null); 196 + return object != null && (this.showDeleted || object.deleted_at == null); 188 197 } 189 198 190 199 private postFooter() { 191 - if (this.props.preview) return null; 200 + if (this.props.discussionsState == null) return null; 192 201 193 202 let cssClasses = `${bn}__expanded`; 194 203 if (this.collapsed) { ··· 200 209 <div className={`${bn}__replies`}> 201 210 {this.props.discussion.posts.slice(1).map(this.renderReply)} 202 211 </div> 203 - {this.canBeRepliedTo && ( 212 + {this.props.discussionsState != null && this.canBeRepliedTo && ( 204 213 <NewReply 205 - beatmapset={this.props.beatmapset} 206 - currentBeatmap={this.props.currentBeatmap} 207 214 discussion={this.props.discussion} 215 + discussionsState={this.props.discussionsState} 208 216 /> 209 217 )} 210 218 </div> ··· 212 220 } 213 221 214 222 private renderPost(post: BeatmapsetDiscussionPostJson, type: 'discussion' | 'reply') { 215 - const user = this.props.users[post.user_id] ?? deletedUserJson; 223 + const user = this.users.get(post.user_id) ?? deletedUserJson; 216 224 217 225 if (post.system) { 218 226 return ( ··· 223 231 return ( 224 232 <Post 225 233 key={post.id} 226 - beatmap={this.props.currentBeatmap} 227 - beatmapset={this.props.beatmapset} 228 234 discussion={this.props.discussion} 235 + discussionsState={this.props.discussionsState} 229 236 post={post} 230 237 read={this.isRead(post)} 231 - readonly={this.props.readonly} 232 - resolvedSystemPostId={this.resolvedSystemPostId} 238 + readonly={this.readonly} 239 + resolvedStateChangedPostId={this.resolvedStateChangedPostId} 240 + store={this.props.store} 233 241 type={type} 234 242 user={user} 235 - users={this.props.users} 236 243 /> 237 244 ); 238 245 } 239 246 240 247 private renderPostButtons() { 241 - if (this.props.preview) return null; 248 + if (this.props.discussionsState == null) { 249 + return null; 250 + } 242 251 243 - const user = this.props.users[this.props.discussion.user_id]; 252 + const user = this.props.store.users.get(this.props.discussion.user_id); 244 253 245 254 return ( 246 255 <div className={`${bn}__top-actions`}> ··· 260 269 <DiscussionVoteButtons 261 270 cannotVote={this.isOwner(this.props.discussion) || (user?.is_bot ?? false) || !this.canBeRepliedTo} 262 271 discussion={this.props.discussion} 263 - users={this.props.users} 272 + discussionsState={this.props.discussionsState} 273 + users={this.users} 264 274 /> 265 275 <button 266 276 className={`${bn}__action ${bn}__action--with-line`}
-8
resources/js/beatmap-discussions/discussions-context.ts
··· 1 - // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - // See the LICENCE file in the repository root for full licence text. 3 - 4 - import { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; 5 - import { createContext } from 'react'; 6 - 7 - // TODO: needs discussions need flattening / normalization 8 - export const DiscussionsContext = createContext({} as Partial<Record<number, BeatmapsetDiscussionJsonForBundle | BeatmapsetDiscussionJsonForShow>>);
-10
resources/js/beatmap-discussions/discussions-state-context.ts
··· 1 - // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - // See the LICENCE file in the repository root for full licence text. 3 - 4 - import { createContext } from 'react'; 5 - import DiscussionsState from './discussions-state'; 6 - 7 - // TODO: combine with DiscussionsContext, BeatmapsetContext, etc into a store with properties. 8 - const DiscussionsStateContext = createContext(new DiscussionsState()); 9 - 10 - export default DiscussionsStateContext;
+420 -2
resources/js/beatmap-discussions/discussions-state.ts
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import { makeObservable, observable } from 'mobx'; 4 + import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 5 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 6 + import GameMode from 'interfaces/game-mode'; 7 + import { maxBy } from 'lodash'; 8 + import { action, computed, makeObservable, observable, reaction } from 'mobx'; 9 + import moment from 'moment'; 10 + import core from 'osu-core-singleton'; 11 + import BeatmapsetDiscussionsShowStore from 'stores/beatmapset-discussions-show-store'; 12 + import { findDefault, group, sortWithMode } from 'utils/beatmap-helper'; 13 + import { canModeratePosts, makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper'; 14 + import { parseJsonNullable, storeJson } from 'utils/json'; 15 + import { Filter, filters } from './current-discussions'; 16 + import DiscussionMode, { discussionModes } from './discussion-mode'; 17 + import DiscussionPage, { isDiscussionPage } from './discussion-page'; 18 + 19 + const jsonId = 'json-discussions-state'; 20 + 21 + export interface UpdateOptions { 22 + beatmap_discussion_post_ids: number[]; 23 + beatmapset: BeatmapsetWithDiscussionsJson; 24 + watching: boolean; 25 + } 26 + 27 + function reviver(key: string, value: unknown) { 28 + if (Array.isArray(value)) { 29 + if (key === 'discussionCollapsed') { 30 + return new Map(value); 31 + } 32 + 33 + if (key === 'readPostIds') { 34 + return new Set(value); 35 + } 36 + } 37 + 38 + return value; 39 + } 40 + 41 + function isFilter(value: unknown): value is Filter { 42 + return (filters as readonly unknown[]).includes(value); 43 + } 5 44 6 45 export default class DiscussionsState { 46 + @observable currentBeatmapId: number; 47 + @observable currentFilter: Filter = 'total'; // TODO: filter should always be total when page is events (also no highlight) 48 + @observable currentPage: DiscussionPage = 'general'; 7 49 @observable discussionCollapsed = new Map<number, boolean>(); 8 50 @observable discussionDefaultCollapsed = false; 9 51 @observable highlightedDiscussionId: number | null = null; 52 + @observable jumpToDiscussion = false; 53 + @observable pinnedNewDiscussion = false; 54 + 55 + @observable readPostIds = new Set<number>(); 56 + @observable selectedUserId: number | null = null; 57 + @observable showDeleted = true; // this toggle only affects All and deleted discussion filters, other filters don't show deleted 58 + 59 + private previousFilter: Filter = 'total'; 60 + private previousPage: DiscussionPage = 'general'; 61 + private readonly urlStateDisposer; 62 + 63 + get beatmapset() { 64 + return this.store.beatmapset; 65 + } 66 + 67 + @computed 68 + get currentBeatmap() { 69 + const beatmap = this.store.beatmaps.get(this.currentBeatmapId); 70 + if (beatmap == null) { 71 + throw new Error('missing beatmap'); 72 + } 73 + 74 + return beatmap; 75 + } 76 + 77 + /** 78 + * Discussions for the current beatmap grouped by filters 79 + */ 80 + @computed 81 + get discussionsByFilter() { 82 + const groups: Record<Filter, BeatmapsetDiscussionJson[]> = { 83 + deleted: [], 84 + hype: [], 85 + mapperNotes: [], 86 + mine: [], 87 + pending: [], 88 + praises: [], 89 + resolved: [], 90 + total: this.discussionForSelectedBeatmap, 91 + }; 92 + 93 + const currentUser = core.currentUser; 94 + const reviewsWithPending = new Set<BeatmapsetDiscussionJson>(); 95 + 96 + for (const discussion of this.discussionForSelectedBeatmap) { 97 + if (discussion.deleted_at != null) { 98 + groups.deleted.push(discussion); 99 + continue; 100 + } 101 + 102 + if (currentUser != null && discussion.user_id === currentUser.id) { 103 + groups.mine.push(discussion); 104 + } 105 + 106 + if (discussion.message_type === 'hype') { 107 + groups.hype.push(discussion); 108 + groups.praises.push(discussion); 109 + } else if (discussion.message_type === 'mapper_note') { 110 + groups.mapperNotes.push(discussion); 111 + } else if (discussion.message_type === 'praise') { 112 + groups.praises.push(discussion); 113 + } 10 114 11 - constructor() { 115 + if (discussion.can_be_resolved) { 116 + if (discussion.resolved) { 117 + groups.resolved.push(discussion); 118 + } else { 119 + groups.pending.push(discussion); 120 + // only reviews with unresolved discussions get added. 121 + if (discussion.parent_id != null) { 122 + const parentDiscussion = this.store.discussions.get(discussion.parent_id); 123 + if (parentDiscussion != null && parentDiscussion.message_type === 'review') { 124 + reviewsWithPending.add(parentDiscussion); 125 + } 126 + } 127 + } 128 + } 129 + } 130 + 131 + groups.pending.push(...reviewsWithPending); 132 + 133 + return groups; 134 + } 135 + 136 + /** 137 + * Discussions for the currently selected beatmap and filter grouped by mode. 138 + */ 139 + @computed 140 + get discussionsByMode() { 141 + const discussions = this.discussionsByFilter[this.currentFilter]; 142 + 143 + const value: Record<DiscussionMode, BeatmapsetDiscussionJson[]> = { 144 + general: [], 145 + generalAll: [], 146 + reviews: [], 147 + timeline: [], 148 + }; 149 + 150 + for (const discussion of discussions) { 151 + if (discussion.message_type === 'review') { 152 + value.reviews.push(discussion); 153 + } else if (discussion.beatmap_id == null) { 154 + value.generalAll.push(discussion); 155 + } else if (discussion.beatmap_id === this.currentBeatmapId) { 156 + if (discussion.timestamp != null) { 157 + value.timeline.push(discussion); 158 + } else { 159 + value.general.push(discussion); 160 + } 161 + } 162 + } 163 + 164 + return value; 165 + } 166 + 167 + @computed 168 + get discussionForSelectedBeatmap() { 169 + const discussions = canModeratePosts() 170 + ? this.discussionsArray 171 + : this.nonDeletedDiscussions; 172 + 173 + return discussions.filter((discussion) => (discussion.beatmap_id == null || discussion.beatmap_id === this.currentBeatmapId)); 174 + } 175 + 176 + @computed 177 + get discussionsArray() { 178 + return [...this.store.discussions.values()]; 179 + } 180 + 181 + @computed 182 + get discussionsForSelectedUserByMode() { 183 + if (this.selectedUser == null) { 184 + return this.discussionsByMode; 185 + } 186 + 187 + const value: Record<DiscussionMode, BeatmapsetDiscussionJson[]> = { 188 + general: [], 189 + generalAll: [], 190 + reviews: [], 191 + timeline: [], 192 + }; 193 + 194 + for (const mode of discussionModes) { 195 + value[mode] = this.discussionsByMode[mode].filter((discussion) => discussion.user_id === this.selectedUserId); 196 + } 197 + 198 + return value; 199 + } 200 + 201 + @computed 202 + get firstBeatmap() { 203 + return [...this.store.beatmaps.values()][0]; 204 + } 205 + 206 + @computed 207 + get groupedBeatmaps() { 208 + return group([...this.store.beatmaps.values()]); 209 + } 210 + 211 + @computed 212 + get hasCurrentUserHyped() { 213 + const currentUser = core.currentUser; 214 + return currentUser != null 215 + && this.discussionsByFilter.hype.some((discussion) => ( 216 + discussion.beatmap_id == null && discussion.user_id === currentUser.id 217 + )); 218 + } 219 + 220 + @computed 221 + get lastUpdate() { 222 + const maxDiscussions = maxBy(this.beatmapset.discussions, 'updated_at')?.updated_at; 223 + const maxEvents = maxBy(this.beatmapset.events, 'created_at')?.created_at; 224 + 225 + const maxLastUpdate = Math.max( 226 + Date.parse(this.beatmapset.last_updated), 227 + maxDiscussions != null ? Date.parse(maxDiscussions) : 0, 228 + maxEvents != null ? Date.parse(maxEvents) : 0, 229 + ); 230 + 231 + return moment(maxLastUpdate).unix(); 232 + } 233 + 234 + @computed 235 + get nonDeletedDiscussions() { 236 + return this.discussionsArray.filter((discussion) => discussion.deleted_at == null); 237 + } 238 + 239 + @computed 240 + get selectedUser() { 241 + return this.store.users.get(this.selectedUserId); 242 + } 243 + 244 + @computed 245 + get sortedBeatmaps() { 246 + return sortWithMode([...this.store.beatmaps.values()]); 247 + } 248 + 249 + @computed 250 + get totalHypeCount() { 251 + return this.nonDeletedDiscussions 252 + .reduce((sum, discussion) => +(discussion.message_type === 'hype') + sum, 0); 253 + } 254 + 255 + @computed 256 + get unresolvedDiscussionTotalCount() { 257 + return this.nonDeletedDiscussions 258 + .reduce((sum, discussion) => { 259 + if (discussion.can_be_resolved && !discussion.resolved) { 260 + if (discussion.beatmap_id == null) return sum + 1; 261 + 262 + const beatmap = this.store.beatmaps.get(discussion.beatmap_id); 263 + if (beatmap != null && beatmap.deleted_at == null) return sum + 1; 264 + } 265 + 266 + return sum; 267 + }, 0); 268 + } 269 + 270 + @computed 271 + get unresolvedDiscussionCounts() { 272 + const byBeatmap: Partial<Record<number, number>> = {}; 273 + const byMode: Record<GameMode, number> = { 274 + fruits: 0, 275 + mania: 0, 276 + osu: 0, 277 + taiko: 0, 278 + }; 279 + 280 + for (const discussion of this.nonDeletedDiscussions) { 281 + if (discussion.beatmap_id != null && discussion.can_be_resolved && !discussion.resolved) { 282 + byBeatmap[discussion.beatmap_id] = (byBeatmap[discussion.beatmap_id] ?? 0) + 1; 283 + 284 + const mode = this.store.beatmaps.get(discussion.beatmap_id)?.mode; 285 + if (mode != null) { 286 + byMode[mode]++; 287 + } 288 + } 289 + } 290 + 291 + return { 292 + byBeatmap, 293 + byMode, 294 + }; 295 + } 296 + 297 + @computed 298 + get url() { 299 + return makeUrl({ 300 + beatmap: this.currentBeatmap, 301 + filter: this.currentFilter, 302 + mode: this.currentPage, 303 + user: this.selectedUserId ?? undefined, 304 + }); 305 + } 306 + 307 + constructor(private readonly store: BeatmapsetDiscussionsShowStore) { 308 + const existingState = parseJsonNullable(jsonId, false, reviver); 309 + 310 + if (existingState != null) { 311 + Object.assign(this, existingState); 312 + } else { 313 + this.jumpToDiscussion = true; 314 + for (const discussion of store.beatmapset.discussions) { 315 + if (discussion.posts != null) { 316 + for (const post of discussion.posts) { 317 + this.readPostIds.add(post.id); 318 + } 319 + } 320 + } 321 + } 322 + 323 + this.currentBeatmapId = (findDefault({ group: this.groupedBeatmaps }) ?? this.firstBeatmap).id; 324 + 325 + // Current url takes priority over saved state. 326 + const query = parseUrl(null, store.beatmapset.discussions); 327 + if (query != null) { 328 + // TODO: maybe die instead? 329 + this.currentPage = query.mode; 330 + this.currentFilter = query.filter; 331 + if (query.beatmapId != null) { 332 + this.currentBeatmapId = query.beatmapId; 333 + } 334 + this.selectedUserId = query.user ?? null; 335 + } 336 + 12 337 makeObservable(this); 338 + 339 + this.urlStateDisposer = reaction(() => this.url, (current, prev) => { 340 + if (current !== prev) { 341 + Turbolinks.controller.advanceHistory(this.url); 342 + } 343 + }); 344 + } 345 + 346 + @action 347 + changeDiscussionPage(page?: string) { 348 + if (!isDiscussionPage(page)) return; 349 + 350 + if (page === 'events') { 351 + // record page and filter when switching to events 352 + this.previousPage = this.currentPage; 353 + this.previousFilter = this.currentFilter; 354 + } else if (this.currentFilter !== this.previousFilter) { 355 + // restore previous filter when switching away from events 356 + this.currentFilter = this.previousFilter; 357 + } 358 + 359 + this.currentPage = page; 360 + } 361 + 362 + @action 363 + changeFilter(filter: unknown) { 364 + if (!isFilter(filter)) return; 365 + 366 + // restore previous page when selecting a filter. 367 + if (this.currentPage === 'events') { 368 + this.currentPage = this.previousPage; 369 + } 370 + 371 + this.currentFilter = filter; 372 + } 373 + 374 + @action 375 + changeGameMode(mode: GameMode) { 376 + const beatmap = findDefault({ items: this.groupedBeatmaps.get(mode) }); 377 + if (beatmap != null) { 378 + this.currentBeatmapId = beatmap.id; 379 + } 380 + } 381 + 382 + destroy() { 383 + this.urlStateDisposer(); 384 + } 385 + 386 + @action 387 + markAsRead(ids: number | number[]) { 388 + if (Array.isArray(ids)) { 389 + ids.forEach((id) => this.readPostIds.add(id)); 390 + } else { 391 + this.readPostIds.add(ids); 392 + } 393 + } 394 + 395 + saveState() { 396 + storeJson(jsonId, this.toJson()); 397 + } 398 + 399 + toJson() { 400 + // Convert serialized properties into primitives supported by JSON.stringify. 401 + // Values that need conversion should have the appropriate reviver to restore 402 + // the original type when deserializing. 403 + return { 404 + currentBeatmapId: this.currentBeatmapId, 405 + currentFilter: this.currentFilter, 406 + currentPage: this.currentPage, 407 + discussionCollapsed: [...this.discussionCollapsed], 408 + discussionDefaultCollapsed: this.discussionDefaultCollapsed, 409 + highlightedDiscussionId: this.highlightedDiscussionId, 410 + jumpToDiscussion: this.jumpToDiscussion, 411 + pinnedNewDiscussion: this.pinnedNewDiscussion, 412 + readPostIds: [...this.readPostIds], 413 + selectedUserId: this.selectedUserId, 414 + showDeleted: this.showDeleted, 415 + }; 416 + } 417 + 418 + @action 419 + update(options: Partial<UpdateOptions>) { 420 + if (options.beatmap_discussion_post_ids != null) { 421 + this.markAsRead(options.beatmap_discussion_post_ids); 422 + } 423 + 424 + if (options.beatmapset != null) { 425 + this.store.beatmapset = options.beatmapset; 426 + } 427 + 428 + if (options.watching != null) { 429 + this.beatmapset.current_user_attributes.is_watching = options.watching; 430 + } 13 431 } 14 432 }
+37 -63
resources/js/beatmap-discussions/discussions.tsx
··· 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 4 import IconExpand from 'components/icon-expand'; 5 - import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 6 5 import BeatmapsetDiscussionJson, { BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; 7 - import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 8 - import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 9 - import UserJson from 'interfaces/user-json'; 10 - import { size } from 'lodash'; 6 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 11 7 import { action, computed, makeObservable, observable } from 'mobx'; 12 8 import { observer } from 'mobx-react'; 13 9 import * as React from 'react'; 14 10 import { canModeratePosts } from 'utils/beatmapset-discussion-helper'; 15 11 import { classWithModifiers } from 'utils/css'; 16 12 import { trans } from 'utils/lang'; 17 - import CurrentDiscussions, { Filter } from './current-discussions'; 18 13 import { Discussion } from './discussion'; 19 14 import DiscussionMode from './discussion-mode'; 20 15 import DiscussionsState from './discussions-state'; 21 - import DiscussionsStateContext from './discussions-state-context'; 22 16 23 17 const bn = 'beatmap-discussions'; 24 18 ··· 58 52 type Sort = 'created_at' | 'updated_at' | 'timeline'; 59 53 60 54 interface Props { 61 - // TODO: most of these can move to context/store after main is converted to typescript. 62 - beatmapset: BeatmapsetExtendedJson & BeatmapsetWithDiscussionsJson; 63 - currentBeatmap: BeatmapExtendedJson; 64 - currentDiscussions: CurrentDiscussions; 65 - currentFilter: Filter; 66 - mode: DiscussionMode; 67 - readPostIds: Set<number>; 68 - showDeleted: boolean; 69 - users: Record<number, UserJson>; 55 + discussionsState: DiscussionsState; 56 + store: BeatmapsetDiscussionsStore; 70 57 } 71 58 72 59 @observer 73 60 export class Discussions extends React.Component<Props> { 74 - @observable private readonly discussionsState = new DiscussionsState(); 75 61 @observable private sort: Record<DiscussionMode, Sort> = { 76 62 general: 'updated_at', 77 63 generalAll: 'updated_at', ··· 81 67 82 68 @computed 83 69 private get currentSort() { 84 - return this.sort[this.props.mode]; 70 + return this.discussionsState.currentPage === 'events' 71 + ? 'timeline' // returning any valid mode is fine. 72 + : this.sort[this.discussionsState.currentPage]; 73 + } 74 + 75 + private get discussionsState() { 76 + return this.props.discussionsState; 85 77 } 86 78 87 79 @computed 88 80 private get isTimelineVisible() { 89 - return this.props.mode === 'timeline' && this.currentSort === 'timeline'; 81 + return this.discussionsState.currentPage === 'timeline' && this.currentSort === 'timeline'; 82 + } 83 + 84 + private get store() { 85 + return this.props.store; 90 86 } 91 87 92 88 @computed 93 89 private get sortedDiscussions() { 94 - return this.props.currentDiscussions[this.props.mode].slice().sort((a: BeatmapsetDiscussionJson, b: BeatmapsetDiscussionJson) => { 90 + if (this.discussionsState.currentPage === 'events') return []; 91 + 92 + const discussions = this.discussionsState.discussionsForSelectedUserByMode[this.discussionsState.currentPage]; 93 + 94 + return discussions.slice().sort((a: BeatmapsetDiscussionJson, b: BeatmapsetDiscussionJson) => { 95 95 const mapperNoteCompare = 96 96 // no sticky for timeline sort 97 97 this.currentSort !== 'timeline' ··· 111 111 constructor(props: Props) { 112 112 super(props); 113 113 makeObservable(this); 114 - } 115 - 116 - componentDidMount() { 117 - $.subscribe('beatmapset-discussions:highlight', this.handleSetHighlight); 118 - } 119 - 120 - componentWillUnmount() { 121 - $.unsubscribe('beatmapset-discussions:highlight', this.handleSetHighlight); 122 114 } 123 115 124 116 render() { ··· 149 141 150 142 @action 151 143 private readonly handleChangeSort = (e: React.SyntheticEvent<HTMLButtonElement>) => { 152 - this.sort[this.props.mode] = e.currentTarget.dataset.sortPreset as Sort; 144 + if (this.discussionsState.currentPage === 'events') return; 145 + this.sort[this.discussionsState.currentPage] = e.currentTarget.dataset.sortPreset as Sort; 153 146 }; 154 147 155 148 @action 156 149 private readonly handleExpandClick = (e: React.SyntheticEvent<HTMLButtonElement>) => { 157 150 this.discussionsState.discussionDefaultCollapsed = e.currentTarget.dataset.type === 'collapse'; 158 151 this.discussionsState.discussionCollapsed.clear(); 159 - }; 160 - 161 - @action 162 - private readonly handleSetHighlight = (_event: unknown, { discussionId }: { discussionId: number }) => { 163 - // TODO: update main to use context instead of publishing event. 164 - this.discussionsState.highlightedDiscussionId = discussionId; 165 152 }; 166 153 167 154 private readonly renderDiscussionPage = (discussion: BeatmapsetDiscussionJsonForShow) => { 168 - const visible = this.props.currentDiscussions.byFilter[this.props.currentFilter][this.props.mode][discussion.id] != null; 169 - 170 - if (!visible) return null; 171 - 172 - const parentDiscussion = discussion.parent_id != null ? this.props.currentDiscussions.byFilter.total.reviews[discussion.parent_id] : null; 155 + const parentDiscussion = this.store.discussions.get(discussion.parent_id); 173 156 174 157 return ( 175 158 <Discussion 176 159 key={discussion.id} 177 - beatmapset={this.props.beatmapset} 178 - currentBeatmap={this.props.currentBeatmap} 179 160 discussion={discussion} 161 + discussionsState={this.discussionsState} 180 162 isTimelineVisible={this.isTimelineVisible} 181 - parentDiscussion={parentDiscussion} 182 - readPostIds={this.props.readPostIds} 183 - showDeleted={this.props.showDeleted} 184 - users={this.props.users} 163 + parentDiscussion={parentDiscussion?.message_type === 'review' ? parentDiscussion : null} 164 + store={this.store} 185 165 /> 186 166 ); 187 167 }; 188 168 189 169 private renderDiscussions() { 190 - const discussions = this.props.currentDiscussions[this.props.mode]; 191 - 192 - if (discussions.length === 0) { 193 - return ( 194 - <div className={`${bn}__discussions ${bn}__discussions--empty`}> 195 - {trans('beatmaps.discussions.empty.empty')} 196 - </div> 197 - ); 198 - } 170 + const count = this.sortedDiscussions.length; 199 171 200 - if (size(this.props.currentDiscussions.byFilter[this.props.currentFilter][this.props.mode]) === 0) { 172 + if (count === 0) { 201 173 return ( 202 174 <div className={`${bn}__discussions ${bn}__discussions--empty`}> 203 - {trans('beatmaps.discussions.empty.hidden')} 175 + {this.discussionsState.discussionsByFilter.total.length > count 176 + ? trans('beatmaps.discussions.empty.hidden') 177 + : trans('beatmaps.discussions.empty.empty') 178 + } 204 179 </div> 205 180 ); 206 181 } ··· 211 186 212 187 {this.isTimelineVisible && <div className={`${bn}__timeline-line hidden-xs`} />} 213 188 214 - <DiscussionsStateContext.Provider value={this.discussionsState}> 215 - {this.sortedDiscussions.map(this.renderDiscussionPage)} 216 - </DiscussionsStateContext.Provider> 189 + {this.sortedDiscussions.map(this.renderDiscussionPage)} 217 190 218 191 {this.renderTimelineCircle()} 219 192 </div> ··· 246 219 type='button' 247 220 > 248 221 <span className={`${bn}__toolbar-link-content`}> 249 - <span className={this.props.showDeleted ? 'fas fa-check-square' : 'far fa-square'} /> 222 + <span className={this.discussionsState.showDeleted ? 'fas fa-check-square' : 'far fa-square'} /> 250 223 </span> 251 224 <span className={`${bn}__toolbar-link-content`}> 252 225 {trans('beatmaps.discussions.show_deleted')} ··· 256 229 } 257 230 258 231 private renderSortOptions() { 259 - const presets: Sort[] = this.props.mode === 'timeline' 232 + const presets: Sort[] = this.discussionsState.currentPage === 'timeline' 260 233 ? ['timeline', 'updated_at'] 261 234 : ['created_at', 'updated_at']; 262 235 ··· 289 262 ); 290 263 } 291 264 265 + @action 292 266 private readonly toggleShowDeleted = () => { 293 - $.publish('beatmapDiscussionPost:toggleShowDeleted'); 267 + this.discussionsState.showDeleted = !this.discussionsState.showDeleted; 294 268 }; 295 269 }
+14 -11
resources/js/beatmap-discussions/editor-discussion-component.tsx
··· 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 4 import { EmbedElement } from 'editor'; 5 - import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 6 5 import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 7 - import BeatmapsetJson from 'interfaces/beatmapset-json'; 8 - import { filter } from 'lodash'; 9 - import { Observer } from 'mobx-react'; 6 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 7 + import { Observer, observer } from 'mobx-react'; 10 8 import * as React from 'react'; 11 9 import { Transforms } from 'slate'; 12 10 import { RenderElementProps } from 'slate-react'; ··· 15 13 import { classWithModifiers } from 'utils/css'; 16 14 import { trans, transArray } from 'utils/lang'; 17 15 import { linkHtml } from 'utils/url'; 16 + import DiscussionsState from './discussions-state'; 18 17 import { DraftsContext } from './drafts-context'; 19 18 import EditorBeatmapSelector from './editor-beatmap-selector'; 20 19 import EditorIssueTypeSelector from './editor-issue-type-selector'; ··· 32 31 } 33 32 34 33 interface Props extends RenderElementProps { 35 - beatmaps: BeatmapExtendedJson[]; 36 - beatmapset: BeatmapsetJson; 37 - discussions: Partial<Record<number, BeatmapsetDiscussionJson>>; 34 + discussionsState: DiscussionsState; 38 35 editMode?: boolean; 39 36 element: EmbedElement; 40 37 readOnly?: boolean; 38 + store: BeatmapsetDiscussionsStore; 41 39 } 42 40 41 + @observer 43 42 export default class EditorDiscussionComponent extends React.Component<Props> { 44 43 static contextType = SlateContext; 45 44 ··· 48 47 tooltipContent = React.createRef<HTMLScriptElement>(); 49 48 tooltipEl?: HTMLElement; 50 49 50 + get discussions() { 51 + return this.props.store.discussions; 52 + } 53 + 51 54 componentDidMount = () => { 52 55 // reset timestamp to null on clone 53 56 if (this.editable()) { ··· 169 172 if (this.cache.nearbyDiscussions == null 170 173 || this.cache.nearbyDiscussions.timestamp !== timestamp 171 174 || this.cache.nearbyDiscussions.beatmap_id !== beatmapId) { 172 - const relevantDiscussions = filter(this.props.discussions, this.isRelevantDiscussion); 175 + const relevantDiscussions = [...this.discussions.values()].filter(this.isRelevantDiscussion); 173 176 this.cache.nearbyDiscussions = { 174 177 beatmap_id: beatmapId, 175 178 discussions: nearbyDiscussions(relevantDiscussions, timestamp), ··· 303 306 304 307 const disabled = this.props.readOnly || !canEdit; 305 308 306 - const discussion = this.props.element.discussionId != null ? this.props.discussions[this.props.element.discussionId] : null; 309 + const discussion = this.discussions.get(this.props.element.discussionId); 307 310 const embedMofidiers = discussion != null 308 311 ? postEmbedModifiers(discussion) 309 312 : this.discussionType() === 'praise' ? 'praise' : null; ··· 321 324 className={`${bn}__selectors`} 322 325 contentEditable={false} // workaround for slatejs 'Cannot resolve a Slate point from DOM point' nonsense 323 326 > 324 - <EditorBeatmapSelector {...this.props} disabled={disabled} element={this.props.element} /> 325 - <EditorIssueTypeSelector {...this.props} disabled={disabled} element={this.props.element} /> 327 + <EditorBeatmapSelector beatmaps={this.props.discussionsState.sortedBeatmaps} disabled={disabled} element={this.props.element} /> 328 + <EditorIssueTypeSelector beatmaps={this.props.discussionsState.sortedBeatmaps} disabled={disabled} element={this.props.element} /> 326 329 <div 327 330 className={`${bn}__timestamp`} 328 331 contentEditable={false} // workaround for slatejs 'Cannot resolve a Slate point from DOM point' nonsense
+38 -45
resources/js/beatmap-discussions/editor.tsx
··· 5 5 import { Spinner } from 'components/spinner'; 6 6 import { EmbedElement } from 'editor'; 7 7 import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 8 - import BeatmapsetDiscussionJson, { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; 9 - import BeatmapsetJson from 'interfaces/beatmapset-json'; 8 + import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 9 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 10 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 10 11 import isHotkey from 'is-hotkey'; 11 12 import { route } from 'laroute'; 12 - import { filter } from 'lodash'; 13 + import { observer } from 'mobx-react'; 13 14 import core from 'osu-core-singleton'; 14 15 import * as React from 'react'; 15 16 import { createEditor, Editor as SlateEditor, Element as SlateElement, Node as SlateNode, NodeEntry, Range, Text, Transforms } from 'slate'; ··· 17 18 import { Editable, ReactEditor, RenderElementProps, RenderLeafProps, Slate, withReact } from 'slate-react'; 18 19 import { DOMRange } from 'slate-react/dist/utils/dom'; 19 20 import { onError } from 'utils/ajax'; 20 - import { sortWithMode } from 'utils/beatmap-helper'; 21 21 import { timestampRegex } from 'utils/beatmapset-discussion-helper'; 22 22 import { nominationsCount } from 'utils/beatmapset-helper'; 23 23 import { classWithModifiers } from 'utils/css'; 24 24 import { trans } from 'utils/lang'; 25 + import DiscussionsState from './discussions-state'; 25 26 import { DraftsContext } from './drafts-context'; 26 27 import EditorDiscussionComponent from './editor-discussion-component'; 27 28 import { ··· 45 46 } 46 47 47 48 interface Props { 48 - beatmaps: Partial<Record<number, BeatmapExtendedJson>>; 49 - beatmapset: BeatmapsetJson; 50 - currentBeatmap: BeatmapExtendedJson | null; 51 49 discussion?: BeatmapsetDiscussionJson; 52 - discussions: Partial<Record<number, BeatmapsetDiscussionJsonForBundle | BeatmapsetDiscussionJsonForShow>>; // passed in via context at parent 50 + discussionsState: DiscussionsState; 53 51 document?: string; 54 52 editing: boolean; 55 53 onChange?: () => void; 56 54 onFocus?: () => void; 55 + store: BeatmapsetDiscussionsStore; 57 56 } 58 57 59 58 interface State { ··· 73 72 return block.type === 'embed' && block.discussionId == null; 74 73 } 75 74 75 + @observer 76 76 export default class Editor extends React.Component<Props, State> { 77 77 static contextType = ReviewEditorConfigContext; 78 78 static defaultProps = { ··· 88 88 slateEditor: SlateEditor; 89 89 toolbarRef: React.RefObject<EditorToolbar>; 90 90 private readonly initialValue: SlateElement[] = emptyDocTemplate; 91 - private xhr?: JQueryXHR | null; 91 + private xhr: JQuery.jqXHR<BeatmapsetWithDiscussionsJson> | null = null; 92 + 93 + private get beatmaps() { 94 + return this.props.store.beatmaps; 95 + } 96 + 97 + private get beatmapset() { 98 + return this.props.discussionsState.beatmapset; 99 + } 100 + 101 + private get discussions() { 102 + return this.props.store.discussions; 103 + } 92 104 93 105 private get editMode() { 94 106 return this.props.document != null; ··· 103 115 this.scrollContainerRef = React.createRef(); 104 116 this.toolbarRef = React.createRef(); 105 117 this.insertMenuRef = React.createRef(); 106 - this.localStorageKey = `newDiscussion-${this.props.beatmapset.id}`; 118 + this.localStorageKey = `newDiscussion-${this.beatmapset.id}`; 107 119 108 120 if (this.editMode) { 109 121 this.initialValue = this.valueFromProps(); ··· 184 196 185 197 return ranges; 186 198 }; 187 - 188 - /** 189 - * Type guard for checking if the beatmap is part of currently selected beatmapset 190 - * 191 - * @param beatmap 192 - * @returns boolean 193 - */ 194 - isCurrentBeatmap = (beatmap?: BeatmapExtendedJson): beatmap is BeatmapExtendedJson => ( 195 - beatmap != null && beatmap.beatmapset_id === this.props.beatmapset.id 196 - ); 197 199 198 200 onChange = (value: SlateElement[]) => { 199 201 // Anything that triggers this needs to be fixed! ··· 260 262 261 263 if (this.showConfirmationIfRequired()) { 262 264 this.setState({ posting: true }, () => { 263 - this.xhr = $.ajax(route('beatmapsets.discussion.review', { beatmapset: this.props.beatmapset.id }), { 265 + this.xhr = $.ajax(route('beatmapsets.discussion.review', { beatmapset: this.beatmapset.id }), { 264 266 data: { document: this.serialize() }, 265 267 method: 'POST', 266 - }) 267 - .done((data) => { 268 - $.publish('beatmapsetDiscussions:update', { beatmapset: data }); 268 + }); 269 + 270 + this.xhr 271 + .done((beatmapset) => { 272 + this.props.discussionsState.update({ beatmapset }); 269 273 this.resetInput(); 270 274 }) 271 275 .fail(onError) ··· 298 302 > 299 303 <div ref={this.scrollContainerRef} className={`${editorClass}__input-area`}> 300 304 <EditorToolbar ref={this.toolbarRef} /> 301 - <EditorInsertionMenu ref={this.insertMenuRef} currentBeatmap={this.props.currentBeatmap} /> 305 + <EditorInsertionMenu ref={this.insertMenuRef} currentBeatmap={this.props.discussionsState.currentBeatmap} /> 302 306 <DraftsContext.Provider value={this.cache.draftEmbeds || []}> 303 307 <Editable 304 308 decorate={this.decorateTimestamps} ··· 369 373 const { element, ...otherProps } = props; // spreading ..props doesn't use the narrower type. 370 374 el = ( 371 375 <EditorDiscussionComponent 372 - beatmaps={this.sortedBeatmaps()} 373 - beatmapset={this.props.beatmapset} 374 - discussions={this.props.discussions} 376 + discussionsState={this.props.discussionsState} 375 377 editMode={this.editMode} 376 378 element={element} 377 379 readOnly={this.state.posting} 380 + store={this.props.store} 378 381 {...otherProps} 379 382 /> 380 383 ); ··· 433 436 showConfirmationIfRequired = () => { 434 437 const docContainsProblem = slateDocumentContainsNewProblem(this.state.value); 435 438 const canDisqualify = core.currentUser != null && (core.currentUser.is_admin || core.currentUser.is_moderator || core.currentUser.is_full_bn); 436 - const willDisqualify = this.props.beatmapset.status === 'qualified' && docContainsProblem; 439 + const willDisqualify = this.beatmapset.status === 'qualified' && docContainsProblem; 437 440 const canReset = core.currentUser != null && (core.currentUser.is_admin || core.currentUser.is_nat || core.currentUser.is_bng); 438 441 const willReset = 439 - this.props.beatmapset.status === 'pending' && 440 - this.props.beatmapset.nominations && nominationsCount(this.props.beatmapset.nominations, 'current') > 0 && 441 - docContainsProblem; 442 + this.beatmapset.status === 'pending' 443 + && nominationsCount(this.beatmapset.nominations, 'current') > 0 444 + && docContainsProblem; 442 445 443 446 if (canDisqualify && willDisqualify) { 444 447 return confirm(trans('beatmaps.nominations.reset_confirm.disqualify')); ··· 451 454 return true; 452 455 }; 453 456 454 - sortedBeatmaps = () => { 455 - if (this.cache.sortedBeatmaps == null) { 456 - // filter to only include beatmaps from the current discussion's beatmapset (for the modding profile page) 457 - const beatmaps = filter(this.props.beatmaps, this.isCurrentBeatmap); 458 - this.cache.sortedBeatmaps = sortWithMode(beatmaps); 459 - } 460 - 461 - return this.cache.sortedBeatmaps; 462 - }; 463 - 464 457 updateDrafts = () => { 465 458 this.cache.draftEmbeds = this.state.value.filter(isDraftEmbed); 466 459 }; ··· 500 493 } 501 494 502 495 if (node.beatmapId != null) { 503 - const beatmap = this.props.beatmaps[node.beatmapId]; 496 + const beatmap = this.beatmaps.get(node.beatmapId); 504 497 if (beatmap == null || beatmap.deleted_at != null) { 505 498 Transforms.setNodes(editor, { beatmapId: undefined }, { at: path }); 506 499 } ··· 523 516 } 524 517 525 518 private valueFromProps() { 526 - if (!this.props.editing || this.props.document == null || this.props.discussions == null) { 519 + if (!this.props.editing || this.props.document == null) { 527 520 return []; 528 521 } 529 522 530 - return parseFromJson(this.props.document, this.props.discussions); 523 + return parseFromJson(this.props.document, this.discussions); 531 524 } 532 525 }
+2 -2
resources/js/beatmap-discussions/events.tsx
··· 10 10 import { trans } from 'utils/lang'; 11 11 12 12 interface Props { 13 - discussions: Partial<Record<string, BeatmapsetDiscussionJson>>; 13 + discussions: Map<number | null | undefined, BeatmapsetDiscussionJson>; 14 14 events: BeatmapsetEventJson[]; 15 - users: Partial<Record<string, UserJson>>; 15 + users: Map<number | null | undefined, UserJson>; 16 16 } 17 17 18 18 export class Events extends React.PureComponent<Props> {
+64 -123
resources/js/beatmap-discussions/header.tsx
··· 11 11 import PlaymodeTabs from 'components/playmode-tabs'; 12 12 import StringWithComponent from 'components/string-with-component'; 13 13 import UserLink from 'components/user-link'; 14 - import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 15 - import BeatmapJson from 'interfaces/beatmap-json'; 16 - import { BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; 17 - import BeatmapsetEventJson from 'interfaces/beatmapset-event-json'; 18 - import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 14 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 19 15 import GameMode, { gameModes } from 'interfaces/game-mode'; 20 - import UserJson from 'interfaces/user-json'; 21 16 import { route } from 'laroute'; 22 - import { kebabCase, size, snakeCase } from 'lodash'; 23 - import { deletedUser } from 'models/user'; 24 - import core from 'osu-core-singleton'; 17 + import { action, computed, makeObservable } from 'mobx'; 18 + import { observer } from 'mobx-react'; 19 + import { deletedUserJson } from 'models/user'; 25 20 import * as React from 'react'; 26 - import { makeUrl } from 'utils/beatmapset-discussion-helper'; 27 21 import { getArtist, getTitle } from 'utils/beatmapset-helper'; 28 - import { classWithModifiers } from 'utils/css'; 29 22 import { trans } from 'utils/lang'; 30 23 import BeatmapList from './beatmap-list'; 31 24 import Chart from './chart'; 32 - import CurrentDiscussions, { Filter } from './current-discussions'; 33 - import { DiscussionPage } from './discussion-mode'; 25 + import DiscussionsState from './discussions-state'; 34 26 import { Nominations } from './nominations'; 35 27 import { Subscribe } from './subscribe'; 28 + import TypeFilters from './type-filters'; 36 29 import { UserFilter } from './user-filter'; 37 30 38 31 interface Props { 39 - beatmaps: Map<GameMode, BeatmapExtendedJson[]>; 40 - beatmapset: BeatmapsetWithDiscussionsJson; 41 - currentBeatmap: BeatmapExtendedJson; 42 - currentDiscussions: CurrentDiscussions; 43 - currentFilter: Filter; 44 - discussions: Partial<Record<number, BeatmapsetDiscussionJsonForShow>>; 45 - discussionStarters: UserJson[]; 46 - events: BeatmapsetEventJson[]; 47 - mode: DiscussionPage; 48 - selectedUserId: number | null; 49 - users: Partial<Record<number, UserJson>>; 32 + discussionsState: DiscussionsState; 33 + store: BeatmapsetDiscussionsStore; 50 34 } 51 35 52 - const statTypes: Filter[] = ['mine', 'mapperNotes', 'resolved', 'pending', 'praises', 'deleted', 'total']; 36 + @observer 37 + export class Header extends React.Component<Props> { 38 + private get beatmapset() { 39 + return this.discussionsState.beatmapset; 40 + } 41 + 42 + private get currentBeatmap() { 43 + return this.discussionsState.currentBeatmap; 44 + } 45 + 46 + private get discussionsState() { 47 + return this.props.discussionsState; 48 + } 53 49 54 - export class Header extends React.PureComponent<Props> { 50 + @computed 51 + private get timelineDiscussions() { 52 + return this.discussionsState.discussionsForSelectedUserByMode.timeline; 53 + } 54 + 55 + private get users() { 56 + return this.props.store.users; 57 + } 58 + 59 + constructor(props: Props) { 60 + super(props); 61 + makeObservable(this); 62 + } 63 + 55 64 render() { 56 65 return ( 57 66 <> 58 67 <HeaderV4 59 - links={headerLinks('discussions', this.props.beatmapset)} 68 + links={headerLinks('discussions', this.beatmapset)} 60 69 linksAppend={( 61 70 <PlaymodeTabs 62 - currentMode={this.props.currentBeatmap.mode} 71 + currentMode={this.currentBeatmap.mode} 63 72 entries={gameModes.map((mode) => ({ 64 - count: this.props.currentDiscussions.countsByPlaymode[mode], 65 - disabled: (this.props.beatmaps.get(mode)?.length ?? 0) === 0, 73 + count: this.discussionsState.unresolvedDiscussionCounts.byMode[mode], 74 + disabled: (this.discussionsState.groupedBeatmaps.get(mode)?.length ?? 0) === 0, 66 75 mode, 67 76 }))} 68 77 modifiers='beatmapset' ··· 77 86 ); 78 87 } 79 88 80 - private readonly createLink = (beatmap: BeatmapJson) => makeUrl({ beatmap }); 81 - 82 - private readonly getCount = (beatmap: BeatmapExtendedJson) => 83 - beatmap.deleted_at == null 84 - ? this.props.currentDiscussions.countsByBeatmap[beatmap.id] 85 - : undefined; 86 - 89 + @action 87 90 private readonly onClickMode = (event: React.MouseEvent<HTMLAnchorElement>, mode: GameMode) => { 88 91 event.preventDefault(); 89 - $.publish('playmode:set', [{ mode }]); 90 - }; 91 - 92 - private readonly onSelectBeatmap = (beatmapId: number) => { 93 - $.publish('beatmapsetDiscussions:update', { 94 - beatmapId, 95 - mode: 'timeline', 96 - }); 92 + this.discussionsState.changeGameMode(mode); 97 93 }; 98 94 99 95 private renderHeaderBottom() { ··· 104 100 <div className={`${bn}__content ${bn}__content--details`}> 105 101 <div className={`${bn}__details ${bn}__details--full`}> 106 102 <BeatmapsetMapping 107 - beatmapset={this.props.beatmapset} 108 - user={this.props.users[this.props.beatmapset.user_id]} 103 + beatmapset={this.beatmapset} 104 + user={this.users.get(this.beatmapset.user_id)} 109 105 /> 110 106 </div> 111 107 <div className={`${bn}__details`}> 112 - <Subscribe beatmapset={this.props.beatmapset} /> 108 + <Subscribe beatmapset={this.beatmapset} discussionsState={this.discussionsState} /> 113 109 </div> 114 110 <div className={`${bn}__details`}> 115 111 <BigButton 116 - href={route('beatmapsets.show', { beatmapset: this.props.beatmapset.id })} 112 + href={route('beatmapsets.show', { beatmapset: this.beatmapset.id })} 117 113 icon='fas fa-info' 118 114 modifiers='full' 119 115 text={trans('beatmaps.discussions.beatmap_information')} ··· 122 118 </div> 123 119 <div className={`${bn}__content ${bn}__content--nomination`}> 124 120 <Nominations 125 - beatmapset={this.props.beatmapset} 126 - currentDiscussions={this.props.currentDiscussions} 127 - discussions={this.props.discussions} 128 - events={this.props.events} 129 - users={this.props.users} 121 + discussionsState={this.discussionsState} 122 + store={this.props.store} 130 123 /> 131 124 </div> 132 125 </div> ··· 142 135 <div className={`${bn}__content`}> 143 136 <div className={`${bn}__cover`}> 144 137 <BeatmapsetCover 145 - beatmapset={this.props.beatmapset} 138 + beatmapset={this.beatmapset} 146 139 modifiers='full' 147 140 size='cover' 148 141 /> ··· 151 144 <h1 className={`${bn}__title`}> 152 145 <a 153 146 className='link link--white link--no-underline' 154 - href={route('beatmapsets.show', { beatmapset: this.props.beatmapset.id })} 147 + href={route('beatmapsets.show', { beatmapset: this.beatmapset.id })} 155 148 > 156 - {getTitle(this.props.beatmapset)} 149 + {getTitle(this.beatmapset)} 157 150 </a> 158 - <BeatmapsetBadge beatmapset={this.props.beatmapset} type='nsfw' /> 159 - <BeatmapsetBadge beatmapset={this.props.beatmapset} type='spotlight' /> 151 + <BeatmapsetBadge beatmapset={this.beatmapset} type='nsfw' /> 152 + <BeatmapsetBadge beatmapset={this.beatmapset} type='spotlight' /> 160 153 </h1> 161 154 <h2 className={`${bn}__title ${bn}__title--artist`}> 162 - {getArtist(this.props.beatmapset)} 163 - <BeatmapsetBadge beatmapset={this.props.beatmapset} type='featured_artist' /> 155 + {getArtist(this.beatmapset)} 156 + <BeatmapsetBadge beatmapset={this.beatmapset} type='featured_artist' /> 164 157 </h2> 165 158 </div> 166 159 <div className={`${bn}__filters`}> 167 160 <div className={`${bn}__filter-group`}> 168 161 <BeatmapList 169 - beatmaps={this.props.beatmaps.get(this.props.currentBeatmap.mode) ?? []} 170 - beatmapset={this.props.beatmapset} 171 - createLink={this.createLink} 172 - currentBeatmap={this.props.currentBeatmap} 173 - getCount={this.getCount} 174 - onSelectBeatmap={this.onSelectBeatmap} 175 - users={this.props.users} 162 + discussionsState={this.discussionsState} 163 + users={this.users} 176 164 /> 177 165 </div> 178 166 <div className={`${bn}__filter-group ${bn}__filter-group--stats`}> 179 167 <UserFilter 180 - ownerId={this.props.beatmapset.user_id} 181 - selectedUser={this.props.selectedUserId != null ? this.props.users[this.props.selectedUserId] : null} 182 - users={this.props.discussionStarters} 168 + discussionsState={this.discussionsState} 169 + store={this.props.store} 183 170 /> 184 171 <div className={`${bn}__stats`}> 185 - {statTypes.map(this.renderType)} 172 + <TypeFilters discussionsState={this.discussionsState} /> 186 173 </div> 187 174 </div> 188 175 </div> 189 176 <div className='u-relative'> 190 177 <Chart 191 - discussions={this.props.currentDiscussions.byFilter[this.props.currentFilter].timeline} 192 - duration={this.props.currentBeatmap.total_length * 1000} 178 + discussions={this.timelineDiscussions} 179 + duration={this.currentBeatmap.total_length * 1000} 193 180 /> 194 181 <div className={`${bn}__beatmap-stats`}> 195 182 <div className={`${bn}__guest`}> 196 - {this.props.currentBeatmap.user_id !== this.props.beatmapset.user_id && ( 183 + {this.currentBeatmap.user_id !== this.beatmapset.user_id && ( 197 184 <span> 198 185 <StringWithComponent 199 186 mappings={{ 200 - user: <UserLink user={this.props.users[this.props.currentBeatmap.user_id] ?? deletedUser} />, 187 + user: <UserLink user={this.users.get(this.currentBeatmap.user_id) ?? deletedUserJson} />, 201 188 }} 202 189 pattern={trans('beatmaps.discussions.guest')} 203 190 /> 204 191 </span> 205 192 )} 206 193 </div> 207 - <BeatmapBasicStats beatmap={this.props.currentBeatmap} beatmapset={this.props.beatmapset} /> 194 + <BeatmapBasicStats beatmap={this.currentBeatmap} beatmapset={this.beatmapset} /> 208 195 </div> 209 196 </div> 210 197 </div> 211 198 </div> 212 199 ); 213 200 } 214 - 215 - private readonly renderType = (type: Filter) => { 216 - if ((type === 'deleted') && !core.currentUser?.is_admin) { 217 - return null; 218 - } 219 - 220 - const bn = 'counter-box'; 221 - 222 - let topClasses = classWithModifiers(bn, 'beatmap-discussions', kebabCase(type)); 223 - if (this.props.mode !== 'events' && this.props.currentFilter === type) { 224 - topClasses += ' js-active'; 225 - } 226 - 227 - const discussionsByFilter = this.props.currentDiscussions.byFilter[type]; 228 - const total = Object.values(discussionsByFilter).reduce((acc, discussions) => acc + size(discussions), 0); 229 - 230 - return ( 231 - <a 232 - key={type} 233 - className={topClasses} 234 - data-type={type} 235 - href={makeUrl({ 236 - beatmapId: this.props.currentBeatmap.id, 237 - beatmapsetId: this.props.beatmapset.id, 238 - filter: type, 239 - mode: this.props.mode, 240 - })} 241 - onClick={this.setFilter} 242 - > 243 - <div className={`${bn}__content`}> 244 - <div className={`${bn}__title`}> 245 - {trans(`beatmaps.discussions.stats.${snakeCase(type)}`)} 246 - </div> 247 - <div className={`${bn}__count`}> 248 - {total} 249 - </div> 250 - </div> 251 - <div className={`${bn}__line`} /> 252 - </a> 253 - ); 254 - }; 255 - 256 - private readonly setFilter = (event: React.SyntheticEvent<HTMLElement>) => { 257 - event.preventDefault(); 258 - $.publish('beatmapsetDiscussions:update', { filter: event.currentTarget.dataset.type }); 259 - }; 260 201 }
+3 -1
resources/js/beatmap-discussions/love-beatmap-dialog.tsx
··· 13 13 import { group as groupBeatmaps } from 'utils/beatmap-helper'; 14 14 import { trans } from 'utils/lang'; 15 15 import { hideLoadingOverlay, showImmediateLoadingOverlay } from 'utils/loading-overlay'; 16 + import DiscussionsState from './discussions-state'; 16 17 17 18 interface Props { 18 19 beatmapset: BeatmapsetExtendedJson & Required<Pick<BeatmapsetExtendedJson, 'beatmaps'>>; 20 + discussionsState: DiscussionsState; 19 21 onClose: () => void; 20 22 } 21 23 ··· 128 130 129 131 this.xhr = $.ajax(url, params); 130 132 this.xhr.done((beatmapset) => { 131 - $.publish('beatmapsetDiscussions:update', { beatmapset }); 133 + this.props.discussionsState.update({ beatmapset }); 132 134 this.props.onClose(); 133 135 }).fail(onError) 134 136 .always(action(() => {
-540
resources/js/beatmap-discussions/main.coffee
··· 1 - # Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - # See the LICENCE file in the repository root for full licence text. 3 - 4 - import { DiscussionsContext } from 'beatmap-discussions/discussions-context' 5 - import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context' 6 - import NewReview from 'beatmap-discussions/new-review' 7 - import { ReviewEditorConfigContext } from 'beatmap-discussions/review-editor-config-context' 8 - import BackToTop from 'components/back-to-top' 9 - import { route } from 'laroute' 10 - import { deletedUserJson } from 'models/user' 11 - import core from 'osu-core-singleton' 12 - import * as React from 'react' 13 - import { div } from 'react-dom-factories' 14 - import * as BeatmapHelper from 'utils/beatmap-helper' 15 - import { defaultFilter, defaultMode, makeUrl, parseUrl, stateFromDiscussion } from 'utils/beatmapset-discussion-helper' 16 - import { nextVal } from 'utils/seq' 17 - import { currentUrl } from 'utils/turbolinks' 18 - import { Discussions } from './discussions' 19 - import { Events } from './events' 20 - import { Header } from './header' 21 - import { ModeSwitcher } from './mode-switcher' 22 - import { NewDiscussion } from './new-discussion' 23 - 24 - el = React.createElement 25 - 26 - export class Main extends React.PureComponent 27 - constructor: (props) -> 28 - super props 29 - 30 - @eventId = "beatmap-discussions-#{nextVal()}" 31 - @modeSwitcherRef = React.createRef() 32 - @newDiscussionRef = React.createRef() 33 - 34 - @checkNewTimeoutDefault = 10000 35 - @checkNewTimeoutMax = 60000 36 - @cache = {} 37 - @disposers = new Set 38 - @timeouts = {} 39 - @xhr = {} 40 - @state = JSON.parse(props.container.dataset.beatmapsetDiscussionState ? null) 41 - @restoredState = @state? 42 - 43 - if @restoredState 44 - @state.readPostIds = new Set(@state.readPostIdsArray) 45 - else 46 - beatmapset = props.initial.beatmapset 47 - reviewsConfig = props.initial.reviews_config 48 - showDeleted = true 49 - readPostIds = new Set 50 - 51 - for discussion in beatmapset.discussions 52 - for post in discussion?.posts ? [] 53 - readPostIds.add(post.id) if post? 54 - 55 - @state = {beatmapset, currentUser, readPostIds, reviewsConfig, showDeleted} 56 - 57 - @state.pinnedNewDiscussion ?= false 58 - 59 - # Current url takes priority over saved state. 60 - query = @queryFromLocation(@state.beatmapset.discussions) 61 - @state.currentMode = query.mode 62 - @state.currentFilter = query.filter 63 - @state.currentBeatmapId = query.beatmapId if query.beatmapId? 64 - @state.selectedUserId = query.user 65 - # FIXME: update url handler to recognize this instead 66 - @focusNewDiscussion = currentUrl().hash == '#new' 67 - 68 - 69 - componentDidMount: => 70 - @focusNewDiscussion = false 71 - $.subscribe "playmode:set.#{@eventId}", @setCurrentPlaymode 72 - 73 - $.subscribe "beatmapsetDiscussions:update.#{@eventId}", @update 74 - $.subscribe "beatmapDiscussion:jump.#{@eventId}", @jumpTo 75 - $.subscribe "beatmapDiscussionPost:markRead.#{@eventId}", @markPostRead 76 - $.subscribe "beatmapDiscussionPost:toggleShowDeleted.#{@eventId}", @toggleShowDeleted 77 - 78 - $(document).on "ajax:success.#{@eventId}", '.js-beatmapset-discussion-update', @ujsDiscussionUpdate 79 - $(document).on "click.#{@eventId}", '.js-beatmap-discussion--jump', @jumpToClick 80 - $(document).on "turbolinks:before-cache.#{@eventId}", @saveStateToContainer 81 - 82 - if !@restoredState 83 - @disposers.add core.reactTurbolinks.runAfterPageLoad(@jumpToDiscussionByHash) 84 - 85 - @timeouts.checkNew = Timeout.set @checkNewTimeoutDefault, @checkNew 86 - 87 - 88 - componentDidUpdate: (_prevProps, prevState) => 89 - return if prevState.currentBeatmapId == @state.currentBeatmapId && 90 - prevState.currentFilter == @state.currentFilter && 91 - prevState.currentMode == @state.currentMode && 92 - prevState.selectedUserId == @state.selectedUserId && 93 - prevState.showDeleted == @state.showDeleted 94 - 95 - Turbolinks.controller.advanceHistory @urlFromState() 96 - 97 - 98 - componentWillUnmount: => 99 - $.unsubscribe ".#{@eventId}" 100 - $(document).off ".#{@eventId}" 101 - 102 - document.documentElement.style.removeProperty '--scroll-padding-top-extra' 103 - 104 - Timeout.clear(timeout) for _name, timeout of @timeouts 105 - xhr?.abort() for _name, xhr of @xhr 106 - @disposers.forEach (disposer) => disposer?() 107 - 108 - 109 - render: => 110 - @cache = {} 111 - 112 - el React.Fragment, null, 113 - el Header, 114 - beatmaps: @groupedBeatmaps() 115 - beatmapset: @state.beatmapset 116 - currentBeatmap: @currentBeatmap() 117 - currentDiscussions: @currentDiscussions() 118 - currentFilter: @state.currentFilter 119 - currentUser: @state.currentUser 120 - discussions: @discussions() 121 - discussionStarters: @discussionStarters() 122 - events: @state.beatmapset.events 123 - mode: @state.currentMode 124 - selectedUserId: @state.selectedUserId 125 - users: @users() 126 - 127 - el ModeSwitcher, 128 - innerRef: @modeSwitcherRef 129 - mode: @state.currentMode 130 - beatmapset: @state.beatmapset 131 - currentBeatmap: @currentBeatmap() 132 - currentDiscussions: @currentDiscussions() 133 - currentFilter: @state.currentFilter 134 - 135 - if @state.currentMode == 'events' 136 - el Events, 137 - events: @state.beatmapset.events 138 - users: @users() 139 - discussions: @discussions() 140 - 141 - else 142 - el DiscussionsContext.Provider, 143 - value: @discussions() 144 - el BeatmapsContext.Provider, 145 - value: @beatmaps() 146 - el ReviewEditorConfigContext.Provider, 147 - value: @state.reviewsConfig 148 - 149 - if @state.currentMode == 'reviews' 150 - el NewReview, 151 - beatmapset: @state.beatmapset 152 - beatmaps: @beatmaps() 153 - currentBeatmap: @currentBeatmap() 154 - currentUser: @state.currentUser 155 - innerRef: @newDiscussionRef 156 - onFocus: @handleNewDiscussionFocus 157 - pinned: @state.pinnedNewDiscussion 158 - setPinned: @setPinnedNewDiscussion 159 - stickTo: @modeSwitcherRef 160 - else 161 - el NewDiscussion, 162 - beatmapset: @state.beatmapset 163 - currentUser: @state.currentUser 164 - currentBeatmap: @currentBeatmap() 165 - currentDiscussions: @currentDiscussions() 166 - innerRef: @newDiscussionRef 167 - mode: @state.currentMode 168 - onFocus: @handleNewDiscussionFocus 169 - pinned: @state.pinnedNewDiscussion 170 - setPinned: @setPinnedNewDiscussion 171 - stickTo: @modeSwitcherRef 172 - autoFocus: @focusNewDiscussion 173 - 174 - el Discussions, 175 - beatmapset: @state.beatmapset 176 - currentBeatmap: @currentBeatmap() 177 - currentDiscussions: @currentDiscussions() 178 - currentFilter: @state.currentFilter 179 - currentUser: @state.currentUser 180 - mode: @state.currentMode 181 - readPostIds: @state.readPostIds 182 - showDeleted: @state.showDeleted 183 - users: @users() 184 - 185 - el BackToTop 186 - 187 - 188 - beatmaps: => 189 - return @cache.beatmaps if @cache.beatmaps? 190 - 191 - hasDiscussion = {} 192 - for discussion in @state.beatmapset.discussions 193 - hasDiscussion[discussion.beatmap_id] = true if discussion? 194 - 195 - @cache.beatmaps ?= 196 - _(@state.beatmapset.beatmaps) 197 - .filter (beatmap) -> 198 - !_.isEmpty(beatmap) && (!beatmap.deleted_at? || hasDiscussion[beatmap.id]?) 199 - .keyBy 'id' 200 - .value() 201 - 202 - 203 - checkNew: => 204 - @nextTimeout ?= @checkNewTimeoutDefault 205 - 206 - Timeout.clear @timeouts.checkNew 207 - @xhr.checkNew?.abort() 208 - 209 - @xhr.checkNew = $.get route('beatmapsets.discussion', beatmapset: @state.beatmapset.id), 210 - format: 'json' 211 - last_updated: @lastUpdate()?.unix() 212 - .done (data, _textStatus, xhr) => 213 - if xhr.status == 304 214 - @nextTimeout *= 2 215 - return 216 - 217 - @nextTimeout = @checkNewTimeoutDefault 218 - 219 - @update null, beatmapset: data.beatmapset 220 - 221 - .always => 222 - @nextTimeout = Math.min @nextTimeout, @checkNewTimeoutMax 223 - 224 - @timeouts.checkNew = Timeout.set @nextTimeout, @checkNew 225 - 226 - 227 - currentBeatmap: => 228 - @beatmaps()[@state.currentBeatmapId] ? BeatmapHelper.findDefault(group: @groupedBeatmaps()) 229 - 230 - 231 - currentDiscussions: => 232 - return @cache.currentDiscussions if @cache.currentDiscussions? 233 - 234 - countsByBeatmap = {} 235 - countsByPlaymode = {} 236 - totalHype = 0 237 - unresolvedIssues = 0 238 - byMode = 239 - timeline: [] 240 - general: [] 241 - generalAll: [] 242 - reviews: [] 243 - byFilter = 244 - deleted: {} 245 - hype: {} 246 - mapperNotes: {} 247 - mine: {} 248 - pending: {} 249 - praises: {} 250 - resolved: {} 251 - total: {} 252 - timelineAllUsers = [] 253 - 254 - for own mode, _items of byMode 255 - for own _filter, modes of byFilter 256 - modes[mode] = {} 257 - 258 - for own _id, d of @discussions() 259 - if !d.deleted_at? 260 - totalHype++ if d.message_type == 'hype' 261 - 262 - if d.can_be_resolved && !d.resolved 263 - beatmap = @beatmaps()[d.beatmap_id] 264 - 265 - if !d.beatmap_id? || (beatmap? && !beatmap.deleted_at?) 266 - unresolvedIssues++ 267 - 268 - if beatmap? 269 - countsByBeatmap[beatmap.id] ?= 0 270 - countsByBeatmap[beatmap.id]++ 271 - 272 - if !beatmap.deleted_at? 273 - countsByPlaymode[beatmap.mode] ?= 0 274 - countsByPlaymode[beatmap.mode]++ 275 - 276 - if d.message_type == 'review' 277 - mode = 'reviews' 278 - else 279 - if d.beatmap_id? 280 - if d.beatmap_id == @currentBeatmap().id 281 - if d.timestamp? 282 - mode = 'timeline' 283 - timelineAllUsers.push d 284 - else 285 - mode = 'general' 286 - else 287 - mode = null 288 - else 289 - mode = 'generalAll' 290 - 291 - # belongs to different beatmap, excluded 292 - continue unless mode? 293 - 294 - # skip if filtering users 295 - continue if @state.selectedUserId? && d.user_id != @state.selectedUserId 296 - 297 - filters = total: true 298 - 299 - if d.deleted_at? 300 - filters.deleted = true 301 - else if d.message_type == 'hype' 302 - filters.hype = true 303 - filters.praises = true 304 - else if d.message_type == 'praise' 305 - filters.praises = true 306 - else if d.can_be_resolved 307 - if d.resolved 308 - filters.resolved = true 309 - else 310 - filters.pending = true 311 - 312 - if d.user_id == @state.currentUser.id 313 - filters.mine = true 314 - 315 - if d.message_type == 'mapper_note' 316 - filters.mapperNotes = true 317 - 318 - # the value should always be true 319 - for own filter, _isSet of filters 320 - byFilter[filter][mode][d.id] = d 321 - 322 - if filters.pending && d.parent_id? 323 - parentDiscussion = @discussions()[d.parent_id] 324 - 325 - if parentDiscussion? && parentDiscussion.message_type == 'review' 326 - byFilter.pending.reviews[parentDiscussion.id] = parentDiscussion 327 - 328 - byMode[mode].push d 329 - 330 - timeline = byMode.timeline 331 - general = byMode.general 332 - generalAll = byMode.generalAll 333 - reviews = byMode.reviews 334 - 335 - @cache.currentDiscussions = {general, generalAll, timeline, reviews, timelineAllUsers, byFilter, countsByBeatmap, countsByPlaymode, totalHype, unresolvedIssues} 336 - 337 - 338 - discussions: => 339 - # skipped discussions 340 - # - not privileged (deleted discussion) 341 - # - deleted beatmap 342 - @cache.discussions ?= _ @state.beatmapset.discussions 343 - .filter (d) -> !_.isEmpty(d) 344 - .keyBy 'id' 345 - .value() 346 - 347 - 348 - discussionStarters: => 349 - _ @discussions() 350 - .filter (discussion) -> discussion.message_type != 'hype' 351 - .map 'user_id' 352 - .uniq() 353 - .map (user_id) => @users()[user_id] 354 - .orderBy (user) -> user.username.toLocaleLowerCase() 355 - .value() 356 - 357 - 358 - groupedBeatmaps: (discussionSet) => 359 - @cache.groupedBeatmaps ?= BeatmapHelper.group _.values(@beatmaps()) 360 - 361 - 362 - handleNewDiscussionFocus: => 363 - # Bug with position: sticky and scroll-padding: https://bugs.chromium.org/p/chromium/issues/detail?id=1466472 364 - document.documentElement.style.removeProperty '--scroll-padding-top-extra' 365 - 366 - 367 - jumpToDiscussionByHash: => 368 - target = parseUrl(null, @state.beatmapset.discussions) 369 - 370 - @jumpTo(null, id: target.discussionId, postId: target.postId) if target.discussionId? 371 - 372 - 373 - jumpTo: (_e, {id, postId}) => 374 - discussion = @discussions()[id] 375 - 376 - return if !discussion? 377 - 378 - newState = stateFromDiscussion(discussion) 379 - 380 - newState.filter = 381 - if @currentDiscussions().byFilter[@state.currentFilter][newState.mode][id]? 382 - @state.currentFilter 383 - else 384 - defaultFilter 385 - 386 - if @state.selectedUserId? && @state.selectedUserId != discussion.user_id 387 - newState.selectedUserId = null 388 - 389 - newState.callback = => 390 - $.publish 'beatmapset-discussions:highlight', discussionId: discussion.id 391 - 392 - attribute = if postId? then "data-post-id='#{postId}'" else "data-id='#{id}'" 393 - target = document.querySelector(".js-beatmap-discussion-jump[#{attribute}]") 394 - 395 - return unless target? && @modeSwitcherRef.current? && @newDiscussionRef.current? 396 - 397 - margin = @modeSwitcherRef.current.getBoundingClientRect().height 398 - margin += @newDiscussionRef.current.getBoundingClientRect().height if @state.pinnedNewDiscussion 399 - 400 - # Update scroll-padding instead of adding scroll-margin, otherwise it doesn't anchor in the right place. 401 - document.documentElement.style.setProperty '--scroll-padding-top-extra', "#{Math.floor(margin)}px" 402 - 403 - # avoid smooth scrolling to avoid triggering lazy loaded images. 404 - # FIXME: Safari still has the issue where images just out of view get loaded and push the page down 405 - # because it doesn't anchor the scroll position. 406 - target.scrollIntoView behavior: 'instant', block: 'start', inline: 'nearest' 407 - 408 - @update null, newState 409 - 410 - 411 - jumpToClick: (e) => 412 - url = e.currentTarget.getAttribute('href') 413 - { discussionId, postId } = parseUrl(url, @state.beatmapset.discussions) 414 - 415 - return if !discussionId? 416 - 417 - e.preventDefault() 418 - @jumpTo null, { id: discussionId, postId } 419 - 420 - 421 - lastUpdate: => 422 - lastUpdate = _.max [ 423 - @state.beatmapset.last_updated 424 - _.maxBy(@state.beatmapset.discussions, 'updated_at')?.updated_at 425 - _.maxBy(@state.beatmapset.events, 'created_at')?.created_at 426 - ] 427 - 428 - moment(lastUpdate) if lastUpdate? 429 - 430 - 431 - markPostRead: (_e, {id}) => 432 - return if @state.readPostIds.has(id) 433 - 434 - newSet = new Set(@state.readPostIds) 435 - if Array.isArray(id) 436 - newSet.add(i) for i in id 437 - else 438 - newSet.add(id) 439 - 440 - @setState readPostIds: newSet 441 - 442 - 443 - queryFromLocation: (discussions = @state.beatmapsetDiscussion.beatmap_discussions) => 444 - parseUrl(null, discussions) 445 - 446 - 447 - saveStateToContainer: => 448 - # This is only so it can be stored with JSON.stringify. 449 - @state.readPostIdsArray = Array.from(@state.readPostIds) 450 - @props.container.dataset.beatmapsetDiscussionState = JSON.stringify(@state) 451 - 452 - 453 - setCurrentPlaymode: (e, {mode}) => 454 - @update e, playmode: mode 455 - 456 - 457 - setPinnedNewDiscussion: (pinned) => 458 - @setState pinnedNewDiscussion: pinned 459 - 460 - 461 - toggleShowDeleted: => 462 - @setState showDeleted: !@state.showDeleted 463 - 464 - 465 - update: (_e, options) => 466 - { 467 - callback 468 - mode 469 - modeIf 470 - beatmapId 471 - playmode 472 - beatmapset 473 - watching 474 - filter 475 - selectedUserId 476 - } = options 477 - newState = {} 478 - 479 - if beatmapset? 480 - newState.beatmapset = beatmapset 481 - 482 - if watching? 483 - newState.beatmapset ?= _.assign {}, @state.beatmapset 484 - newState.beatmapset.current_user_attributes.is_watching = watching 485 - 486 - if playmode? 487 - beatmap = BeatmapHelper.findDefault items: @groupedBeatmaps().get(playmode) 488 - beatmapId = beatmap?.id 489 - 490 - if beatmapId? && beatmapId != @currentBeatmap().id 491 - newState.currentBeatmapId = beatmapId 492 - 493 - if filter? 494 - if @state.currentMode == 'events' 495 - newState.currentMode = @lastMode ? defaultMode(newState.currentBeatmapId) 496 - 497 - if filter != @state.currentFilter 498 - newState.currentFilter = filter 499 - 500 - if mode? && mode != @state.currentMode 501 - if !modeIf? || modeIf == @state.currentMode 502 - newState.currentMode = mode 503 - 504 - # switching to events: 505 - # - record last filter, to be restored when setMode is called 506 - # - record last mode, to be restored when setFilter is called 507 - # - set filter to total 508 - if mode == 'events' 509 - @lastMode = @state.currentMode 510 - @lastFilter = @state.currentFilter 511 - newState.currentFilter = 'total' 512 - # switching from events: 513 - # - restore whatever last filter set or default to total 514 - else if @state.currentMode == 'events' 515 - newState.currentFilter = @lastFilter ? 'total' 516 - 517 - newState.selectedUserId = selectedUserId if selectedUserId != undefined # need to setState if null 518 - 519 - @setState newState, callback 520 - 521 - 522 - urlFromState: => 523 - makeUrl 524 - beatmap: @currentBeatmap() 525 - mode: @state.currentMode 526 - filter: @state.currentFilter 527 - user: @state.selectedUserId 528 - 529 - 530 - users: => 531 - if !@cache.users? 532 - @cache.users = _.keyBy @state.beatmapset.related_users, 'id' 533 - @cache.users[null] = @cache.users[undefined] = deletedUserJson 534 - 535 - @cache.users 536 - 537 - 538 - ujsDiscussionUpdate: (_e, data) => 539 - # to allow ajax:complete to be run 540 - Timeout.set 0, => @update(null, beatmapset: data)
+256
resources/js/beatmap-discussions/main.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import NewReview from 'beatmap-discussions/new-review'; 5 + import { ReviewEditorConfigContext } from 'beatmap-discussions/review-editor-config-context'; 6 + import BackToTop from 'components/back-to-top'; 7 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 8 + import { route } from 'laroute'; 9 + import { action, makeObservable, observable, toJS } from 'mobx'; 10 + import { observer } from 'mobx-react'; 11 + import core from 'osu-core-singleton'; 12 + import * as React from 'react'; 13 + import BeatmapsetDiscussionsShowStore from 'stores/beatmapset-discussions-show-store'; 14 + import { defaultFilter, parseUrl, stateFromDiscussion } from 'utils/beatmapset-discussion-helper'; 15 + import { parseJson, storeJson } from 'utils/json'; 16 + import { nextVal } from 'utils/seq'; 17 + import { currentUrl } from 'utils/turbolinks'; 18 + import { Discussions } from './discussions'; 19 + import DiscussionsState from './discussions-state'; 20 + import { Events } from './events'; 21 + import { Header } from './header'; 22 + import { ModeSwitcher } from './mode-switcher'; 23 + import { NewDiscussion } from './new-discussion'; 24 + 25 + const checkNewTimeoutDefault = 10000; 26 + const checkNewTimeoutMax = 60000; 27 + const beatmapsetJsonId = 'json-beatmapset'; 28 + 29 + interface Props { 30 + reviewsConfig: { 31 + max_blocks: number; 32 + }; 33 + } 34 + 35 + interface UpdateResponseJson { 36 + beatmapset: BeatmapsetWithDiscussionsJson; 37 + } 38 + 39 + @observer 40 + export default class Main extends React.Component<Props> { 41 + @observable private readonly discussionsState: DiscussionsState; 42 + private readonly disposers = new Set<((() => void) | undefined)>(); 43 + private readonly eventId = `beatmap-discussions-${nextVal()}`; 44 + // FIXME: update url handler to recognize this instead 45 + private readonly focusNewDiscussion = currentUrl().hash === '#new'; 46 + private readonly modeSwitcherRef = React.createRef<HTMLDivElement>(); 47 + private readonly newDiscussionRef = React.createRef<HTMLDivElement>(); 48 + private nextTimeout = checkNewTimeoutDefault; 49 + @observable private readonly store; 50 + private timeoutCheckNew?: number; 51 + private xhrCheckNew?: JQuery.jqXHR<UpdateResponseJson>; 52 + 53 + constructor(props: Props) { 54 + super(props); 55 + 56 + // TODO: avoid reparsing/loading everything on browser navigation for better performance. 57 + const beatmapset = parseJson<BeatmapsetWithDiscussionsJson>(beatmapsetJsonId); 58 + 59 + this.store = new BeatmapsetDiscussionsShowStore(beatmapset); 60 + this.discussionsState = new DiscussionsState(this.store); 61 + 62 + makeObservable(this); 63 + } 64 + 65 + componentDidMount() { 66 + $(document).on(`ajax:success.${this.eventId}`, '.js-beatmapset-discussion-update', this.ujsDiscussionUpdate); 67 + $(document).on(`click.${this.eventId}`, '.js-beatmap-discussion--jump', this.jumpToClick); 68 + document.addEventListener('turbolinks:before-cache', this.destroy); 69 + 70 + if (this.discussionsState.jumpToDiscussion) { 71 + this.disposers.add(core.reactTurbolinks.runAfterPageLoad(this.jumpToDiscussionByHash)); 72 + } 73 + 74 + this.timeoutCheckNew = window.setTimeout(this.checkNew, checkNewTimeoutDefault); 75 + } 76 + 77 + componentWillUnmount() { 78 + $.unsubscribe(`.${this.eventId}`); 79 + $(document).off(`.${this.eventId}`); 80 + } 81 + 82 + render() { 83 + return ( 84 + <> 85 + <Header 86 + discussionsState={this.discussionsState} 87 + store={this.store} 88 + /> 89 + <ModeSwitcher 90 + discussionsState={this.discussionsState} 91 + innerRef={this.modeSwitcherRef} 92 + /> 93 + {this.discussionsState.currentPage === 'events' ? ( 94 + <Events 95 + discussions={this.store.discussions} 96 + events={this.discussionsState.beatmapset.events} 97 + users={this.store.users} 98 + /> 99 + ) : ( 100 + <ReviewEditorConfigContext.Provider value={this.props.reviewsConfig}> 101 + {this.discussionsState.currentPage === 'reviews' ? ( 102 + <NewReview 103 + discussionsState={this.discussionsState} 104 + innerRef={this.newDiscussionRef} 105 + onFocus={this.handleNewDiscussionFocus} 106 + stickTo={this.modeSwitcherRef} 107 + store={this.store} 108 + /> 109 + ) : ( 110 + <NewDiscussion 111 + autoFocus={this.focusNewDiscussion} 112 + discussionsState={this.discussionsState} 113 + innerRef={this.newDiscussionRef} 114 + onFocus={this.handleNewDiscussionFocus} 115 + stickTo={this.modeSwitcherRef} 116 + 117 + /> 118 + )} 119 + <Discussions 120 + discussionsState={this.discussionsState} 121 + store={this.store} 122 + /> 123 + </ReviewEditorConfigContext.Provider> 124 + )} 125 + <BackToTop /> 126 + </> 127 + ); 128 + } 129 + 130 + @action 131 + private readonly checkNew = () => { 132 + if (this.xhrCheckNew != null) return; 133 + 134 + window.clearTimeout(this.timeoutCheckNew); 135 + 136 + this.xhrCheckNew = $.get(route('beatmapsets.discussion', { beatmapset: this.discussionsState.beatmapset.id }), { 137 + format: 'json', 138 + last_updated: this.discussionsState.lastUpdate, 139 + }); 140 + 141 + this.xhrCheckNew.done((data, _textStatus, xhr) => { 142 + if (xhr.status === 304) { 143 + this.nextTimeout *= 2; 144 + return; 145 + } 146 + 147 + this.nextTimeout = checkNewTimeoutDefault; 148 + this.discussionsState.update({ beatmapset: data.beatmapset }); 149 + }).always(() => { 150 + this.nextTimeout = Math.min(this.nextTimeout, checkNewTimeoutMax); 151 + 152 + this.timeoutCheckNew = window.setTimeout(this.checkNew, this.nextTimeout); 153 + this.xhrCheckNew = undefined; 154 + }); 155 + }; 156 + 157 + private readonly destroy = () => { 158 + document.removeEventListener('turbolinks:before-cache', this.destroy); 159 + 160 + document.documentElement.style.removeProperty('--scroll-padding-top-extra'); 161 + window.clearTimeout(this.timeoutCheckNew); 162 + this.xhrCheckNew?.abort(); 163 + 164 + storeJson(beatmapsetJsonId, toJS(this.store.beatmapset)); 165 + this.discussionsState.saveState(); 166 + 167 + this.disposers.forEach((disposer) => disposer?.()); 168 + this.discussionsState.destroy(); 169 + }; 170 + 171 + private readonly handleNewDiscussionFocus = () => { 172 + // Bug with position: sticky and scroll-padding: https://bugs.chromium.org/p/chromium/issues/detail?id=1466472 173 + document.documentElement.style.removeProperty('--scroll-padding-top-extra'); 174 + }; 175 + 176 + @action 177 + private jumpTo(id: number, postId?: number) { 178 + const discussion = this.store.discussions.get(id); 179 + 180 + if (discussion == null) return; 181 + 182 + const { 183 + beatmapId, 184 + mode, 185 + } = stateFromDiscussion(discussion); 186 + 187 + // unset filter 188 + const currentDiscussionsByMode = this.discussionsState.discussionsByMode[mode]; 189 + if (currentDiscussionsByMode.find((d) => d.id === discussion.id) == null) { 190 + this.discussionsState.currentFilter = defaultFilter; 191 + } 192 + 193 + // unset user filter if new discussion would have been filtered out. 194 + if (this.discussionsState.selectedUserId != null && this.discussionsState.selectedUserId !== discussion.user_id) { 195 + this.discussionsState.selectedUserId = null; 196 + } 197 + 198 + if (beatmapId != null) { 199 + this.discussionsState.currentBeatmapId = beatmapId; 200 + } 201 + 202 + this.discussionsState.currentPage = mode; 203 + this.discussionsState.highlightedDiscussionId = discussion.id; 204 + 205 + window.setTimeout(() => this.jumpToAfterRender(id, postId), 0); 206 + } 207 + 208 + private jumpToAfterRender(discussionId: number, postId?: number) { 209 + const attribute = postId != null ? `data-post-id='${postId}'` : `data-id='${discussionId}'`; 210 + const target = document.querySelector(`.js-beatmap-discussion-jump[${attribute}]`); 211 + 212 + if (target == null || this.modeSwitcherRef.current == null || this.newDiscussionRef.current == null) return; 213 + 214 + let margin = this.modeSwitcherRef.current.getBoundingClientRect().height; 215 + if (this.discussionsState.pinnedNewDiscussion) { 216 + margin += this.newDiscussionRef.current.getBoundingClientRect().height; 217 + } 218 + 219 + // Update scroll-padding instead of adding scroll-margin, otherwise it doesn't anchor in the right place. 220 + document.documentElement.style.setProperty('--scroll-padding-top-extra', `${Math.floor(margin)}px`); 221 + 222 + // avoid smooth scrolling to avoid triggering lazy loaded images. 223 + // FIXME: Safari still has the issue where images just out of view get loaded and push the page down 224 + // because it doesn't anchor the scroll position. 225 + target.scrollIntoView({ behavior: 'instant', block: 'start', inline: 'nearest' }); 226 + } 227 + 228 + private readonly jumpToClick = (e: JQuery.TriggeredEvent<Document, unknown, HTMLElement, HTMLElement>) => { 229 + if (!(e.currentTarget instanceof HTMLAnchorElement)) return; 230 + 231 + const url = e.currentTarget.href; 232 + const parsedUrl = parseUrl(url, this.discussionsState.discussionsArray); 233 + 234 + if (parsedUrl == null) return; 235 + 236 + const { discussionId, postId } = parsedUrl; 237 + 238 + if (discussionId == null) return; 239 + 240 + e.preventDefault(); 241 + this.jumpTo(discussionId, postId); 242 + }; 243 + 244 + private readonly jumpToDiscussionByHash = () => { 245 + const target = parseUrl(null, this.discussionsState.discussionsArray); 246 + 247 + if (target?.discussionId != null) { 248 + this.jumpTo(target.discussionId, target.postId); 249 + } 250 + }; 251 + 252 + private readonly ujsDiscussionUpdate = (_event: unknown, beatmapset: BeatmapsetWithDiscussionsJson) => { 253 + // to allow ajax:complete to be run 254 + window.setTimeout(() => this.discussionsState.update({ beatmapset }), 0); 255 + }; 256 + }
+27 -17
resources/js/beatmap-discussions/mode-switcher.tsx
··· 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 4 import StringWithComponent from 'components/string-with-component'; 5 - import BeatmapJson from 'interfaces/beatmap-json'; 6 - import BeatmapsetJson from 'interfaces/beatmapset-json'; 7 - import { snakeCase, size } from 'lodash'; 5 + import { snakeCase } from 'lodash'; 6 + import { action } from 'mobx'; 7 + import { observer } from 'mobx-react'; 8 8 import * as React from 'react'; 9 9 import { makeUrl } from 'utils/beatmapset-discussion-helper'; 10 10 import { classWithModifiers } from 'utils/css'; 11 11 import { trans } from 'utils/lang'; 12 - import CurrentDiscussions, { Filter } from './current-discussions'; 13 - import { DiscussionPage, discussionPages } from './discussion-mode'; 12 + import DiscussionPage, { discussionPages } from './discussion-page'; 13 + import DiscussionsState from './discussions-state'; 14 14 15 15 interface Props { 16 - beatmapset: BeatmapsetJson; 17 - currentBeatmap: BeatmapJson; 18 - currentDiscussions: CurrentDiscussions; 19 - currentFilter: Filter; 16 + discussionsState: DiscussionsState; 20 17 innerRef: React.RefObject<HTMLDivElement>; 21 - mode: DiscussionPage; 22 18 } 23 19 24 20 const selectedClassName = 'page-mode-link--is-active'; 25 21 26 - export class ModeSwitcher extends React.PureComponent<Props> { 22 + @observer 23 + export class ModeSwitcher extends React.Component<Props> { 27 24 private readonly scrollerRef = React.createRef<HTMLUListElement>(); 28 25 26 + private get beatmapset() { 27 + return this.props.discussionsState.beatmapset; 28 + } 29 + 30 + private get currentBeatmap() { 31 + return this.props.discussionsState.currentBeatmap; 32 + } 33 + 34 + private get currentMode() { 35 + return this.props.discussionsState.currentPage; 36 + } 37 + 29 38 componentDidMount() { 30 39 this.scrollModeSwitcher(); 31 40 } ··· 52 61 private readonly renderMode = (mode: DiscussionPage) => ( 53 62 <li key={mode} className='page-mode__item'> 54 63 <a 55 - className={classWithModifiers('page-mode-link', { 'is-active': this.props.mode === mode })} 64 + className={classWithModifiers('page-mode-link', { 'is-active': this.currentMode === mode })} 56 65 data-mode={mode} 57 66 href={makeUrl({ 58 - beatmapId: this.props.currentBeatmap.id, 59 - beatmapsetId: this.props.beatmapset.id, 67 + beatmapId: this.currentBeatmap.id, 68 + beatmapsetId: this.beatmapset.id, 60 69 mode, 61 70 })} 62 71 onClick={this.switch} ··· 65 74 {this.renderModeText(mode)} 66 75 {mode !== 'events' && ( 67 76 <span className='page-mode-link__badge'> 68 - {size(this.props.currentDiscussions.byFilter[this.props.currentFilter][mode])} 77 + {this.props.discussionsState.discussionsForSelectedUserByMode[mode].length} 69 78 </span> 70 79 )} 71 80 <span className='page-mode-link__stripe' /> ··· 77 86 private renderModeText(mode: DiscussionPage) { 78 87 if (mode === 'general' || mode === 'generalAll') { 79 88 const text = mode === 'general' 80 - ? this.props.currentBeatmap.version 89 + ? this.currentBeatmap.version 81 90 : trans('beatmaps.discussions.mode.scopes.generalAll'); 82 91 83 92 return ( ··· 100 109 $(this.scrollerRef.current).scrollTo(`.${selectedClassName}`, 0, { over: { left: -1 } }); 101 110 } 102 111 112 + @action 103 113 private readonly switch = (e: React.SyntheticEvent<HTMLAnchorElement>) => { 104 114 e.preventDefault(); 105 115 106 - $.publish('beatmapsetDiscussions:update', { mode: e.currentTarget.dataset.mode }); 116 + this.props.discussionsState.changeDiscussionPage(e.currentTarget.dataset.mode); 107 117 }; 108 118 }
+74 -57
resources/js/beatmap-discussions/new-discussion.tsx
··· 10 10 import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 11 11 import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 12 12 import { BeatmapsetDiscussionPostStoreResponseJson } from 'interfaces/beatmapset-discussion-post-responses'; 13 - import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 14 - import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 15 13 import { route } from 'laroute'; 16 - import { action, computed, makeObservable, observable, runInAction } from 'mobx'; 17 - import { observer } from 'mobx-react'; 14 + import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; 15 + import { disposeOnUnmount, observer } from 'mobx-react'; 18 16 import core from 'osu-core-singleton'; 19 17 import * as React from 'react'; 20 18 import { onError } from 'utils/ajax'; ··· 25 23 import { joinComponents, trans } from 'utils/lang'; 26 24 import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay'; 27 25 import { present } from 'utils/string'; 28 - import CurrentDiscussions from './current-discussions'; 29 26 import DiscussionMessageLengthCounter from './discussion-message-length-counter'; 30 - import DiscussionMode from './discussion-mode'; 27 + import DiscussionsState from './discussions-state'; 31 28 import { hypeExplanationClass } from './nominations'; 32 29 33 30 const bn = 'beatmap-discussion-new'; ··· 40 37 41 38 interface Props { 42 39 autoFocus: boolean; 43 - beatmapset: BeatmapsetExtendedJson & BeatmapsetWithDiscussionsJson; 44 - currentBeatmap: BeatmapExtendedJson; 45 - currentDiscussions: CurrentDiscussions; 40 + discussionsState: DiscussionsState; 46 41 innerRef: React.RefObject<HTMLDivElement>; 47 - mode: DiscussionMode; 48 42 onFocus?: () => void; 49 - pinned: boolean; 50 - setPinned: (flag: boolean) => void; 51 43 stickTo: React.RefObject<HTMLElement>; 52 44 } 53 45 ··· 64 56 @observable private stickToHeight: number | undefined; 65 57 @observable private timestampConfirmed = false; 66 58 59 + private get beatmapset() { 60 + return this.props.discussionsState.beatmapset; 61 + } 62 + 63 + private get currentBeatmap() { 64 + return this.props.discussionsState.currentBeatmap; 65 + } 66 + 67 + private get currentMode() { 68 + return this.props.discussionsState.currentPage; 69 + } 70 + 67 71 private get canPost() { 68 72 if (core.currentUser == null) return false; 69 - if (downloadLimited(this.props.beatmapset)) return false; 73 + if (downloadLimited(this.beatmapset)) return false; 70 74 71 75 return !core.currentUser.is_silenced 72 - && (!this.props.beatmapset.discussion_locked || canModeratePosts()) 73 - && (this.props.currentBeatmap.deleted_at == null || this.props.mode === 'generalAll'); 76 + && (!this.beatmapset.discussion_locked || canModeratePosts()) 77 + && (this.currentBeatmap.deleted_at == null || this.currentMode === 'generalAll'); 74 78 } 75 79 76 80 @computed 77 81 private get cssTop() { 78 - if (this.mounted && this.props.pinned && this.stickToHeight != null) { 82 + if (this.mounted && this.pinned && this.stickToHeight != null) { 79 83 return core.stickyHeader.headerHeight + this.stickToHeight; 80 84 } 81 85 } 82 86 83 87 private get isTimeline() { 84 - return this.props.mode === 'timeline'; 88 + return this.currentMode === 'timeline'; 85 89 } 86 90 87 91 private get nearbyDiscussions() { 88 92 const timestamp = this.timestamp; 89 93 if (timestamp == null) return []; 90 94 91 - if (this.nearbyDiscussionsCache == null || (this.nearbyDiscussionsCache.beatmap !== this.props.currentBeatmap || this.nearbyDiscussionsCache.timestamp !== this.timestamp)) { 95 + if (this.nearbyDiscussionsCache == null || (this.nearbyDiscussionsCache.beatmap !== this.currentBeatmap || this.nearbyDiscussionsCache.timestamp !== this.timestamp)) { 92 96 this.nearbyDiscussionsCache = { 93 - beatmap: this.props.currentBeatmap, 94 - discussions: nearbyDiscussions(this.props.currentDiscussions.timelineAllUsers, timestamp), 97 + beatmap: this.currentBeatmap, 98 + discussions: nearbyDiscussions(this.props.discussionsState.discussionForSelectedBeatmap, timestamp), 95 99 timestamp: this.timestamp, 96 100 }; 97 101 } ··· 99 103 return this.nearbyDiscussionsCache.discussions; 100 104 } 101 105 106 + private get pinned() { 107 + return this.props.discussionsState.pinnedNewDiscussion; 108 + } 109 + 102 110 private get storageKey() { 103 - return `beatmapset-discussion:store:${this.props.beatmapset.id}:message`; 111 + return `beatmapset-discussion:store:${this.beatmapset.id}:message`; 104 112 } 105 113 106 114 private get storedMessage() { ··· 111 119 if (core.currentUser == null) return; 112 120 113 121 if (this.canPost) { 114 - return trans(`beatmaps.discussions.message_placeholder.${this.props.mode}`, { version: this.props.currentBeatmap.version }); 122 + return trans(`beatmaps.discussions.message_placeholder.${this.currentMode}`, { version: this.currentBeatmap.version }); 115 123 } 116 124 117 125 if (core.currentUser.is_silenced) { 118 126 return trans('beatmaps.discussions.message_placeholder_silenced'); 119 - } else if (this.props.beatmapset.discussion_locked || downloadLimited(this.props.beatmapset)) { 127 + } else if (this.beatmapset.discussion_locked || downloadLimited(this.beatmapset)) { 120 128 return trans('beatmaps.discussions.message_placeholder_locked'); 121 129 } else { 122 130 return trans('beatmaps.discussions.message_placeholder_deleted_beatmap'); ··· 125 133 126 134 @computed 127 135 private get timestamp() { 128 - return this.props.mode === 'timeline' 136 + return this.currentMode === 'timeline' 129 137 ? parseTimestamp(this.message) 130 138 : null; 131 139 } ··· 134 142 super(props); 135 143 makeObservable(this); 136 144 this.handleKeyDown = makeTextAreaHandler(this.handleKeyDownCallback); 145 + 146 + disposeOnUnmount(this, reaction(() => this.message, (current, prev) => { 147 + if (prev !== current) { 148 + this.storeMessage(); 149 + } 150 + })); 151 + 152 + disposeOnUnmount(this, reaction(() => this.props.discussionsState.beatmapset, (current, prev) => { 153 + // TODO: check if this is still needed. 154 + if (prev.id !== current.id) { 155 + runInAction(() => { 156 + this.message = this.storedMessage; 157 + }); 158 + } 159 + })); 137 160 } 138 161 139 162 componentDidMount() { ··· 149 172 } 150 173 } 151 174 152 - componentDidUpdate(prevProps: Readonly<Props>) { 153 - if (prevProps.beatmapset.id !== this.props.beatmapset.id) { 154 - this.message = this.storedMessage; 155 - return; 156 - } 157 - this.storeMessage(); 158 - } 159 - 160 175 componentWillUnmount() { 161 176 $(window).off('resize', this.updateStickToHeight); 162 177 this.postXhr?.abort(); ··· 164 179 } 165 180 166 181 render() { 167 - const cssClasses = classWithModifiers('beatmap-discussion-new-float', { pinned: this.props.pinned }); 182 + const cssClasses = classWithModifiers('beatmap-discussion-new-float', { pinned: this.pinned }); 168 183 169 184 return ( 170 185 <div ··· 208 223 } 209 224 210 225 if (type === 'hype') { 211 - if (!confirm(trans('beatmaps.hype.confirm', { n: this.props.beatmapset.current_user_attributes.remaining_hype }))) return; 226 + if (!confirm(trans('beatmaps.hype.confirm', { n: this.beatmapset.current_user_attributes.remaining_hype }))) return; 212 227 } 213 228 214 229 showLoadingOverlay(); ··· 216 231 217 232 const data = { 218 233 beatmap_discussion: { 219 - beatmap_id: this.props.mode === 'generalAll' ? undefined : this.props.currentBeatmap.id, 234 + beatmap_id: this.currentMode === 'generalAll' ? undefined : this.currentBeatmap.id, 220 235 message_type: type, 221 236 timestamp: this.timestamp, 222 237 }, 223 238 beatmap_discussion_post: { 224 239 message: this.message, 225 240 }, 226 - beatmapset_id: this.props.currentBeatmap.beatmapset_id, 241 + beatmapset_id: this.currentBeatmap.beatmapset_id, 227 242 }; 228 243 229 244 this.postXhr = $.ajax(route('beatmapsets.discussions.posts.store'), { ··· 235 250 .done((json) => runInAction(() => { 236 251 this.message = ''; 237 252 this.timestampConfirmed = false; 238 - $.publish('beatmapDiscussionPost:markRead', { id: json.beatmap_discussion_post_ids }); 239 - $.publish('beatmapsetDiscussions:update', { beatmapset: json.beatmapset }); 253 + for (const postId of json.beatmap_discussion_post_ids) { 254 + this.props.discussionsState.readPostIds.add(postId); 255 + } 256 + this.props.discussionsState.update({ beatmapset: json.beatmapset }); 240 257 })) 241 258 .fail(onError) 242 259 .always(action(() => { ··· 248 265 249 266 private problemType() { 250 267 const canDisqualify = core.currentUser?.is_admin || core.currentUser?.is_moderator || core.currentUser?.is_full_bn; 251 - const willDisqualify = this.props.beatmapset.status === 'qualified'; 268 + const willDisqualify = this.beatmapset.status === 'qualified'; 252 269 253 270 if (canDisqualify && willDisqualify) return 'disqualify'; 254 271 255 272 const canReset = core.currentUser?.is_admin || core.currentUser?.is_nat || core.currentUser?.is_bng; 256 - const currentNominations = nominationsCount(this.props.beatmapset.nominations, 'current'); 257 - const willReset = this.props.beatmapset.status === 'pending' && currentNominations > 0; 273 + const currentNominations = nominationsCount(this.beatmapset.nominations, 'current'); 274 + const willReset = this.beatmapset.status === 'pending' && currentNominations > 0; 258 275 259 276 if (canReset && willReset) return 'nomination_reset'; 260 277 if (willDisqualify) return 'problem_warning'; ··· 263 280 } 264 281 265 282 private renderBox() { 266 - const canHype = this.props.beatmapset.current_user_attributes?.can_hype 267 - && this.props.beatmapset.can_be_hyped 268 - && this.props.mode === 'generalAll'; 283 + const canHype = this.beatmapset.current_user_attributes?.can_hype 284 + && this.beatmapset.can_be_hyped 285 + && this.currentMode === 'generalAll'; 269 286 270 287 const canPostNote = core.currentUser != null 271 - && (core.currentUser.id === this.props.beatmapset.user_id 272 - || (core.currentUser.id === this.props.currentBeatmap.user_id && ['general', 'timeline'].includes(this.props.mode)) 288 + && (core.currentUser.id === this.beatmapset.user_id 289 + || (core.currentUser.id === this.currentBeatmap.user_id && ['general', 'timeline'].includes(this.currentMode)) 273 290 || core.currentUser.is_bng 274 291 || canModeratePosts()); 275 292 276 - const buttonCssClasses = classWithModifiers('btn-circle', { activated: this.props.pinned }); 293 + const buttonCssClasses = classWithModifiers('btn-circle', { activated: this.pinned }); 277 294 278 295 return ( 279 296 <div className='osu-page osu-page--small'> ··· 285 302 <span 286 303 className={buttonCssClasses} 287 304 onClick={this.toggleSticky} 288 - title={trans(`beatmaps.discussions.new.${this.props.pinned ? 'unpin' : 'pin'}`)} 305 + title={trans(`beatmaps.discussions.new.${this.pinned ? 'unpin' : 'pin'}`)} 289 306 > 290 307 <span className='btn-circle__content'> 291 308 <i className='fas fa-thumbtack' /> ··· 320 337 } 321 338 322 339 private renderHype() { 323 - if (!(this.props.mode === 'generalAll' && this.props.beatmapset.can_be_hyped)) return null; 340 + if (!(this.currentMode === 'generalAll' && this.beatmapset.can_be_hyped)) return null; 324 341 325 342 return ( 326 343 <div className={`${bn}__footer-content ${hypeExplanationClass} js-flash-border`}> ··· 330 347 <div className={`${bn}__footer-message`}> 331 348 {core.currentUser != null ? ( 332 349 <span> 333 - {this.props.beatmapset.current_user_attributes.can_hype ? trans('beatmaps.hype.explanation') : this.props.beatmapset.current_user_attributes.can_hype_reason} 334 - {(this.props.beatmapset.current_user_attributes.can_hype || this.props.beatmapset.current_user_attributes.remaining_hype <= 0) && ( 350 + {this.beatmapset.current_user_attributes.can_hype ? trans('beatmaps.hype.explanation') : this.beatmapset.current_user_attributes.can_hype_reason} 351 + {(this.beatmapset.current_user_attributes.can_hype || this.beatmapset.current_user_attributes.remaining_hype <= 0) && ( 335 352 <> 336 353 <StringWithComponent 337 - mappings={{ remaining: this.props.beatmapset.current_user_attributes.remaining_hype }} 354 + mappings={{ remaining: this.beatmapset.current_user_attributes.remaining_hype }} 338 355 pattern={` ${trans('beatmaps.hype.remaining')}`} 339 356 /> 340 - {this.props.beatmapset.current_user_attributes.new_hype_time != null && ( 357 + {this.beatmapset.current_user_attributes.new_hype_time != null && ( 341 358 <StringWithComponent 342 359 mappings={{ 343 - new_time: <TimeWithTooltip dateTime={this.props.beatmapset.current_user_attributes.new_hype_time} relative />, 360 + new_time: <TimeWithTooltip dateTime={this.beatmapset.current_user_attributes.new_hype_time} relative />, 344 361 }} 345 362 pattern={` ${trans('beatmaps.hype.new_time')}`} 346 363 /> ··· 417 434 } 418 435 419 436 private renderTimestamp() { 420 - if (this.props.mode !== 'timeline') return null; 437 + if (this.currentMode !== 'timeline') return null; 421 438 422 439 const timestamp = this.timestamp != null ? formatTimestamp(this.timestamp) : trans('beatmaps.discussions.new.timestamp_missing'); 423 440 ··· 440 457 441 458 @action 442 459 private readonly setSticky = (sticky: boolean) => { 443 - this.props.setPinned(sticky); 460 + this.props.discussionsState.pinnedNewDiscussion = sticky; 444 461 this.updateStickToHeight(); 445 462 }; 446 463 ··· 471 488 } 472 489 473 490 private readonly toggleSticky = () => { 474 - this.setSticky(!this.props.pinned); 491 + this.setSticky(!this.pinned); 475 492 }; 476 493 477 494 @action
+3 -6
resources/js/beatmap-discussions/new-reply.tsx
··· 4 4 import BigButton from 'components/big-button'; 5 5 import TextareaAutosize from 'components/textarea-autosize'; 6 6 import UserAvatar from 'components/user-avatar'; 7 - import BeatmapJson from 'interfaces/beatmap-json'; 8 7 import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 9 8 import { BeatmapsetDiscussionPostStoreResponseJson } from 'interfaces/beatmapset-discussion-post-responses'; 10 - import BeatmapsetJson from 'interfaces/beatmapset-json'; 11 9 import { route } from 'laroute'; 12 10 import { action, makeObservable, observable, runInAction } from 'mobx'; 13 11 import { observer } from 'mobx-react'; ··· 20 18 import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay'; 21 19 import { present } from 'utils/string'; 22 20 import DiscussionMessageLengthCounter from './discussion-message-length-counter'; 21 + import DiscussionsState from './discussions-state'; 23 22 24 23 const bn = 'beatmap-discussion-post'; 25 24 26 25 interface Props { 27 - beatmapset: BeatmapsetJson; 28 - currentBeatmap: BeatmapJson | null; 29 26 discussion: BeatmapsetDiscussionJson; 27 + discussionsState: DiscussionsState; 30 28 } 31 29 32 30 const actionIcons = { ··· 161 159 .done((json) => runInAction(() => { 162 160 this.editing = false; 163 161 this.setMessage(''); 164 - $.publish('beatmapDiscussionPost:markRead', { id: json.beatmap_discussion_post_ids }); 165 - $.publish('beatmapsetDiscussions:update', { beatmapset: json.beatmapset }); 162 + this.props.discussionsState.update(json); 166 163 })) 167 164 .fail(onError) 168 165 .always(action(() => {
+24 -26
resources/js/beatmap-discussions/new-review.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import { DiscussionsContext } from 'beatmap-discussions/discussions-context'; 5 - import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 6 - import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 4 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 7 5 import { action, computed, makeObservable, observable } from 'mobx'; 8 6 import { observer } from 'mobx-react'; 9 7 import core from 'osu-core-singleton'; ··· 11 9 import { downloadLimited } from 'utils/beatmapset-helper'; 12 10 import { classWithModifiers } from 'utils/css'; 13 11 import { trans } from 'utils/lang'; 12 + import DiscussionsState from './discussions-state'; 14 13 import Editor from './editor'; 15 14 16 15 interface Props { 17 - beatmaps: BeatmapExtendedJson[]; 18 - beatmapset: BeatmapsetExtendedJson; 19 - currentBeatmap: BeatmapExtendedJson; 16 + discussionsState: DiscussionsState; 20 17 innerRef: React.RefObject<HTMLDivElement>; 21 18 onFocus?: () => void; 22 - pinned?: boolean; 23 - setPinned?: (sticky: boolean) => void; 24 19 stickTo?: React.RefObject<HTMLDivElement>; 20 + store: BeatmapsetDiscussionsStore; 25 21 } 26 22 27 23 @observer ··· 30 26 @observable private mounted = false; 31 27 @observable private stickToHeight: number | undefined; 32 28 29 + private get beatmapset() { 30 + return this.props.discussionsState.beatmapset; 31 + } 32 + 33 33 @computed 34 34 private get cssTop() { 35 - if (this.mounted && this.props.pinned && this.stickToHeight != null) { 35 + if (this.mounted && this.pinned && this.stickToHeight != null) { 36 36 return core.stickyHeader.headerHeight + this.stickToHeight; 37 37 } 38 38 } 39 39 40 + private get pinned() { 41 + return this.props.discussionsState.pinnedNewDiscussion; 42 + } 43 + 40 44 private get noPermissionText() { 41 - if (downloadLimited(this.props.beatmapset)) { 45 + if (downloadLimited(this.beatmapset)) { 42 46 return trans('beatmaps.discussions.message_placeholder_locked'); 43 47 } 44 48 ··· 74 78 const placeholder = this.noPermissionText; 75 79 76 80 return ( 77 - <div className={classWithModifiers(floatClass, { pinned: this.props.pinned })} style={{ top: this.cssTop }}> 81 + <div className={classWithModifiers(floatClass, { pinned: this.pinned })} style={{ top: this.cssTop }}> 78 82 <div className={`${floatClass}__floatable`}> 79 83 <div ref={this.props.innerRef} className={`${floatClass}__content`}> 80 84 <div className='osu-page osu-page--small'> ··· 83 87 {trans('beatmaps.discussions.review.new')} 84 88 <span className='page-title__button'> 85 89 <span 86 - className={classWithModifiers('btn-circle', { activated: this.props.pinned })} 90 + className={classWithModifiers('btn-circle', { activated: this.pinned })} 87 91 onClick={this.toggleSticky} 88 - title={trans(`beatmaps.discussions.new.${this.props.pinned ? 'unpin' : 'pin'}`)} 92 + title={trans(`beatmaps.discussions.new.${this.pinned ? 'unpin' : 'pin'}`)} 89 93 > 90 94 <span className='btn-circle__content'><i className='fas fa-thumbtack' /></span> 91 95 </span> 92 96 </span> 93 97 </div> 94 98 {placeholder == null ? ( 95 - <DiscussionsContext.Consumer> 96 - { 97 - (discussions) => (<Editor 98 - beatmaps={this.props.beatmaps} 99 - beatmapset={this.props.beatmapset} 100 - currentBeatmap={this.props.currentBeatmap} 101 - discussions={discussions} 102 - onFocus={this.handleFocus} 103 - />) 104 - } 105 - </DiscussionsContext.Consumer> 99 + <Editor 100 + discussionsState={this.props.discussionsState} 101 + onFocus={this.handleFocus} 102 + store={this.props.store} 103 + /> 106 104 ) : <div className='beatmap-discussion-new__login-required'>{placeholder}</div>} 107 105 </div> 108 106 </div> ··· 119 117 120 118 @action 121 119 private setSticky(sticky: boolean) { 122 - this.props.setPinned?.(sticky); 120 + this.props.discussionsState.pinnedNewDiscussion = sticky; 123 121 this.updateStickToHeight(); 124 122 } 125 123 126 124 private readonly toggleSticky = () => { 127 - this.setSticky(!this.props.pinned); 125 + this.setSticky(!this.pinned); 128 126 }; 129 127 130 128 @action
+93 -85
resources/js/beatmap-discussions/nominations.tsx
··· 11 11 import StringWithComponent from 'components/string-with-component'; 12 12 import TimeWithTooltip from 'components/time-with-tooltip'; 13 13 import UserLink from 'components/user-link'; 14 - import { BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; 14 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 15 15 import BeatmapsetEventJson from 'interfaces/beatmapset-event-json'; 16 16 import { BeatmapsetNominationsInterface } from 'interfaces/beatmapset-json'; 17 17 import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 18 18 import GameMode from 'interfaces/game-mode'; 19 19 import UserJson from 'interfaces/user-json'; 20 20 import { route } from 'laroute'; 21 - import { action, makeObservable, observable } from 'mobx'; 21 + import { action, makeObservable, observable, runInAction } from 'mobx'; 22 22 import { observer } from 'mobx-react'; 23 - import { deletedUser } from 'models/user'; 23 + import { deletedUser, deletedUserJson } from 'models/user'; 24 24 import moment from 'moment'; 25 25 import core from 'osu-core-singleton'; 26 26 import * as React from 'react'; 27 27 import { onError } from 'utils/ajax'; 28 - import { canModeratePosts, makeUrl } from 'utils/beatmapset-discussion-helper'; 28 + import { canModeratePosts, makeUrl, startingPost } from 'utils/beatmapset-discussion-helper'; 29 29 import { nominationsCount } from 'utils/beatmapset-helper'; 30 30 import { classWithModifiers } from 'utils/css'; 31 31 import { formatNumber } from 'utils/html'; 32 32 import { joinComponents, trans, transExists } from 'utils/lang'; 33 33 import { presence } from 'utils/string'; 34 34 import { wikiUrl } from 'utils/url'; 35 - import CurrentDiscussions from './current-discussions'; 35 + import DiscussionsState from './discussions-state'; 36 36 37 37 const bn = 'beatmap-discussion-nomination'; 38 38 const flashClass = 'js-flash-border--on'; ··· 40 40 const nominatorsVisibleBeatmapStatuses = Object.freeze(new Set(['wip', 'pending', 'ranked', 'qualified'])); 41 41 42 42 interface Props { 43 - beatmapset: BeatmapsetWithDiscussionsJson; 44 - currentDiscussions: CurrentDiscussions; 45 - discussions: Partial<Record<number, BeatmapsetDiscussionJsonForShow>>; 46 - events: BeatmapsetEventJson[]; 47 - users: Partial<Record<number, UserJson>>; 43 + discussionsState: DiscussionsState; 44 + store: BeatmapsetDiscussionsStore; 48 45 } 49 46 50 47 type XhrType = 'delete' | 'discussionLock' | 'removeFromLoved'; ··· 62 59 } 63 60 64 61 @observer 65 - export class Nominations extends React.PureComponent<Props> { 62 + export class Nominations extends React.Component<Props> { 66 63 private hypeFocusTimeout: number | undefined; 67 64 @observable private showBeatmapsOwnerEditor = false; 68 65 @observable private showLoveBeatmapDialog = false; 69 66 @observable private readonly xhr: Partial<Record<XhrType, JQuery.jqXHR<BeatmapsetWithDiscussionsJson>>> = {}; 70 67 68 + private get beatmapset() { 69 + return this.props.discussionsState.beatmapset; 70 + } 71 + 72 + private get discussions() { 73 + return this.props.store.discussions; 74 + } 75 + 76 + private get events() { 77 + return this.beatmapset.events; 78 + } 79 + 71 80 private get isQualified() { 72 - return this.props.beatmapset.status === 'qualified'; 81 + return this.beatmapset.status === 'qualified'; 73 82 } 74 83 75 84 private get userCanDisqualify() { ··· 77 86 } 78 87 79 88 private get userIsOwner() { 80 - return core.currentUser != null && (core.currentUser.id === this.props.beatmapset.user_id); 89 + return core.currentUser != null && (core.currentUser.id === this.beatmapset.user_id); 90 + } 91 + 92 + private get users() { 93 + return this.props.store.users; 81 94 } 82 95 83 96 constructor(props: Props) { ··· 112 125 <div className={`${bn}__item`}>{this.renderDisqualifyButton()}</div> 113 126 <div className={`${bn}__item`}> 114 127 <Nominator 115 - beatmapset={this.props.beatmapset} 116 - currentHype={this.props.currentDiscussions.totalHype} 117 - unresolvedIssues={this.props.currentDiscussions.unresolvedIssues} 118 - users={this.props.users} 128 + discussionsState={this.props.discussionsState} 129 + store={this.props.store} 119 130 /> 120 131 </div> 121 132 </div> ··· 142 153 if (!confirm(message)) return; 143 154 144 155 this.xhr.delete = $.ajax( 145 - route('beatmapsets.destroy', { beatmapset: this.props.beatmapset.id }), 156 + route('beatmapsets.destroy', { beatmapset: this.beatmapset.id }), 146 157 { method: 'DELETE' }, 147 158 ) 148 - .done(() => Turbolinks.visit(route('users.show', { user: this.props.beatmapset.user_id }))) 159 + .done(() => Turbolinks.visit(route('users.show', { user: this.beatmapset.user_id }))) 149 160 .fail(onError) 150 161 .always(action(() => { 151 162 this.xhr.delete = undefined; ··· 161 172 if (reason == null) return; 162 173 163 174 this.xhr.discussionLock = $.ajax( 164 - route('beatmapsets.discussion-lock', { beatmapset: this.props.beatmapset.id }), 175 + route('beatmapsets.discussion-lock', { beatmapset: this.beatmapset.id }), 165 176 { data: { reason }, method: 'POST' }, 166 177 ); 167 178 168 179 this.xhr.discussionLock 169 - .done((beatmapset) => { 170 - $.publish('beatmapsetDiscussions:update', { beatmapset }); 171 - }) 180 + .done((beatmapset) => runInAction(() => { 181 + this.props.discussionsState.update({ beatmapset }); 182 + })) 172 183 .fail(onError) 173 184 .always(action(() => { 174 185 this.xhr.discussionLock = undefined; ··· 182 193 if (!confirm(trans('beatmaps.discussions.lock.prompt.unlock'))) return; 183 194 184 195 this.xhr.discussionLock = $.ajax( 185 - route('beatmapsets.discussion-unlock', { beatmapset: this.props.beatmapset.id }), 196 + route('beatmapsets.discussion-unlock', { beatmapset: this.beatmapset.id }), 186 197 { method: 'POST' }, 187 198 ); 188 199 189 200 this.xhr.discussionLock 190 - .done((beatmapset) => { 191 - $.publish('beatmapsetDiscussions:update', { beatmapset }); 192 - }) 201 + .done((beatmapset) => runInAction(() => { 202 + this.props.discussionsState.update({ beatmapset }); 203 + })) 193 204 .fail(onError) 194 205 .always(action(() => { 195 206 this.xhr.discussionLock = undefined; 196 207 })); 197 208 }; 198 209 210 + @action 199 211 private readonly focusHypeInput = () => { 200 212 // switch to generalAll tab, set current filter to praises 201 - $.publish('beatmapsetDiscussions:update', { 202 - filter: 'praises', 203 - mode: 'generalAll', 204 - }); 213 + this.props.discussionsState.changeFilter('praises'); 214 + this.props.discussionsState.changeDiscussionPage('generalAll'); 205 215 206 216 this.hypeFocusTimeout = window.setTimeout(() => { 207 217 this.focusNewDiscussion(() => { ··· 215 225 }, 0); 216 226 }; 217 227 218 - private focusNewDiscussion(this: void, callback: () => void) { 228 + private focusNewDiscussion(this: void, callback?: () => void) { 219 229 const inputBox = $('.js-hype--input'); 220 230 inputBox.trigger('focus'); 221 231 ··· 227 237 }); 228 238 } 229 239 240 + @action 230 241 private readonly focusNewDiscussionWithModeSwitch = () => { 231 242 // Switch to generalAll tab just in case currently in event tab 232 243 // and thus new discussion box isn't visible. 233 - $.publish('beatmapsetDiscussions:update', { 234 - callback: this.focusNewDiscussion, 235 - mode: 'generalAll', 236 - modeIf: 'events', 237 - }); 244 + if (this.props.discussionsState.currentPage === 'events') { 245 + this.props.discussionsState.changeDiscussionPage('generalAll'); 246 + this.focusNewDiscussion(); 247 + } 238 248 }; 239 249 240 250 @action ··· 248 258 }; 249 259 250 260 private parseEventData(event: BeatmapsetEventJson) { 251 - const user = event.user_id != null ? this.props.users[event.user_id] : null; 261 + const user = this.users.get(event.user_id) ?? deletedUserJson; 252 262 const discussionId = discussionIdFromEvent(event); 253 - const discussion = discussionId != null ? this.props.discussions[discussionId] : null; 254 - const post = discussion?.posts[0]; 263 + const discussion = this.discussions.get(discussionId); 264 + const post = discussion != null ? startingPost(discussion) : null; 255 265 256 266 let link: React.ReactNode; 257 267 let message: React.ReactNode; ··· 277 287 if (reason == null) return; 278 288 279 289 this.xhr.removeFromLoved = $.ajax( 280 - route('beatmapsets.remove-from-loved', { beatmapset: this.props.beatmapset.id }), 290 + route('beatmapsets.remove-from-loved', { beatmapset: this.beatmapset.id }), 281 291 { data: { reason }, method: 'DELETE' }, 282 292 ); 283 293 284 294 this.xhr.removeFromLoved 285 - .done((beatmapset) => 286 - $.publish('beatmapsetDiscussions:update', { beatmapset }), 287 - ) 295 + .done((beatmapset) => runInAction(() => { 296 + this.props.discussionsState.update({ beatmapset }); 297 + })) 288 298 .fail(onError) 289 299 .always(action(() => { 290 300 this.xhr.removeFromLoved = undefined; ··· 297 307 return ( 298 308 <Modal> 299 309 <BeatmapsOwnerEditor 300 - beatmapset={this.props.beatmapset} 310 + beatmapset={this.beatmapset} 311 + discussionsState={this.props.discussionsState} 301 312 onClose={this.handleToggleBeatmapsOwnerEditor} 302 - users={this.props.users} 313 + users={this.props.store.users} 303 314 /> 304 315 </Modal> 305 316 ); 306 317 } 307 318 308 319 private renderBeatmapsOwnerEditorButton() { 309 - if (!this.props.beatmapset.current_user_attributes.can_beatmap_update_owner) return; 320 + if (!this.beatmapset.current_user_attributes.can_beatmap_update_owner) return; 310 321 311 322 return ( 312 323 <BigButton ··· 320 331 } 321 332 322 333 private renderDeleteButton() { 323 - if (!this.props.beatmapset.current_user_attributes.can_delete) return; 334 + if (!this.beatmapset.current_user_attributes.can_delete) return; 324 335 325 336 return ( 326 337 <BigButton ··· 338 349 private renderDiscussionLockButton() { 339 350 if (!canModeratePosts()) return; 340 351 341 - const { buttonProps, lockAction } = this.props.beatmapset.discussion_locked 352 + const { buttonProps, lockAction } = this.beatmapset.discussion_locked 342 353 ? { 343 354 buttonProps: { 344 355 icon: 'fas fa-unlock', ··· 368 379 } 369 380 370 381 private renderDiscussionLockMessage() { 371 - if (!this.props.beatmapset.discussion_locked) return; 382 + if (!this.beatmapset.discussion_locked) return; 372 383 373 - for (let i = this.props.events.length - 1; i >= 0; i--) { 374 - const event = this.props.events[i]; 384 + for (let i = this.events.length - 1; i >= 0; i--) { 385 + const event = this.events[i]; 375 386 if (event.type === 'discussion_lock') { 376 387 return trans('beatmapset_events.event.discussion_lock', { text: event.comment.reason }); 377 388 } ··· 379 390 } 380 391 381 392 private renderDisqualificationMessage() { 382 - const showHype = this.props.beatmapset.can_be_hyped; 383 - const disqualification = this.props.beatmapset.nominations.disqualification; 393 + const showHype = this.beatmapset.can_be_hyped; 394 + const disqualification = this.beatmapset.nominations.disqualification; 384 395 385 396 if (!showHype || this.isQualified || disqualification == null) return; 386 397 ··· 403 414 } 404 415 405 416 private renderFeedbackButton() { 406 - if (core.currentUser == null || this.userIsOwner || this.props.beatmapset.can_be_hyped || this.props.beatmapset.discussion_locked) { 417 + if (core.currentUser == null || this.userIsOwner || this.beatmapset.can_be_hyped || this.beatmapset.discussion_locked) { 407 418 return null; 408 419 } 409 420 ··· 419 430 } 420 431 421 432 private renderHypeBar() { 422 - if (!this.props.beatmapset.can_be_hyped || this.props.beatmapset.hype == null) return; 433 + if (!this.beatmapset.can_be_hyped || this.beatmapset.hype == null) return; 423 434 424 - const requiredHype = this.props.beatmapset.hype.required; 425 - const hype = this.props.currentDiscussions.totalHype; 435 + const requiredHype = this.beatmapset.hype.required; 436 + const hype = this.props.discussionsState.totalHypeCount; 426 437 427 438 return ( 428 439 <div> ··· 438 449 } 439 450 440 451 private renderHypeButton() { 441 - if (!this.props.beatmapset.can_be_hyped || core.currentUser == null || this.userIsOwner) return; 442 - 443 - const currentUser = core.currentUser; // core.currentUser check below doesn't make the inferrence that it's not nullable after the check. 444 - const discussions = Object.values(this.props.currentDiscussions.byFilter.hype.generalAll); 445 - const userAlreadyHyped = currentUser != null && discussions.some((discussion) => discussion?.user_id === currentUser.id); 452 + if (!this.beatmapset.can_be_hyped || core.currentUser == null || this.userIsOwner) return; 446 453 447 454 return ( 448 455 <BigButton 449 - disabled={!this.props.beatmapset.current_user_attributes.can_hype} 456 + disabled={!this.beatmapset.current_user_attributes.can_hype} 450 457 icon='fas fa-bullhorn' 451 458 props={{ 452 459 onClick: this.focusHypeInput, 453 - title: this.props.beatmapset.current_user_attributes.can_hype_reason, 460 + title: this.beatmapset.current_user_attributes.can_hype_reason, 454 461 }} 455 - text={userAlreadyHyped ? trans('beatmaps.hype.button_done') : trans('beatmaps.hype.button')} 462 + text={this.props.discussionsState.hasCurrentUserHyped ? trans('beatmaps.hype.button_done') : trans('beatmaps.hype.button')} 456 463 /> 457 464 ); 458 465 } ··· 460 467 private renderLightsForNominations(nominations?: BeatmapsetNominationsInterface) { 461 468 if (nominations == null) return; 462 469 463 - const hybrid = Object.keys(this.props.beatmapset.nominations.required).length > 1; 470 + const hybrid = Object.keys(this.beatmapset.nominations.required).length > 1; 464 471 465 472 return ( 466 473 <div className={classWithModifiers(`${bn}__discrete-bar-group`, { hybrid })}> ··· 485 492 return ( 486 493 <Modal> 487 494 <LoveBeatmapDialog 488 - beatmapset={this.props.beatmapset} 495 + beatmapset={this.beatmapset} 496 + discussionsState={this.props.discussionsState} 489 497 onClose={this.handleToggleLoveBeatmapDialog} 490 498 /> 491 499 </Modal> ··· 493 501 } 494 502 495 503 private renderLoveButton() { 496 - if (!this.props.beatmapset.current_user_attributes.can_love) return; 504 + if (!this.beatmapset.current_user_attributes.can_love) return; 497 505 498 506 return ( 499 507 <BigButton ··· 508 516 } 509 517 510 518 private renderNominationBar() { 511 - const requiredHype = this.props.beatmapset.hype?.required ?? 0; // TODO: skip if null? 512 - const hypeRaw = this.props.currentDiscussions.totalHype; 513 - const mapCanBeNominated = this.props.beatmapset.status === 'pending' && hypeRaw >= requiredHype; 519 + const requiredHype = this.beatmapset.hype?.required ?? 0; // TODO: skip if null? 520 + const hypeRaw = this.props.discussionsState.totalHypeCount; 521 + const mapCanBeNominated = this.beatmapset.status === 'pending' && hypeRaw >= requiredHype; 514 522 515 523 if (!(mapCanBeNominated || this.isQualified)) return; 516 524 517 - const nominations = this.props.beatmapset.nominations; 525 + const nominations = this.beatmapset.nominations; 518 526 519 527 return ( 520 528 <div> ··· 528 536 } 529 537 530 538 private renderNominationResetMessage() { 531 - const nominationReset = this.props.beatmapset.nominations.nomination_reset; 539 + const nominationReset = this.beatmapset.nominations.nomination_reset; 532 540 533 - if (!this.props.beatmapset.can_be_hyped || this.isQualified || nominationReset == null) return; 541 + if (!this.beatmapset.can_be_hyped || this.isQualified || nominationReset == null) return; 534 542 535 543 return <div>{this.renderResetReason(nominationReset)}</div>; 536 544 } 537 545 538 546 private renderNominatorsList() { 539 - if (!nominatorsVisibleBeatmapStatuses.has(this.props.beatmapset.status)) return; 547 + if (!nominatorsVisibleBeatmapStatuses.has(this.beatmapset.status)) return; 540 548 541 549 const nominators: UserJson[] = []; 542 - for (let i = this.props.events.length - 1; i >= 0; i--) { 543 - const event = this.props.events[i]; 550 + for (let i = this.events.length - 1; i >= 0; i--) { 551 + const event = this.events[i]; 544 552 if (event.type === 'disqualify' || event.type === 'nomination_reset') { 545 553 break; 546 554 } 547 555 548 556 if (event.type === 'nominate' && event.user_id != null) { 549 - const user = this.props.users[event.user_id]; // for typing 557 + const user = this.users.get(event.user_id); 550 558 if (user != null) { 551 559 nominators.unshift(user); 552 560 } ··· 568 576 } 569 577 570 578 private renderRemoveFromLovedButton() { 571 - if (!this.props.beatmapset.current_user_attributes.can_remove_from_loved) return; 579 + if (!this.beatmapset.current_user_attributes.can_remove_from_loved) return; 572 580 573 581 return ( 574 582 <BigButton ··· 616 624 } 617 625 618 626 private renderStatusMessage() { 619 - switch (this.props.beatmapset.status) { 627 + switch (this.beatmapset.status) { 620 628 case 'approved': 621 629 case 'loved': 622 630 case 'ranked': 623 - return trans(`beatmaps.discussions.status-messages.${this.props.beatmapset.status}`, { date: formatDate(this.props.beatmapset.ranked_date) }); 631 + return trans(`beatmaps.discussions.status-messages.${this.beatmapset.status}`, { date: formatDate(this.beatmapset.ranked_date) }); 624 632 case 'graveyard': 625 - return trans('beatmaps.discussions.status-messages.graveyard', { date: formatDate(this.props.beatmapset.last_updated) }); 633 + return trans('beatmaps.discussions.status-messages.graveyard', { date: formatDate(this.beatmapset.last_updated) }); 626 634 case 'wip': 627 635 return trans('beatmaps.discussions.status-messages.wip'); 628 636 case 'qualified': { 629 - const rankingEta = this.props.beatmapset.nominations.ranking_eta; 637 + const rankingEta = this.beatmapset.nominations.ranking_eta; 630 638 const date = rankingEta != null 631 639 // TODO: remove after translations are updated 632 640 ? transExists('beatmaps.nominations.rank_estimate.on') ··· 639 647 mappings={{ 640 648 date, 641 649 // TODO: ranking_queue_position should not be nullable when status is qualified. 642 - position: formatNumber(this.props.beatmapset.nominations.ranking_queue_position ?? 0), 650 + position: formatNumber(this.beatmapset.nominations.ranking_queue_position ?? 0), 643 651 queue: ( 644 652 <a 645 653 href={wikiUrl('Beatmap_ranking_procedure/Ranking_queue')}
+41 -30
resources/js/beatmap-discussions/nominator.tsx
··· 3 3 4 4 import BigButton from 'components/big-button'; 5 5 import Modal from 'components/modal'; 6 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 6 7 import BeatmapsetEventJson from 'interfaces/beatmapset-event-json'; 7 8 import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 8 9 import GameMode from 'interfaces/game-mode'; 9 - import UserJson from 'interfaces/user-json'; 10 10 import { route } from 'laroute'; 11 11 import { forEachRight, map, uniq } from 'lodash'; 12 - import { action, computed, makeObservable, observable } from 'mobx'; 12 + import { action, computed, makeObservable, observable, runInAction } from 'mobx'; 13 13 import { observer } from 'mobx-react'; 14 14 import core from 'osu-core-singleton'; 15 15 import * as React from 'react'; ··· 17 17 import { isUserFullNominator } from 'utils/beatmapset-discussion-helper'; 18 18 import { classWithModifiers } from 'utils/css'; 19 19 import { trans } from 'utils/lang'; 20 + import DiscussionsState from './discussions-state'; 20 21 21 22 interface Props { 22 - beatmapset: BeatmapsetWithDiscussionsJson; 23 - currentHype: number; 24 - unresolvedIssues: number; 25 - users: Partial<Record<number, UserJson>>; 23 + discussionsState: DiscussionsState; 24 + store: BeatmapsetDiscussionsStore; 26 25 } 27 26 28 27 const bn = 'nomination-dialog'; ··· 35 34 @observable private visible = false; 36 35 private xhr?: JQuery.jqXHR<BeatmapsetWithDiscussionsJson>; 37 36 37 + private get beatmapset() { 38 + return this.props.discussionsState.beatmapset; 39 + } 40 + 41 + private get currentHype() { 42 + return this.props.discussionsState.totalHypeCount; 43 + } 44 + 38 45 private get mapCanBeNominated() { 39 - if (this.props.beatmapset.hype == null) { 46 + if (this.beatmapset.hype == null) { 40 47 return false; 41 48 } 42 49 43 - return this.props.beatmapset.status === 'pending' && this.props.currentHype >= this.props.beatmapset.hype.required; 50 + return this.beatmapset.status === 'pending' && this.currentHype >= this.beatmapset.hype.required; 44 51 } 45 52 46 53 private get nominationEvents() { 47 54 const nominations: BeatmapsetEventJson[] = []; 48 55 49 - forEachRight(this.props.beatmapset.events, (event) => { 56 + forEachRight(this.beatmapset.events, (event) => { 50 57 if (event.type === 'nomination_reset' || event.type === 'disqualify') { 51 58 return false; 52 59 } ··· 61 68 62 69 @computed 63 70 private get playmodes() { 64 - return this.props.beatmapset.nominations.legacy_mode 71 + return this.beatmapset.nominations.legacy_mode 65 72 ? null 66 - : Object.keys(this.props.beatmapset.nominations.required) as GameMode[]; 73 + : Object.keys(this.beatmapset.nominations.required) as GameMode[]; 74 + } 75 + 76 + private get users() { 77 + return this.props.store.users; 67 78 } 68 79 69 80 private get userCanNominate() { ··· 71 82 return false; 72 83 } 73 84 74 - const nominationModes = this.playmodes ?? uniq(this.props.beatmapset.beatmaps.map((bm) => bm.mode)); 85 + const nominationModes = this.playmodes ?? uniq(this.beatmapset.beatmaps.map((bm) => bm.mode)); 75 86 76 87 return nominationModes.some((mode) => this.userCanNominateMode(mode)); 77 88 } ··· 84 95 private get userIsOwner() { 85 96 const userId = core.currentUserOrFail.id; 86 97 87 - return userId === this.props.beatmapset.user_id 88 - || this.props.beatmapset.beatmaps.some((beatmap) => beatmap.deleted_at == null && userId === beatmap.user_id); 98 + return userId === this.beatmapset.user_id 99 + || this.beatmapset.beatmaps.some((beatmap) => beatmap.deleted_at == null && userId === beatmap.user_id); 89 100 } 90 101 91 102 private get userNominatableModes() { ··· 93 104 return {}; 94 105 } 95 106 96 - return this.props.beatmapset.current_user_attributes.nomination_modes ?? {}; 107 + return this.beatmapset.current_user_attributes.nomination_modes ?? {}; 97 108 } 98 109 99 110 constructor(props: Props) { ··· 119 130 120 131 private hasFullNomination(mode: GameMode) { 121 132 return this.nominationEvents.some((event) => { 122 - const user = event.user_id != null ? this.props.users[event.user_id] : null; 133 + const user = this.users.get(event.user_id); 123 134 124 135 return event.type === 'nominate' && event.comment != null 125 136 ? event.comment.modes.includes(mode) && isUserFullNominator(user, mode) ··· 138 149 139 150 this.loading = true; 140 151 141 - const url = route('beatmapsets.nominate', { beatmapset: this.props.beatmapset.id }); 152 + const url = route('beatmapsets.nominate', { beatmapset: this.beatmapset.id }); 142 153 const params = { 143 154 data: { 144 155 playmodes: this.playmodes != null && this.playmodes.length === 1 ? this.playmodes : this.selectedModes, ··· 147 158 }; 148 159 149 160 this.xhr = $.ajax(url, params); 150 - this.xhr.done((response) => { 151 - $.publish('beatmapsetDiscussions:update', { beatmapset: response }); 161 + this.xhr.done((beatmapset) => runInAction(() => { 162 + this.props.discussionsState.update({ beatmapset }); 152 163 this.hideNominationModal(); 153 - }) 164 + })) 154 165 .fail(onError) 155 166 .always(action(() => this.loading = false)); 156 167 }; 157 168 158 169 private nominationCountMet(mode: GameMode) { 159 - if (this.props.beatmapset.nominations.legacy_mode || this.props.beatmapset.nominations.required[mode] === 0) { 170 + if (this.beatmapset.nominations.legacy_mode || this.beatmapset.nominations.required[mode] === 0) { 160 171 return false; 161 172 } 162 173 163 - const req = this.props.beatmapset.nominations.required[mode]; 164 - const curr = this.props.beatmapset.nominations.current[mode] ?? 0; 174 + const req = this.beatmapset.nominations.required[mode]; 175 + const curr = this.beatmapset.nominations.current[mode] ?? 0; 165 176 166 177 if (req == null) { 167 178 return false; ··· 176 187 } 177 188 178 189 let tooltipText: string | undefined; 179 - if (this.props.unresolvedIssues > 0) { 190 + if (this.props.discussionsState.unresolvedDiscussionTotalCount > 0) { 180 191 tooltipText = trans('beatmaps.nominations.unresolved_issues'); 181 - } else if (this.props.beatmapset.nominations.nominated) { 192 + } else if (this.beatmapset.nominations.nominated) { 182 193 tooltipText = trans('beatmaps.nominations.already_nominated'); 183 194 } else if (!this.userCanNominate) { 184 195 tooltipText = trans('beatmaps.nominations.cannot_nominate'); ··· 276 287 let req: number; 277 288 let curr: number; 278 289 279 - if (this.props.beatmapset.nominations.legacy_mode) { 280 - req = this.props.beatmapset.nominations.required; 281 - curr = this.props.beatmapset.nominations.current; 290 + if (this.beatmapset.nominations.legacy_mode) { 291 + req = this.beatmapset.nominations.required; 292 + curr = this.beatmapset.nominations.current; 282 293 } else { 283 - req = this.props.beatmapset.nominations.required[mode] ?? 0; 284 - curr = this.props.beatmapset.nominations.current[mode] ?? 0; 294 + req = this.beatmapset.nominations.required[mode] ?? 0; 295 + curr = this.beatmapset.nominations.current[mode] ?? 0; 285 296 } 286 297 287 298 return (curr === req - 1) && !this.hasFullNomination(mode);
+90 -88
resources/js/beatmap-discussions/post.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context'; 5 - import { DiscussionsContext } from 'beatmap-discussions/discussions-context'; 6 4 import Editor from 'beatmap-discussions/editor'; 7 5 import { ReviewPost } from 'beatmap-discussions/review-post'; 8 6 import BigButton from 'components/big-button'; ··· 12 10 import TextareaAutosize from 'components/textarea-autosize'; 13 11 import TimeWithTooltip from 'components/time-with-tooltip'; 14 12 import UserLink from 'components/user-link'; 15 - import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 16 13 import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 17 14 import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json'; 18 - import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 19 - import BeatmapsetJson from 'interfaces/beatmapset-json'; 15 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 20 16 import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 21 17 import UserJson from 'interfaces/user-json'; 22 18 import { route } from 'laroute'; 23 19 import { isEqual } from 'lodash'; 24 20 import { action, autorun, computed, makeObservable, observable, runInAction } from 'mobx'; 25 21 import { disposeOnUnmount, observer } from 'mobx-react'; 26 - import { deletedUser, deletedUserJson } from 'models/user'; 22 + import { deletedUserJson } from 'models/user'; 27 23 import core from 'osu-core-singleton'; 28 24 import * as React from 'react'; 29 25 import { onError } from 'utils/ajax'; ··· 34 30 import { trans } from 'utils/lang'; 35 31 import DiscussionMessage from './discussion-message'; 36 32 import DiscussionMessageLengthCounter from './discussion-message-length-counter'; 33 + import DiscussionsState from './discussions-state'; 37 34 import { UserCard } from './user-card'; 38 35 39 36 const bn = 'beatmap-discussion-post'; 40 37 41 38 interface Props { 42 - beatmap: BeatmapExtendedJson | null; 43 - beatmapset: BeatmapsetJson | BeatmapsetExtendedJson; 44 39 discussion: BeatmapsetDiscussionJson; 40 + discussionsState: DiscussionsState | null; // TODO: make optional 45 41 post: BeatmapsetDiscussionMessagePostJson; 46 42 read: boolean; 47 43 readonly: boolean; 48 - resolvedSystemPostId: number; 44 + resolvedStateChangedPostId: number; 45 + store: BeatmapsetDiscussionsStore; 49 46 type: string; 50 47 user: UserJson; 51 - users: Partial<Record<number, UserJson>>; 52 48 } 53 49 54 50 @observer 55 51 export default class Post extends React.Component<Props> { 52 + static defaultProps = { 53 + resolvedStateChangedPostId: -1, 54 + }; 55 + 56 56 @observable private canSave = true; // this isn't computed because Editor's onChange doesn't provide anything to react to. 57 57 @observable private editing = false; 58 58 private readonly handleTextareaKeyDown; ··· 63 63 private readonly textareaRef = React.createRef<HTMLTextAreaElement>(); 64 64 @observable private xhr: JQuery.jqXHR<BeatmapsetWithDiscussionsJson> | null = null; 65 65 66 + private get beatmap() { 67 + return this.props.discussionsState?.currentBeatmap; 68 + } 69 + 70 + private get beatmapset() { 71 + return this.props.discussionsState?.beatmapset; 72 + } 73 + 74 + private get users() { 75 + return this.props.store.users; 76 + } 77 + 66 78 @computed 67 79 private get canEdit() { 68 80 // no information available (non-discussion pages), return false. 69 - if (!('discussion_locked' in this.props.beatmapset)) { 81 + if (this.beatmapset == null) { 70 82 return false; 71 83 } 72 84 73 85 return this.isAdmin 74 - || (!downloadLimited(this.props.beatmapset) 86 + || (!downloadLimited(this.beatmapset) 75 87 && this.isOwn 76 - && this.props.post.id > this.props.resolvedSystemPostId 77 - && !this.props.beatmapset.discussion_locked 88 + && this.props.post.id > this.props.resolvedStateChangedPostId 89 + && !this.beatmapset.discussion_locked 78 90 ); 79 91 } 80 92 ··· 157 169 {this.props.type === 'reply' && ( 158 170 <UserCard 159 171 group={badgeGroup({ 160 - beatmapset: this.props.beatmapset, 161 - currentBeatmap: this.props.beatmap, 172 + beatmapset: this.beatmapset, 173 + currentBeatmap: this.beatmap, 162 174 discussion: this.props.discussion, 163 175 user: this.props.user, 164 176 })} ··· 200 212 }; 201 213 202 214 private readonly handleMarkRead = () => { 203 - $.publish('beatmapDiscussionPost:markRead', { id: this.props.post.id }); 215 + this.props.discussionsState?.markAsRead(this.props.post.id); 204 216 }; 205 217 206 218 @action ··· 217 229 218 230 private renderDeletedBy() { 219 231 if (this.deleteModel.deleted_at == null) return null; 220 - const user = ( 221 - this.deleteModel.deleted_by_id != null 222 - ? this.props.users[this.deleteModel.deleted_by_id] 223 - : null 224 - ) ?? deletedUser; 232 + const user = this.users.get(this.deleteModel.deleted_by_id) ?? deletedUserJson; 225 233 226 234 return ( 227 235 <span className={`${bn}__info`}> ··· 247 255 return null; 248 256 } 249 257 250 - const lastEditor = this.props.users[this.props.post.last_editor_id] ?? deletedUserJson; 258 + const lastEditor = this.users.get(this.props.post.last_editor_id) ?? deletedUserJson; 251 259 252 260 return ( 253 261 <span className={`${bn}__info`}> ··· 277 285 } 278 286 279 287 private renderMessageEditor() { 280 - if (!this.canEdit) return; 288 + if (this.props.discussionsState == null || !this.canEdit) return; 281 289 const canPost = !this.isPosting && this.canSave; 282 290 283 291 const document = this.props.post.message; ··· 285 293 return ( 286 294 <div className={`${bn}__message-container`}> 287 295 {this.isReview ? ( 288 - <DiscussionsContext.Consumer> 289 - {(discussions) => ( 290 - <BeatmapsContext.Consumer> 291 - {(beatmaps) => ( 292 - <Editor 293 - ref={this.reviewEditorRef} 294 - beatmaps={beatmaps} 295 - beatmapset={this.props.beatmapset} 296 - currentBeatmap={this.props.beatmap} 297 - discussion={this.props.discussion} 298 - discussions={discussions} 299 - document={document} 300 - editing={this.editing} 301 - onChange={this.handleEditorChange} 302 - /> 303 - )} 304 - </BeatmapsContext.Consumer> 305 - )} 306 - </DiscussionsContext.Consumer> 296 + <Editor 297 + ref={this.reviewEditorRef} 298 + discussion={this.props.discussion} 299 + discussionsState={this.props.discussionsState} 300 + document={document} 301 + editing={this.editing} 302 + onChange={this.handleEditorChange} 303 + store={this.props.store} 304 + /> 307 305 ) : ( 308 306 <> 309 307 <TextareaAutosize ··· 347 345 <div className={`${bn}__message-container`}> 348 346 {this.isReview ? ( 349 347 <div className={`${bn}__message`}> 350 - <ReviewPost post={this.props.post} /> 348 + <ReviewPost post={this.props.post} store={this.props.store} /> 351 349 </div> 352 350 ) : ( 353 351 <div ref={this.messageBodyRef} className={`${bn}__message`}> ··· 373 371 ); 374 372 } 375 373 376 - 377 374 private renderMessageViewerActions() { 378 375 return ( 379 376 <div className={`${bn}__actions`}> ··· 386 383 /> 387 384 </span> 388 385 389 - {!this.props.readonly && ( 390 - <> 391 - {this.canEdit && ( 392 - <button 393 - className={`${bn}__action ${bn}__action--button`} 394 - onClick={this.editStart} 395 - > 396 - {trans('beatmaps.discussions.edit')} 397 - </button> 398 - )} 399 - 400 - {this.deleteModel.deleted_at == null && this.canDelete && ( 401 - <a 402 - className={`js-beatmapset-discussion-update ${bn}__action ${bn}__action--button`} 403 - data-confirm={trans('common.confirmation')} 404 - data-method='DELETE' 405 - data-remote 406 - href={this.deleteHref('destroy')} 407 - > 408 - {trans('beatmaps.discussions.delete')} 409 - </a> 410 - )} 411 - 412 - {this.deleteModel.deleted_at != null && this.canModerate && ( 413 - <a 414 - className={`js-beatmapset-discussion-update ${bn}__action ${bn}__action--button`} 415 - data-confirm={trans('common.confirmation')} 416 - data-method='POST' 417 - data-remote 418 - href={this.deleteHref('restore')} 419 - > 420 - {trans('beatmaps.discussions.restore')} 421 - </a> 422 - )} 423 - 424 - {this.props.type === 'discussion' && this.props.discussion.current_user_attributes?.can_moderate_kudosu && ( 425 - this.props.discussion.can_grant_kudosu 426 - ? this.renderKudosuAction('deny') 427 - : this.props.discussion.kudosu_denied && this.renderKudosuAction('allow') 428 - )} 429 - </> 430 - )} 386 + {this.renderMessageViewerEditingActions()} 431 387 432 388 {this.canReport && ( 433 389 <ReportReportable ··· 442 398 ); 443 399 } 444 400 401 + private renderMessageViewerEditingActions() { 402 + if (this.props.readonly || this.props.discussionsState == null) return; 403 + 404 + return ( 405 + <> 406 + {this.canEdit && ( 407 + <button 408 + className={`${bn}__action ${bn}__action--button`} 409 + onClick={this.editStart} 410 + > 411 + {trans('beatmaps.discussions.edit')} 412 + </button> 413 + )} 414 + 415 + {this.deleteModel.deleted_at == null && this.canDelete && ( 416 + <a 417 + className={`js-beatmapset-discussion-update ${bn}__action ${bn}__action--button`} 418 + data-confirm={trans('common.confirmation')} 419 + data-method='DELETE' 420 + data-remote 421 + href={this.deleteHref('destroy')} 422 + > 423 + {trans('beatmaps.discussions.delete')} 424 + </a> 425 + )} 426 + 427 + {this.deleteModel.deleted_at != null && this.canModerate && ( 428 + <a 429 + className={`js-beatmapset-discussion-update ${bn}__action ${bn}__action--button`} 430 + data-confirm={trans('common.confirmation')} 431 + data-method='POST' 432 + data-remote 433 + href={this.deleteHref('restore')} 434 + > 435 + {trans('beatmaps.discussions.restore')} 436 + </a> 437 + )} 438 + 439 + {this.props.type === 'discussion' && this.props.discussion.current_user_attributes?.can_moderate_kudosu && ( 440 + this.props.discussion.can_grant_kudosu 441 + ? this.renderKudosuAction('deny') 442 + : this.props.discussion.kudosu_denied && this.renderKudosuAction('allow') 443 + )} 444 + </> 445 + ); 446 + } 445 447 446 448 @action 447 449 private readonly updatePost = () => { ··· 481 483 482 484 this.xhr.done((beatmapset) => runInAction(() => { 483 485 this.editing = false; 484 - $.publish('beatmapsetDiscussions:update', { beatmapset }); 486 + this.props.discussionsState?.update({ beatmapset }); 485 487 })) 486 488 .fail(onError) 487 489 .always(action(() => this.xhr = null));
+5 -5
resources/js/beatmap-discussions/review-document.ts
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; 4 + import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 5 5 import remarkParse from 'remark-parse'; 6 6 import disableConstructs from 'remark-plugins/disable-constructs'; 7 7 import { Element, Text } from 'slate'; ··· 30 30 return node.type === 'text'; 31 31 } 32 32 33 - export function parseFromJson(json: string, discussions: Partial<Record<number, BeatmapsetDiscussionJsonForBundle | BeatmapsetDiscussionJsonForShow>>) { 33 + export function parseFromJson(json: string, discussions: Map<number | null | undefined, BeatmapsetDiscussionJson>) { 34 34 let srcDoc: BeatmapDiscussionReview; 35 35 36 36 try { ··· 87 87 case 'embed': { 88 88 // embed 89 89 const existingEmbedBlock = block as PersistedDocumentIssueEmbed; 90 - const discussion = discussions[existingEmbedBlock.discussion_id]; 90 + const discussion = discussions.get(existingEmbedBlock.discussion_id); 91 91 if (discussion == null) { 92 92 console.error('unknown/external discussion referenced', existingEmbedBlock.discussion_id); 93 93 break; ··· 99 99 } 100 100 101 101 const post = startingPost(discussion); 102 - if (post.system) { 103 - console.error('embed should not have system starting post', existingEmbedBlock.discussion_id); 102 + if (post == null || post.system) { 103 + console.error('embed starting post is missing or is system post', existingEmbedBlock.discussion_id); 104 104 break; 105 105 } 106 106
+11 -11
resources/js/beatmap-discussions/review-post-embed.tsx
··· 4 4 import { discussionTypeIcons } from 'beatmap-discussions/discussion-type'; 5 5 import { BeatmapIcon } from 'components/beatmap-icon'; 6 6 import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 7 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 7 8 import * as React from 'react'; 8 9 import { formatTimestamp, makeUrl, startingPost } from 'utils/beatmapset-discussion-helper'; 9 10 import { classWithModifiers } from 'utils/css'; 10 11 import { trans } from 'utils/lang'; 11 - import { BeatmapsContext } from './beatmaps-context'; 12 12 import DiscussionMessage from './discussion-message'; 13 - import { DiscussionsContext } from './discussions-context'; 14 13 15 14 interface Props { 16 15 data: { 17 16 discussion_id: number; 18 17 }; 18 + store: BeatmapsetDiscussionsStore; 19 19 } 20 20 21 21 export function postEmbedModifiers(discussion: BeatmapsetDiscussionJson) { ··· 27 27 }; 28 28 } 29 29 30 - export const ReviewPostEmbed = ({ data }: Props) => { 31 - const bn = 'beatmap-discussion-review-post-embed-preview'; 32 - const discussions = React.useContext(DiscussionsContext); 33 - const beatmaps = React.useContext(BeatmapsContext); 34 - const discussion = discussions[data.discussion_id]; 30 + const bn = 'beatmap-discussion-review-post-embed-preview'; 35 31 36 - if (!discussion) { 32 + export const ReviewPostEmbed = ({ data, store }: Props) => { 33 + const beatmaps = store.beatmaps; 34 + const discussion = store.discussions.get(data.discussion_id); 35 + 36 + if (discussion == null) { 37 37 // if a discussion has been deleted or is otherwise missing 38 38 return ( 39 39 <div className={classWithModifiers(bn, ['deleted', 'lighter'])}> ··· 43 43 } 44 44 45 45 const post = startingPost(discussion); 46 - if (post.system) { 47 - console.error('embed should not have system starting post', discussion.id); 46 + if (post == null || post.system) { 47 + console.error('embed starting post is missing or is system post', discussion.id); 48 48 return null; 49 49 } 50 50 51 - const beatmap = discussion.beatmap_id == null ? undefined : beatmaps[discussion.beatmap_id]; 51 + const beatmap = discussion.beatmap_id == null ? undefined : beatmaps.get(discussion.beatmap_id); 52 52 53 53 const messageTypeIcon = () => { 54 54 const type = discussion.message_type;
+3 -1
resources/js/beatmap-discussions/review-post.tsx
··· 3 3 4 4 import { PersistedBeatmapDiscussionReview } from 'interfaces/beatmap-discussion-review'; 5 5 import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json'; 6 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 6 7 import * as React from 'react'; 7 8 import DiscussionMessage from './discussion-message'; 8 9 import { ReviewPostEmbed } from './review-post-embed'; 9 10 10 11 interface Props { 11 12 post: BeatmapsetDiscussionMessagePostJson; 13 + store: BeatmapsetDiscussionsStore; 12 14 } 13 15 14 16 export class ReviewPost extends React.Component<Props> { ··· 27 29 } 28 30 case 'embed': 29 31 if (block.discussion_id) { 30 - docBlocks.push(<ReviewPostEmbed key={index} data={{ discussion_id: block.discussion_id }} />); 32 + docBlocks.push(<ReviewPostEmbed key={index} data={{ discussion_id: block.discussion_id }} store={this.props.store} />); 31 33 } 32 34 break; 33 35 }
+3 -1
resources/js/beatmap-discussions/subscribe.tsx
··· 9 9 import * as React from 'react'; 10 10 import { onError } from 'utils/ajax'; 11 11 import { trans } from 'utils/lang'; 12 + import DiscussionsState from './discussions-state'; 12 13 13 14 interface Props { 14 15 beatmapset: BeatmapsetJson; 16 + discussionsState: DiscussionsState; 15 17 } 16 18 17 19 @observer ··· 60 62 }); 61 63 62 64 this.xhr.done(() => { 63 - $.publish('beatmapsetDiscussions:update', { watching: !this.isWatching }); 65 + this.props.discussionsState.update({ watching: !this.isWatching }); 64 66 }) 65 67 .fail(onError) 66 68 .always(action(() => this.xhr = null));
+86
resources/js/beatmap-discussions/type-filters.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import { kebabCase, snakeCase } from 'lodash'; 5 + import { computed } from 'mobx'; 6 + import { observer } from 'mobx-react'; 7 + import core from 'osu-core-singleton'; 8 + import * as React from 'react'; 9 + import { makeUrl } from 'utils/beatmapset-discussion-helper'; 10 + import { classWithModifiers } from 'utils/css'; 11 + import { trans } from 'utils/lang'; 12 + import { Filter } from './current-discussions'; 13 + import DiscussionsState from './discussions-state'; 14 + 15 + interface Props { 16 + discussionsState: DiscussionsState; 17 + } 18 + 19 + const bn = 'counter-box'; 20 + const statTypes: Filter[] = ['mine', 'mapperNotes', 'resolved', 'pending', 'praises', 'deleted', 'total']; 21 + 22 + @observer 23 + export default class TypeFilters extends React.Component<Props> { 24 + @computed 25 + private get discussionCounts() { 26 + const counts: Partial<Record<Filter, number>> = {}; 27 + const selectedUserId = this.props.discussionsState.selectedUserId; 28 + 29 + for (const type of statTypes) { 30 + let discussions = this.props.discussionsState.discussionsByFilter[type]; 31 + if (selectedUserId != null) { 32 + discussions = discussions.filter((discussion) => discussion.user_id === selectedUserId); 33 + } 34 + 35 + counts[type] = discussions.length; 36 + } 37 + 38 + return counts; 39 + } 40 + 41 + render() { 42 + return statTypes.map(this.renderType); 43 + } 44 + 45 + private readonly renderType = (type: Filter) => { 46 + if ((type === 'deleted') && !core.currentUser?.is_admin) { 47 + return null; 48 + } 49 + 50 + let topClasses = classWithModifiers(bn, 'beatmap-discussions', kebabCase(type)); 51 + if (this.props.discussionsState.currentPage !== 'events' && this.props.discussionsState.currentFilter === type) { 52 + topClasses += ' js-active'; 53 + } 54 + 55 + return ( 56 + <a 57 + key={type} 58 + className={topClasses} 59 + data-type={type} 60 + href={makeUrl({ 61 + beatmapId: this.props.discussionsState.currentBeatmap.id, 62 + beatmapsetId: this.props.discussionsState.beatmapset.id, 63 + filter: type, 64 + mode: this.props.discussionsState.currentPage, 65 + })} 66 + onClick={this.setFilter} 67 + > 68 + <div className={`${bn}__content`}> 69 + <div className={`${bn}__title`}> 70 + {trans(`beatmaps.discussions.stats.${snakeCase(type)}`)} 71 + </div> 72 + <div className={`${bn}__count`}> 73 + {this.discussionCounts[type]} 74 + </div> 75 + </div> 76 + <div className={`${bn}__line`} /> 77 + </a> 78 + ); 79 + }; 80 + 81 + private readonly setFilter = (event: React.SyntheticEvent<HTMLElement>) => { 82 + event.preventDefault(); 83 + this.props.discussionsState.changeFilter(event.currentTarget.dataset.type); 84 + }; 85 + } 86 +
+51 -12
resources/js/beatmap-discussions/user-filter.tsx
··· 3 3 4 4 import mapperGroup from 'beatmap-discussions/mapper-group'; 5 5 import SelectOptions, { OptionRenderProps } from 'components/select-options'; 6 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 6 7 import UserJson from 'interfaces/user-json'; 8 + import { action, computed, makeObservable } from 'mobx'; 9 + import { observer } from 'mobx-react'; 10 + import { usernameSortAscending } from 'models/user'; 7 11 import * as React from 'react'; 12 + import { mobxArrayGet } from 'utils/array'; 8 13 import { makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper'; 9 14 import { groupColour } from 'utils/css'; 10 15 import { trans } from 'utils/lang'; 16 + import DiscussionsState from './discussions-state'; 11 17 12 18 const allUsers = Object.freeze({ 13 19 id: null, ··· 21 27 22 28 interface Option { 23 29 groups: UserJson['groups']; 24 - id: UserJson['id']; 30 + id: UserJson['id'] | null; 25 31 text: UserJson['username']; 26 32 } 27 33 28 34 interface Props { 29 - ownerId: number; 30 - selectedUser?: UserJson | null; 31 - users: UserJson[]; 35 + discussionsState: DiscussionsState; 36 + store: BeatmapsetDiscussionsStore; 32 37 } 33 38 34 39 function mapUserProperties(user: UserJson): Option { ··· 39 44 }; 40 45 } 41 46 47 + @observer 42 48 export class UserFilter extends React.Component<Props> { 49 + private get ownerId() { 50 + return this.props.discussionsState.beatmapset.user_id; 51 + } 52 + 53 + @computed 43 54 private get selected() { 44 - return this.props.selectedUser != null 45 - ? mapUserProperties(this.props.selectedUser) 55 + return this.props.discussionsState.selectedUser != null 56 + ? mapUserProperties(this.props.discussionsState.selectedUser) 46 57 : noSelection; 47 58 } 48 59 60 + @computed 49 61 private get options() { 50 - return [allUsers, ...this.props.users.map(mapUserProperties)]; 62 + const usersWithDicussions = new Map<number, UserJson>(); 63 + for (const [, discussion] of this.props.store.discussions) { 64 + if (discussion.message_type === 'hype') continue; 65 + 66 + const user = this.props.store.users.get(discussion.user_id); 67 + if (user != null && !usersWithDicussions.has(user.id)) { 68 + usersWithDicussions.set(user.id, user); 69 + } 70 + } 71 + 72 + return [ 73 + allUsers, 74 + ...[...usersWithDicussions.values()] 75 + .sort(usernameSortAscending) 76 + .map(mapUserProperties), 77 + ]; 78 + } 79 + 80 + constructor(props: Props) { 81 + super(props); 82 + makeObservable(this); 51 83 } 52 84 53 85 render() { ··· 62 94 ); 63 95 } 64 96 97 + private getGroup(option: Option) { 98 + if (this.isOwner(option)) return mapperGroup; 99 + 100 + return mobxArrayGet(option.groups, 0); 101 + } 102 + 103 + @action 65 104 private readonly handleChange = (option: Option) => { 66 - $.publish('beatmapsetDiscussions:update', { selectedUserId: option.id }); 105 + this.props.discussionsState.selectedUserId = option.id; 67 106 }; 68 107 69 108 private isOwner(user?: Option) { 70 - return user != null && user.id === this.props.ownerId; 109 + return user != null && user.id === this.ownerId; 71 110 } 72 111 73 112 private readonly renderOption = ({ cssClasses, children, onClick, option }: OptionRenderProps<Option>) => { 74 - const group = this.isOwner(option) ? mapperGroup : option.groups?.[0]; 113 + const group = this.getGroup(option); 75 114 const style = groupColour(group); 76 115 77 116 const urlOptions = parseUrl(); 78 117 // means it doesn't work on non-beatmapset discussion paths 79 118 if (urlOptions == null) return null; 80 119 81 - urlOptions.user = option?.id; 120 + urlOptions.user = option.id ?? undefined; 82 121 83 122 return ( 84 123 <a 85 - key={option?.id} 124 + key={option.id} 86 125 className={cssClasses} 87 126 href={makeUrl(urlOptions)} 88 127 onClick={onClick}
+2 -2
resources/js/beatmapsets-show/scoreboard/table-row.tsx
··· 16 16 import { classWithModifiers, Modifiers } from 'utils/css'; 17 17 import { formatNumber } from 'utils/html'; 18 18 import { trans } from 'utils/lang'; 19 - import { hasMenu, isPerfectCombo, modeAttributesMap, scoreUrl, totalScore } from 'utils/score-helper'; 19 + import { filterMods, hasMenu, isPerfectCombo, modeAttributesMap, scoreUrl, totalScore } from 'utils/score-helper'; 20 20 21 21 const bn = 'beatmap-scoreboard-table'; 22 22 ··· 149 149 150 150 <TdLink href={this.scoreUrl} modifiers='mods'> 151 151 <div className={`${bn}__mods`}> 152 - {score.mods.map((mod) => <Mod key={mod.acronym} mod={mod} />)} 152 + {filterMods(score).map((mod) => <Mod key={mod.acronym} mod={mod} />)} 153 153 </div> 154 154 </TdLink> 155 155
+2 -2
resources/js/beatmapsets-show/scoreboard/top-card.tsx
··· 19 19 import { classWithModifiers, Modifiers } from 'utils/css'; 20 20 import { formatNumber } from 'utils/html'; 21 21 import { trans } from 'utils/lang'; 22 - import { isPerfectCombo, modeAttributesMap, scoreUrl, totalScore } from 'utils/score-helper'; 22 + import { filterMods, isPerfectCombo, modeAttributesMap, scoreUrl, totalScore } from 'utils/score-helper'; 23 23 24 24 interface Props { 25 25 beatmap: BeatmapJson; ··· 182 182 {trans('beatmapsets.show.scoreboard.headers.mods')} 183 183 </div> 184 184 <div className='beatmap-score-top__stat-value beatmap-score-top__stat-value--mods u-hover'> 185 - {this.props.score.mods.map((mod) => <Mod key={mod.acronym} mod={mod} />)} 185 + {filterMods(this.props.score).map((mod) => <Mod key={mod.acronym} mod={mod} />)} 186 186 </div> 187 187 </div> 188 188 </div>
+8 -9
resources/js/components/beatmapset-event.tsx
··· 9 9 import UserJson from 'interfaces/user-json'; 10 10 import { route } from 'laroute'; 11 11 import { kebabCase } from 'lodash'; 12 - import { deletedUser } from 'models/user'; 12 + import { deletedUser, deletedUserJson } from 'models/user'; 13 13 import * as React from 'react'; 14 14 import { makeUrl } from 'utils/beatmapset-discussion-helper'; 15 15 import { classWithModifiers } from 'utils/css'; ··· 27 27 export type EventViewMode = 'discussions' | 'profile' | 'list'; 28 28 29 29 interface Props { 30 - discussions?: Partial<Record<string, BeatmapsetDiscussionJson>>; 30 + discussions?: Map<number | null | undefined, BeatmapsetDiscussionJson>; 31 31 event: BeatmapsetEventJson; 32 32 mode: EventViewMode; 33 33 time?: string; 34 - users: Partial<Record<string, UserJson>>; 34 + users: Map<number | null | undefined, UserJson>; 35 35 } 36 36 37 37 export default class BeatmapsetEvent extends React.PureComponent<Props> { ··· 48 48 49 49 // discussion page doesn't include the discussion as part of the event. 50 50 private get discussion() { 51 - return this.props.event.discussion ?? this.props.discussions?.[this.discussionId ?? '']; 51 + return this.props.event.discussion ?? this.props.discussions?.get(this.discussionId); 52 52 } 53 53 54 54 private get firstPost() { ··· 129 129 url = makeUrl({ discussion: this.discussion }); 130 130 text = firstPostMessage != null ? <PlainTextPreview markdown={firstPostMessage} /> : '[no preview]'; 131 131 132 - const discussionUser = this.props.users[this.discussion.user_id]; 132 + const discussionUser = this.props.users.get(this.discussion.user_id) ?? deletedUserJson; 133 133 134 - if (discussionUser != null) { 135 - discussionUserLink = <UserLink user={discussionUser} />; 136 - } 134 + // TODO: remove link for deleted user? 135 + discussionUserLink = <UserLink user={discussionUser} />; 137 136 } 138 137 139 138 discussionLink = <a className='js-beatmap-discussion--jump' href={url}>{`#${this.discussionId}`}</a>; ··· 148 147 } 149 148 150 149 if (this.props.event.user_id != null) { 151 - const userData = this.props.users[this.props.event.user_id]; 150 + const userData = this.props.users.get(this.props.event.user_id); 152 151 user = userData != null ? <UserLink user={userData} /> : deletedUser.username; 153 152 } 154 153
+1 -1
resources/js/components/beatmapset-events.tsx
··· 9 9 export interface Props { 10 10 events: BeatmapsetEventJson[]; 11 11 mode: EventViewMode; 12 - users: Partial<Record<string, UserJson>>; 12 + users: Map<number | null | undefined, UserJson>; 13 13 } 14 14 15 15 export default class BeatmapsetEvents extends React.PureComponent<Props> {
+1 -1
resources/js/entrypoints/beatmap-discussions-history.tsx
··· 8 8 import { parseJson } from 'utils/json'; 9 9 10 10 core.reactTurbolinks.register('beatmap-discussions-history', () => ( 11 - <Main bundle={parseJson<BeatmapsetDiscussionsBundleJson>('json-index')} /> 11 + <Main {...parseJson<BeatmapsetDiscussionsBundleJson>('json-index')} /> 12 12 ));
-12
resources/js/entrypoints/beatmap-discussions.coffee
··· 1 - # Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - # See the LICENCE file in the repository root for full licence text. 3 - 4 - import core from 'osu-core-singleton' 5 - import { createElement } from 'react' 6 - import { parseJson } from 'utils/json' 7 - import { Main } from 'beatmap-discussions/main' 8 - 9 - core.reactTurbolinks.register 'beatmap-discussions', (container) -> 10 - createElement Main, 11 - initial: parseJson 'json-beatmapset-discussion' 12 - container: container
+12
resources/js/entrypoints/beatmap-discussions.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import Main from 'beatmap-discussions/main'; 5 + import core from 'osu-core-singleton'; 6 + import React from 'react'; 7 + import { parseJson } from 'utils/json'; 8 + 9 + 10 + core.reactTurbolinks.register('beatmap-discussions', () => ( 11 + <Main reviewsConfig={parseJson('json-reviews_config')} /> 12 + ));
+2 -12
resources/js/entrypoints/modding-profile.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 + import { BeatmapsetDiscussionsBundleJsonForModdingProfile } from 'interfaces/beatmapset-discussions-bundle-json'; 4 5 import Main from 'modding-profile/main'; 5 6 import core from 'osu-core-singleton'; 6 7 import React from 'react'; 7 8 import { parseJson } from 'utils/json'; 8 9 9 10 core.reactTurbolinks.register('modding-profile', () => ( 10 - <Main 11 - beatmaps={parseJson('json-beatmaps')} 12 - beatmapsets={parseJson('json-beatmapsets')} 13 - discussions={parseJson('json-discussions')} 14 - events={parseJson('json-events')} 15 - extras={parseJson('json-extras')} 16 - perPage={parseJson('json-perPage')} 17 - posts={parseJson('json-posts')} 18 - user={parseJson('json-user')} 19 - users={parseJson('json-users')} 20 - votes={parseJson('json-votes')} 21 - /> 11 + <Main {...parseJson<BeatmapsetDiscussionsBundleJsonForModdingProfile>('json-bundle')} /> 22 12 ));
+22
resources/js/interfaces/beatmapset-discussions-bundle-json.ts
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 + import { Direction, VoteSummary } from 'modding-profile/votes'; 4 5 import BeatmapExtendedJson from './beatmap-extended-json'; 5 6 import { BeatmapsetDiscussionJsonForBundle } from './beatmapset-discussion-json'; 7 + import { BeatmapsetDiscussionMessagePostJson } from './beatmapset-discussion-post-json'; 8 + import BeatmapsetEventJson from './beatmapset-event-json'; 6 9 import BeatmapsetExtendedJson from './beatmapset-extended-json'; 10 + import KudosuHistoryJson from './kudosu-history-json'; 7 11 import UserJson from './user-json'; 12 + import UserModdingProfileJson from './user-modding-profile-json'; 8 13 9 14 export default interface BeatmapsetDiscussionsBundleJson { 10 15 beatmaps: BeatmapExtendedJson[]; ··· 13 18 included_discussions: BeatmapsetDiscussionJsonForBundle[]; 14 19 users: UserJson[]; 15 20 } 21 + 22 + export interface BeatmapsetDiscussionsBundleJsonForModdingProfile { 23 + beatmaps: BeatmapExtendedJson[]; 24 + beatmapsets: BeatmapsetExtendedJson[]; 25 + discussions: BeatmapsetDiscussionJsonForBundle[]; 26 + events: BeatmapsetEventJson[]; 27 + extras: { 28 + recentlyReceivedKudosu: KudosuHistoryJson[]; 29 + }; 30 + perPage: { 31 + recentlyReceivedKudosu: number; 32 + }; 33 + posts: BeatmapsetDiscussionMessagePostJson[]; 34 + user: UserModdingProfileJson; 35 + users: UserJson[]; 36 + votes: Record<Direction, VoteSummary[]>; 37 + }
+14
resources/js/interfaces/beatmapset-discussions-store.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 5 + import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 6 + import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 7 + import UserJson from 'interfaces/user-json'; 8 + 9 + export default interface BeatmapsetDiscussionsStore { 10 + beatmaps: Map<number, BeatmapExtendedJson>; 11 + beatmapsets: Map<number, BeatmapsetExtendedJson>; 12 + discussions: Map<number | null | undefined, BeatmapsetDiscussionJson>; 13 + users: Map<number | null | undefined, UserJson>; 14 + }
+10 -2
resources/js/interfaces/beatmapset-with-discussions-json.ts
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 + import { BeatmapsetDiscussionJsonForShow } from './beatmapset-discussion-json'; 4 5 import BeatmapsetExtendedJson from './beatmapset-extended-json'; 5 6 6 - type DiscussionsRequiredAttributes = 'beatmaps' | 'current_user_attributes' | 'discussions' | 'events' | 'nominations' | 'related_users'; 7 - type BeatmapsetWithDiscussionsJson = BeatmapsetExtendedJson & Required<Pick<BeatmapsetExtendedJson, DiscussionsRequiredAttributes>>; 7 + type DiscussionsRequiredAttributes = 'beatmaps' | 'current_user_attributes' | 'events' | 'nominations' | 'related_users'; 8 + type BeatmapsetWithDiscussionsJson = 9 + Omit<BeatmapsetExtendedJson, keyof OverrideIncludes> 10 + & OverrideIncludes 11 + & Required<Pick<BeatmapsetExtendedJson, DiscussionsRequiredAttributes>>; 12 + 13 + interface OverrideIncludes { 14 + discussions: BeatmapsetDiscussionJsonForShow[]; 15 + } 8 16 9 17 export default BeatmapsetWithDiscussionsJson;
+2 -1
resources/js/interfaces/solo-score-json.ts
··· 34 34 has_replay: boolean; 35 35 id: number; 36 36 legacy_score_id: number | null; 37 - legacy_total_score: number | null; 37 + legacy_total_score: number; 38 38 max_combo: number; 39 39 mods: ScoreModJson[]; 40 40 passed: boolean; 41 41 pp: number | null; 42 42 rank: Rank; 43 + ranked?: boolean; 43 44 ruleset_id: number; 44 45 started_at: string | null; 45 46 statistics: Partial<Record<SoloScoreStatisticsAttribute, number>>;
+2
resources/js/interfaces/user-preferences-json.ts
··· 16 16 comments_show_deleted: false, 17 17 comments_sort: 'new', 18 18 forum_posts_show_deleted: true, 19 + legacy_score_only: true, 19 20 profile_cover_expanded: true, 20 21 user_list_filter: 'all', 21 22 user_list_sort: 'last_visit', ··· 33 34 comments_show_deleted: boolean; 34 35 comments_sort: string; 35 36 forum_posts_show_deleted: boolean; 37 + legacy_score_only: boolean; 36 38 profile_cover_expanded: boolean; 37 39 user_list_filter: Filter; 38 40 user_list_sort: SortMode;
+15 -30
resources/js/modding-profile/discussions.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context'; 5 - import { BeatmapsetsContext } from 'beatmap-discussions/beatmapsets-context'; 6 4 import { Discussion } from 'beatmap-discussions/discussion'; 7 5 import BeatmapsetCover from 'components/beatmapset-cover'; 8 - import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 9 6 import { BeatmapsetDiscussionJsonForBundle } from 'interfaces/beatmapset-discussion-json'; 10 - import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 7 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 11 8 import UserJson from 'interfaces/user-json'; 12 9 import { route } from 'laroute'; 10 + import { observer } from 'mobx-react'; 13 11 import React from 'react'; 14 12 import { makeUrl } from 'utils/beatmapset-discussion-helper'; 15 13 import { trans } from 'utils/lang'; 16 14 17 15 interface Props { 18 16 discussions: BeatmapsetDiscussionJsonForBundle[]; 17 + store: BeatmapsetDiscussionsStore; 19 18 user: UserJson; 20 - users: Partial<Record<number, UserJson>>; 21 19 } 22 20 21 + @observer 23 22 export default class Discussions extends React.Component<Props> { 24 23 render() { 25 24 return ( ··· 29 28 {this.props.discussions.length === 0 ? ( 30 29 <div className='modding-profile-list__empty'>{trans('users.show.extra.none')}</div> 31 30 ) : ( 32 - <BeatmapsetsContext.Consumer> 33 - {(beatmapsets) => ( 34 - <BeatmapsContext.Consumer> 35 - {(beatmaps) => ( 36 - <> 37 - {this.props.discussions.map((discussion) => this.renderDiscussion(discussion, beatmapsets, beatmaps))} 38 - <a className='modding-profile-list__show-more' href={route('beatmapsets.discussions.index', { user: `@${this.props.user.username}` })}> 39 - {trans('users.show.extra.discussions.show_more')} 40 - </a> 41 - </> 42 - )} 43 - </BeatmapsContext.Consumer> 44 - )} 45 - </BeatmapsetsContext.Consumer> 31 + <> 32 + {this.props.discussions.map((discussion) => this.renderDiscussion(discussion))} 33 + <a className='modding-profile-list__show-more' href={route('beatmapsets.discussions.index', { user: `@${this.props.user.username}` })}> 34 + {trans('users.show.extra.discussions.show_more')} 35 + </a> 36 + </> 46 37 )} 47 38 </div> 48 39 </div> 49 40 ); 50 41 } 51 42 52 - private renderDiscussion(discussion: BeatmapsetDiscussionJsonForBundle, beatmapsets: Partial<Record<number, BeatmapsetExtendedJson>>, beatmaps: Partial<Record<number, BeatmapExtendedJson>>) { 53 - const beatmapset = beatmapsets[discussion.beatmapset_id]; 54 - const currentBeatmap = discussion.beatmap_id != null ? beatmaps[discussion.beatmap_id] : null; 55 - 43 + private renderDiscussion(discussion: BeatmapsetDiscussionJsonForBundle) { 44 + const beatmapset = this.props.store.beatmapsets.get(discussion.beatmapset_id); 56 45 if (beatmapset == null) return null; 57 46 58 47 return ( 59 48 <div key={discussion.id} className='modding-profile-list__row'> 60 49 <a className='modding-profile-list__thumbnail' href={makeUrl({ discussion })}> 61 - <BeatmapsetCover beatmapset={beatmapsets[discussion.beatmapset_id]} size='list' /> 50 + <BeatmapsetCover beatmapset={beatmapset} size='list' /> 62 51 </a> 63 52 <Discussion 64 - beatmapset={beatmapset} 65 - currentBeatmap={currentBeatmap ?? null} 66 53 discussion={discussion} 54 + discussionsState={null} 67 55 isTimelineVisible={false} 68 - preview 69 - readonly 70 - showDeleted 71 - users={this.props.users} 56 + store={this.props.store} 72 57 /> 73 58 </div> 74 59 );
+1 -1
resources/js/modding-profile/events.tsx
··· 11 11 interface Props { 12 12 events: BeatmapsetEventJson[]; 13 13 user: UserJson; 14 - users: Partial<Record<string, UserJson>>; 14 + users: Map<number | null | undefined, UserJson>; 15 15 } 16 16 17 17 export default class Events extends React.Component<Props> {
+78 -134
resources/js/modding-profile/main.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context'; 5 - import { BeatmapsetsContext } from 'beatmap-discussions/beatmapsets-context'; 6 - import { DiscussionsContext } from 'beatmap-discussions/discussions-context'; 7 4 import HeaderV4 from 'components/header-v4'; 8 5 import ProfilePageExtraTab from 'components/profile-page-extra-tab'; 9 6 import ProfileTournamentBanner from 'components/profile-tournament-banner'; 10 7 import UserProfileContainer from 'components/user-profile-container'; 11 - import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 12 - import { BeatmapsetDiscussionJsonForBundle } from 'interfaces/beatmapset-discussion-json'; 13 - import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json'; 14 - import BeatmapsetEventJson from 'interfaces/beatmapset-event-json'; 15 - import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 16 - import KudosuHistoryJson from 'interfaces/kudosu-history-json'; 17 - import UserJson from 'interfaces/user-json'; 18 - import UserModdingProfileJson from 'interfaces/user-modding-profile-json'; 19 - import { first, isEmpty, keyBy, last, throttle } from 'lodash'; 8 + import { BeatmapsetDiscussionsBundleJsonForModdingProfile } from 'interfaces/beatmapset-discussions-bundle-json'; 9 + import { first, last, throttle } from 'lodash'; 20 10 import { action, computed, makeObservable, observable } from 'mobx'; 21 11 import { observer } from 'mobx-react'; 22 12 import Kudosu from 'modding-profile/kudosu'; 23 - import { deletedUserJson } from 'models/user'; 24 13 import core from 'osu-core-singleton'; 25 14 import Badges from 'profile-page/badges'; 26 15 import Cover from 'profile-page/cover'; 27 16 import DetailBar from 'profile-page/detail-bar'; 28 17 import headerLinks from 'profile-page/header-links'; 29 18 import * as React from 'react'; 19 + import BeatmapsetDiscussionsBundleForModdingProfileStore from 'stores/beatmapset-discussions-for-modding-profile-store'; 30 20 import { bottomPage } from 'utils/html'; 31 21 import { nextVal } from 'utils/seq'; 32 22 import { switchNever } from 'utils/switch-never'; 33 23 import { currentUrl } from 'utils/turbolinks'; 34 24 import Discussions from './discussions'; 35 25 import Events from './events'; 36 - import { Posts } from './posts'; 26 + import Posts from './posts'; 37 27 import Stats from './stats'; 38 - import Votes, { Direction, VoteSummary } from './votes'; 28 + import Votes from './votes'; 39 29 40 30 // in display order. 41 31 const moddingExtraPages = ['events', 'discussions', 'posts', 'votes', 'kudosu'] as const; 42 32 type ModdingExtraPage = (typeof moddingExtraPages)[number]; 43 - 44 - interface Props { 45 - beatmaps: BeatmapExtendedJson[]; 46 - beatmapsets: BeatmapsetExtendedJson[]; 47 - discussions: BeatmapsetDiscussionJsonForBundle[]; 48 - events: BeatmapsetEventJson[]; 49 - extras: { 50 - recentlyReceivedKudosu: KudosuHistoryJson[]; 51 - }; 52 - perPage: { 53 - recentlyReceivedKudosu: number; 54 - }; 55 - posts: BeatmapsetDiscussionMessagePostJson[]; 56 - user: UserModdingProfileJson; 57 - users: UserJson[]; 58 - votes: Record<Direction, VoteSummary[]>; 59 - } 60 - 61 33 type Page = ModdingExtraPage | 'main'; 62 34 63 35 function validPage(page: unknown) { ··· 69 41 } 70 42 71 43 @observer 72 - export default class Main extends React.Component<Props> { 44 + export default class Main extends React.Component<BeatmapsetDiscussionsBundleJsonForModdingProfile> { 73 45 @observable private currentPage: Page = 'main'; 74 46 private readonly disposers = new Set<(() => void) | undefined>(); 75 47 private readonly eventId = `users-modding-history-index-${nextVal()}`; ··· 84 56 }; 85 57 private readonly pages = React.createRef<HTMLDivElement>(); 86 58 private readonly pagesOffsetRef = React.createRef<HTMLDivElement>(); 59 + @observable private readonly store = new BeatmapsetDiscussionsBundleForModdingProfileStore(this.props); 87 60 private readonly tabs = React.createRef<HTMLDivElement>(); 88 61 89 - @computed 90 - private get beatmaps() { 91 - return keyBy(this.props.beatmaps, 'id'); 92 - } 93 - 94 - @computed 95 - private get beatmapsets() { 96 - return keyBy(this.props.beatmapsets, 'id'); 97 - } 98 - 99 - @computed 100 - private get discussions() { 101 - // skipped discussions 102 - // - not privileged (deleted discussion) 103 - // - deleted beatmap 104 - return keyBy(this.props.discussions.filter((d) => !isEmpty(d)), 'id'); 105 - } 106 - 107 62 private get pagesOffset() { 108 63 return this.pagesOffsetRef.current; 109 64 } ··· 112 67 return core.stickyHeader.headerHeight + (this.pagesOffset?.getBoundingClientRect().height ?? 0); 113 68 } 114 69 115 - @computed 116 - private get userDiscussions() { 117 - return this.props.discussions.filter((d) => d.user_id === this.props.user.id); 70 + private get user() { 71 + return this.props.user; 118 72 } 119 73 120 74 @computed 121 - private get users() { 122 - const values = keyBy(this.props.users, 'id'); 123 - // eslint-disable-next-line id-blacklist 124 - values.null = values.undefined = deletedUserJson; 125 - 126 - return values; 75 + private get userDiscussions() { 76 + return [...this.store.discussions.values()].filter((d) => d.user_id === this.props.user.id); 127 77 } 128 78 129 - constructor(props: Props) { 79 + constructor(props: BeatmapsetDiscussionsBundleJsonForModdingProfile) { 130 80 super(props); 131 81 132 82 makeObservable(this); ··· 155 105 156 106 render() { 157 107 return ( 158 - <DiscussionsContext.Provider value={this.discussions}> 159 - <BeatmapsetsContext.Provider value={this.beatmapsets}> 160 - <BeatmapsContext.Provider value={this.beatmaps}> 161 - <UserProfileContainer user={this.props.user}> 162 - <HeaderV4 163 - backgroundImage={this.props.user.cover.url} 164 - links={headerLinks(this.props.user, 'modding')} 165 - theme='users' 166 - /> 167 - <div className='osu-page osu-page--generic-compact'> 168 - <div ref={this.pageRefs.main} data-page-id='main'> 169 - <Cover 170 - coverUrl={this.props.user.cover.url} 171 - currentMode={this.props.user.playmode} 172 - user={this.props.user} 173 - /> 174 - <Badges badges={this.props.user.badges} /> 175 - {!this.props.user.is_bot && ( 176 - <> 177 - {this.props.user.active_tournament_banners.map((banner) => ( 178 - <ProfileTournamentBanner key={banner.id} banner={banner} /> 179 - ))} 180 - <div className='profile-detail'> 181 - <Stats user={this.props.user} /> 182 - </div> 183 - </> 184 - )} 185 - <DetailBar user={this.props.user} /> 108 + <UserProfileContainer user={this.user}> 109 + <HeaderV4 110 + backgroundImage={this.user.cover.url} 111 + links={headerLinks(this.user, 'modding')} 112 + theme='users' 113 + /> 114 + <div className='osu-page osu-page--generic-compact'> 115 + <div ref={this.pageRefs.main} data-page-id='main'> 116 + <Cover 117 + coverUrl={this.user.cover.url} 118 + currentMode={this.user.playmode} 119 + user={this.user} 120 + /> 121 + <Badges badges={this.user.badges} /> 122 + {!this.user.is_bot && ( 123 + <> 124 + {this.user.active_tournament_banners.map((banner) => ( 125 + <ProfileTournamentBanner key={banner.id} banner={banner} /> 126 + ))} 127 + <div className='profile-detail'> 128 + <Stats user={this.props.user} /> 186 129 </div> 187 - <div 188 - ref={this.pagesOffsetRef} 189 - className='page-extra-tabs page-extra-tabs--profile-page' 130 + </> 131 + )} 132 + <DetailBar user={this.user} /> 133 + </div> 134 + <div 135 + ref={this.pagesOffsetRef} 136 + className='page-extra-tabs page-extra-tabs--profile-page' 137 + > 138 + <div 139 + ref={this.tabs} 140 + className='page-mode page-mode--profile-page-extra' 141 + > 142 + {moddingExtraPages.map((page) => ( 143 + <a 144 + key={page} 145 + className='page-mode__item' 146 + data-page-id={page} 147 + href={`#${page}`} 148 + onClick={this.tabClick} 190 149 > 191 - <div 192 - ref={this.tabs} 193 - className='page-mode page-mode--profile-page-extra' 194 - > 195 - {moddingExtraPages.map((page) => ( 196 - <a 197 - key={page} 198 - className='page-mode__item' 199 - data-page-id={page} 200 - href={`#${page}`} 201 - onClick={this.tabClick} 202 - > 203 - <ProfilePageExtraTab 204 - currentPage={this.currentPage} 205 - page={page} 206 - /> 207 - </a> 208 - ))} 209 - </div> 210 - </div> 211 - <div ref={this.pages} className='user-profile-pages'> 212 - {moddingExtraPages.map((name) => ( 213 - <div 214 - key={name} 215 - ref={this.pageRefs[name]} 216 - data-page-id={name} 217 - > 218 - {this.extraPage(name)} 219 - </div> 220 - ))} 221 - </div> 150 + <ProfilePageExtraTab 151 + currentPage={this.currentPage} 152 + page={page} 153 + /> 154 + </a> 155 + ))} 156 + </div> 157 + </div> 158 + <div ref={this.pages} className='user-profile-pages'> 159 + {moddingExtraPages.map((name) => ( 160 + <div 161 + key={name} 162 + ref={this.pageRefs[name]} 163 + data-page-id={name} 164 + > 165 + {this.extraPage(name)} 222 166 </div> 223 - </UserProfileContainer> 224 - </BeatmapsContext.Provider> 225 - </BeatmapsetsContext.Provider> 226 - </DiscussionsContext.Provider> 167 + ))} 168 + </div> 169 + </div> 170 + </UserProfileContainer> 227 171 ); 228 172 } 229 173 230 174 private readonly extraPage = (name: ModdingExtraPage) => { 231 175 switch (name) { 232 176 case 'discussions': 233 - return <Discussions discussions={this.userDiscussions} user={this.props.user} users={this.users} />; 177 + return <Discussions discussions={this.userDiscussions} store={this.store} user={this.user} />; 234 178 case 'events': 235 - return <Events events={this.props.events} user={this.props.user} users={this.users} />; 179 + return <Events events={this.props.events} user={this.user} users={this.store.users} />; 236 180 case 'kudosu': 237 181 return ( 238 182 <Kudosu 239 183 expectedInitialCount={this.props.perPage.recentlyReceivedKudosu} 240 184 initialKudosu={this.props.extras.recentlyReceivedKudosu} 241 185 name={name} 242 - total={this.props.user.kudosu.total} 243 - userId={this.props.user.id} 186 + total={this.user.kudosu.total} 187 + userId={this.user.id} 244 188 /> 245 189 ); 246 190 case 'posts': 247 - return <Posts posts={this.props.posts} user={this.props.user} users={this.users} />; 191 + return <Posts posts={this.props.posts} store={this.store} user={this.user} />; 248 192 case 'votes': 249 - return <Votes users={this.users} votes={this.props.votes} />; 193 + return <Votes users={this.props.users} votes={this.props.votes} />; 250 194 default: 251 195 switchNever(name); 252 196 throw new Error('unsupported extra page');
+6 -13
resources/js/modding-profile/posts.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context'; 5 4 import BeatmapsetCover from 'components/beatmapset-cover'; 6 5 import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json'; 6 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 7 7 import UserJson from 'interfaces/user-json'; 8 8 import { route } from 'laroute'; 9 9 import { deletedUserJson } from 'models/user'; ··· 15 15 16 16 interface Props { 17 17 posts: BeatmapsetDiscussionMessagePostJson[]; 18 + store: BeatmapsetDiscussionsStore; 18 19 user: UserJson; 19 - users: Partial<Record<number, UserJson>>; 20 20 } 21 21 22 - export class Posts extends React.Component<Props> { 23 - static contextType = BeatmapsContext; 24 - declare context: React.ContextType<typeof BeatmapsContext>; 25 - 22 + export default class Posts extends React.Component<Props> { 26 23 render() { 27 24 return ( 28 25 <div className='page-extra'> ··· 50 47 const discussion = post.beatmap_discussion; 51 48 if (discussion == null || discussion.beatmapset == null) return; 52 49 53 - const beatmap = (discussion.beatmap_id != null ? this.context[discussion.beatmap_id] : null) ?? null; 54 - 55 50 const discussionClasses = classWithModifiers( 56 51 'beatmap-discussion', 57 52 ['preview', 'modding-profile'], ··· 76 71 <div className={discussionClasses}> 77 72 <div className='beatmap-discussion__discussion'> 78 73 <Post 79 - beatmap={beatmap} 80 - beatmapset={discussion.beatmapset} 81 74 discussion={discussion} 75 + discussionsState={null} 82 76 post={post} 83 77 read 84 78 readonly 85 - resolvedSystemPostId={-1} // TODO: Can probably move to context after refactoring state? 79 + store={this.props.store} 86 80 type='reply' 87 - user={this.props.users[post.user_id] ?? deletedUserJson} 88 - users={this.props.users} 81 + user={this.props.store.users.get(post.user_id) ?? deletedUserJson} 89 82 /> 90 83 </div> 91 84 </div>
+1
resources/js/mp-history/game-header.coffee
··· 9 9 import { div, a, span, h1, h2 } from 'react-dom-factories' 10 10 import { getArtist, getTitle } from 'utils/beatmapset-helper' 11 11 import { trans } from 'utils/lang' 12 + import { filterMods } from 'utils/score-helper' 12 13 13 14 el = React.createElement 14 15
+6 -5
resources/js/profile-page/play-detail.tsx
··· 13 13 import { classWithModifiers } from 'utils/css'; 14 14 import { formatNumber } from 'utils/html'; 15 15 import { trans } from 'utils/lang'; 16 - import { hasMenu } from 'utils/score-helper'; 16 + import { accuracy, filterMods, hasMenu, rank } from 'utils/score-helper'; 17 17 import { beatmapUrl } from 'utils/url'; 18 18 19 19 const bn = 'play-detail'; ··· 52 52 } 53 53 54 54 const scoreWeight = this.props.showPpWeight ? score.weight : null; 55 + const scoreRank = rank(score); 55 56 56 57 return ( 57 58 <div className={blockClass} {...additionalAttributes}> 58 59 {this.renderPinSortableHandle()} 59 60 <div className={`${bn}__group ${bn}__group--top`}> 60 61 <div className={`${bn}__icon ${bn}__icon--main`}> 61 - <div className={`score-rank score-rank--full score-rank--${score.rank}`} /> 62 + <div className={`score-rank score-rank--full score-rank--${scoreRank}`} /> 62 63 </div> 63 64 64 65 <div className={`${bn}__detail`}> ··· 86 87 <div className={`${bn}__group ${bn}__group--bottom`}> 87 88 <div className={`${bn}__score-detail ${bn}__score-detail--score`}> 88 89 <div className={`${bn}__icon ${bn}__icon--extra`}> 89 - <div className={`score-rank score-rank--full score-rank--${score.rank}`} /> 90 + <div className={`score-rank score-rank--full score-rank--${scoreRank}`} /> 90 91 </div> 91 92 <div className={`${bn}__score-detail-top-right`}> 92 93 <div className={`${bn}__accuracy-and-weighted-pp`}> 93 94 <span className={`${bn}__accuracy`}> 94 - {formatNumber(score.accuracy * 100, 2)}% 95 + {formatNumber(accuracy(score) * 100, 2)}% 95 96 </span> 96 97 {scoreWeight != null && ( 97 98 <span className={`${bn}__weighted-pp`}> ··· 111 112 </div> 112 113 113 114 <div className={`${bn}__score-detail ${bn}__score-detail--mods`}> 114 - {score.mods.map((mod) => <Mod key={mod.acronym} mod={mod} />)} 115 + {filterMods(score).map((mod) => <Mod key={mod.acronym} mod={mod} />)} 115 116 </div> 116 117 117 118 <div className={`${bn}__pp`}>
+2 -7
resources/js/register-components.tsx
··· 19 19 import { startListening, UserCardTooltip } from 'components/user-card-tooltip'; 20 20 import { UserCards } from 'components/user-cards'; 21 21 import { WikiSearch } from 'components/wiki-search'; 22 - import { keyBy } from 'lodash'; 23 22 import { observable } from 'mobx'; 24 - import { deletedUserJson } from 'models/user'; 25 23 import NotificationWidget from 'notification-widget/main'; 26 24 import core from 'osu-core-singleton'; 27 25 import QuickSearch from 'quick-search/main'; 28 26 import QuickSearchWorker from 'quick-search/worker'; 29 27 import * as React from 'react'; 30 28 import { parseJson } from 'utils/json'; 29 + import { mapBy } from 'utils/map'; 31 30 32 31 function reqJson<T>(input: string|undefined): T { 33 32 // This will throw when input is missing and thus parsing empty string. ··· 54 53 const props: BeatmapsetEventsProps = { 55 54 events: parseJson('json-events'), 56 55 mode: 'list', 57 - users: keyBy(parseJson('json-users'), 'id'), 56 + users: mapBy(parseJson('json-users'), 'id'), 58 57 }; 59 - 60 - // TODO: move to store? 61 - // eslint-disable-next-line id-blacklist 62 - props.users.null = props.users.undefined = deletedUserJson; 63 58 64 59 return <BeatmapsetEvents {...props} />; 65 60 });
+3 -2
resources/js/scores-show/info.tsx
··· 5 5 import { SoloScoreJsonForShow } from 'interfaces/solo-score-json'; 6 6 import * as React from 'react'; 7 7 import { rulesetName } from 'utils/beatmap-helper'; 8 + import { accuracy, rank } from 'utils/score-helper'; 8 9 import Buttons from './buttons'; 9 10 import Dial from './dial'; 10 11 import Player from './player'; ··· 22 23 </div> 23 24 24 25 <div className='score-info__item'> 25 - <Tower rank={score.rank} /> 26 + <Tower rank={rank(score)} /> 26 27 </div> 27 28 28 29 <div className='score-info__item score-info__item--dial'> 29 - <Dial accuracy={score.accuracy} mode={rulesetName(score.ruleset_id)} rank={score.rank} /> 30 + <Dial accuracy={accuracy(score)} mode={rulesetName(score.ruleset_id)} rank={rank(score)} /> 30 31 </div> 31 32 32 33 <div className='score-info__item score-info__item--player'>
+2 -2
resources/js/scores-show/player.tsx
··· 7 7 import * as React from 'react'; 8 8 import { formatNumber } from 'utils/html'; 9 9 import { trans } from 'utils/lang'; 10 - import { totalScore } from 'utils/score-helper'; 10 + import { filterMods, totalScore } from 'utils/score-helper'; 11 11 12 12 interface Props { 13 13 score: SoloScoreJsonForShow; ··· 22 22 </div> 23 23 24 24 <div className='score-player__mods'> 25 - {props.score.mods.map((mod) => ( 25 + {filterMods(props.score).map((mod) => ( 26 26 <div key={mod.acronym} className='score-player__mod'> 27 27 <Mod mod={mod} /> 28 28 </div>
+4 -1
resources/js/scores/pp-value.tsx
··· 21 21 if (!isBest && !isSolo) { 22 22 title = trans('scores.status.non_best'); 23 23 content = '-'; 24 + } else if (props.score.ranked === false) { 25 + title = trans('scores.status.no_pp'); 26 + content = '-'; 24 27 } else if (props.score.pp == null) { 25 28 if (isSolo && !props.score.passed) { 26 29 title = trans('scores.status.non_passing'); 27 30 content = '-'; 28 31 } else { 29 32 title = trans('scores.status.processing'); 30 - content = <span className='fas fa-exclamation-triangle' />; 33 + content = <span className='fas fa-sync' />; 31 34 } 32 35 } else { 33 36 title = formatNumber(props.score.pp);
+38
resources/js/stores/beatmapset-discussions-bundle-store.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import BeatmapsetDiscussionsBundleJson from 'interfaces/beatmapset-discussions-bundle-json'; 5 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 6 + import { computed, makeObservable, observable } from 'mobx'; 7 + import { mapBy, mapByWithNulls } from 'utils/map'; 8 + 9 + export default class BeatmapsetDiscussionsBundleStore implements BeatmapsetDiscussionsStore { 10 + /** TODO: accessor; readonly */ 11 + @observable bundle; 12 + 13 + @computed 14 + get beatmaps() { 15 + return mapBy(this.bundle.beatmaps, 'id'); 16 + } 17 + 18 + @computed 19 + get beatmapsets() { 20 + return mapBy(this.bundle.beatmapsets, 'id'); 21 + } 22 + 23 + @computed 24 + get discussions() { 25 + // TODO: add bundle.discussions? 26 + return mapByWithNulls(this.bundle.included_discussions, 'id'); 27 + } 28 + 29 + @computed 30 + get users() { 31 + return mapByWithNulls(this.bundle.users, 'id'); 32 + } 33 + 34 + constructor(bundle: BeatmapsetDiscussionsBundleJson) { 35 + this.bundle = bundle; 36 + makeObservable(this); 37 + } 38 + }
+37
resources/js/stores/beatmapset-discussions-for-modding-profile-store.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import { BeatmapsetDiscussionsBundleJsonForModdingProfile } from 'interfaces/beatmapset-discussions-bundle-json'; 5 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 6 + import { computed, makeObservable, observable } from 'mobx'; 7 + import { mapBy, mapByWithNulls } from 'utils/map'; 8 + 9 + export default class BeatmapsetDiscussionsBundleForModdingProfileStore implements BeatmapsetDiscussionsStore { 10 + /** TODO: accessor; readonly */ 11 + @observable bundle; 12 + 13 + @computed 14 + get beatmaps() { 15 + return mapBy(this.bundle.beatmaps, 'id'); 16 + } 17 + 18 + @computed 19 + get beatmapsets() { 20 + return mapBy(this.bundle.beatmapsets, 'id'); 21 + } 22 + 23 + @computed 24 + get discussions() { 25 + return mapByWithNulls(this.bundle.discussions, 'id'); 26 + } 27 + 28 + @computed 29 + get users() { 30 + return mapByWithNulls(this.bundle.users, 'id'); 31 + } 32 + 33 + constructor(bundle: BeatmapsetDiscussionsBundleJsonForModdingProfile) { 34 + this.bundle = bundle; 35 + makeObservable(this); 36 + } 37 + }
+52
resources/js/stores/beatmapset-discussions-show-store.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; 5 + import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 6 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 7 + import { computed, makeObservable, observable } from 'mobx'; 8 + import { mapBy, mapByWithNulls } from 'utils/map'; 9 + 10 + export default class BeatmapsetDiscussionsShowStore implements BeatmapsetDiscussionsStore { 11 + @observable beatmapset: BeatmapsetWithDiscussionsJson; 12 + 13 + @computed 14 + get beatmaps() { 15 + const hasDiscussion = new Set<number>(); 16 + for (const discussion of this.beatmapset.discussions) { 17 + if (discussion?.beatmap_id != null) { 18 + hasDiscussion.add(discussion.beatmap_id); 19 + } 20 + } 21 + 22 + return mapBy( 23 + this.beatmapset.beatmaps.filter((beatmap) => beatmap.deleted_at == null || hasDiscussion.has(beatmap.id)), 24 + 'id', 25 + ); 26 + } 27 + 28 + @computed 29 + get beatmapsets() { 30 + return new Map<number, BeatmapsetExtendedJson>([[this.beatmapset.id, this.beatmapset]]); 31 + } 32 + 33 + @computed 34 + get discussions() { 35 + // skipped discussions 36 + // - not privileged (deleted discussion) 37 + // - deleted beatmap 38 + 39 + // allow null for the key so we can use .get(null) 40 + return mapByWithNulls(this.beatmapset.discussions, 'id'); 41 + } 42 + 43 + @computed 44 + get users() { 45 + return mapByWithNulls(this.beatmapset.related_users, 'id'); 46 + } 47 + 48 + constructor(beatmapset: BeatmapsetWithDiscussionsJson) { 49 + this.beatmapset = beatmapset; 50 + makeObservable(this); 51 + } 52 + }
+6
resources/js/utils/array.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + export function mobxArrayGet<T>(array: T[] | null | undefined, index: number): T | undefined { 5 + return array != null && array.length > index ? array[index] : undefined; 6 + }
+15 -16
resources/js/utils/beatmapset-discussion-helper.ts
··· 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 4 import { Filter, filters } from 'beatmap-discussions/current-discussions'; 5 - import DiscussionMode, { DiscussionPage, discussionPages } from 'beatmap-discussions/discussion-mode'; 5 + import DiscussionMode from 'beatmap-discussions/discussion-mode'; 6 + import DiscussionPage, { isDiscussionPage } from 'beatmap-discussions/discussion-page'; 6 7 import guestGroup from 'beatmap-discussions/guest-group'; 7 8 import mapperGroup from 'beatmap-discussions/mapper-group'; 8 9 import BeatmapJson from 'interfaces/beatmap-json'; 9 - import BeatmapsetDiscussionJson, { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; 10 + import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 10 11 import BeatmapsetDiscussionPostJson from 'interfaces/beatmapset-discussion-post-json'; 11 12 import BeatmapsetJson from 'interfaces/beatmapset-json'; 12 13 import GameMode, { gameModes } from 'interfaces/game-mode'; ··· 20 21 import { getInt } from './math'; 21 22 22 23 interface BadgeGroupParams { 23 - beatmapset: BeatmapsetJson; 24 - currentBeatmap: BeatmapJson | null; 24 + beatmapset?: BeatmapsetJson; 25 + currentBeatmap?: BeatmapJson | null; 25 26 discussion: BeatmapsetDiscussionJson; 26 27 user?: UserJson; 27 28 } ··· 70 71 // parseUrl and makeUrl lookups 71 72 const filterLookup = new Set<unknown>(filters); 72 73 const generalPages = new Set<unknown>(['events', 'generalAll', 'reviews']); 73 - const pageLookup = new Set<unknown>(discussionPages); 74 74 75 75 const defaultBeatmapId = '-'; 76 76 ··· 89 89 return null; 90 90 } 91 91 92 - if (user.id === beatmapset.user_id) { 92 + if (user.id === beatmapset?.user_id) { 93 93 return mapperGroup; 94 94 } 95 95 ··· 97 97 return guestGroup; 98 98 } 99 99 100 - return user.groups?.[0]; 100 + if (user.groups == null || user.groups.length === 0) { 101 + return null; 102 + } 103 + 104 + return user.groups[0]; 101 105 } 102 106 103 107 export function canModeratePosts() { ··· 128 132 const m = Math.floor(value / 1000 / 60); 129 133 130 134 return `${padStart(m.toString(), 2, '0')}:${padStart(s.toString(), 2, '0')}:${padStart(ms.toString(), 3, '0')}`; 131 - } 132 - 133 - 134 - function isDiscussionPage(value: string): value is DiscussionPage { 135 - return pageLookup.has(value); 136 135 } 137 136 138 137 function isFilter(value: string): value is Filter { ··· 375 374 } 376 375 377 376 // Workaround for the discussion starting_post typing mess until the response gets refactored and normalized. 378 - export function startingPost(discussion: BeatmapsetDiscussionJsonForBundle | BeatmapsetDiscussionJsonForShow): BeatmapsetDiscussionPostJson { 379 - if (!('posts' in discussion)) { 380 - return discussion.starting_post; 377 + export function startingPost(discussion: BeatmapsetDiscussionJson) { 378 + if ('posts' in discussion && discussion.posts != null) { 379 + return discussion.posts[0]; 381 380 } 382 381 383 - return discussion.posts[0]; 382 + return discussion.starting_post; 384 383 } 385 384 386 385 export function stateFromDiscussion(discussion: BeatmapsetDiscussionJson) {
+4 -4
resources/js/utils/json.ts
··· 55 55 * 56 56 * @param id id of the HTMLScriptElement. 57 57 */ 58 - export function parseJson<T>(id: string): T { 59 - const json = parseJsonNullable<T>(id); 58 + export function parseJson<T>(id: string, remove = false): T { 59 + const json = parseJsonNullable<T>(id, remove); 60 60 if (json == null) { 61 61 throw new Error(`script element ${id} is missing or contains nullish value.`); 62 62 } ··· 71 71 * @param id id of the HTMLScriptElement. 72 72 * @param remove true to remove the element after parsing; false, otherwise. 73 73 */ 74 - export function parseJsonNullable<T>(id: string, remove = false): T | undefined { 74 + export function parseJsonNullable<T>(id: string, remove = false, reviver?: (key: string, value: any) => any): T | undefined { 75 75 const element = (window.newBody ?? document.body).querySelector(`#${id}`); 76 76 if (!(element instanceof HTMLScriptElement)) return undefined; 77 - const json = JSON.parse(element.text) as T; 77 + const json = JSON.parse(element.text, reviver) as T; 78 78 79 79 if (remove) { 80 80 element.remove();
+158
resources/js/utils/legacy-score-helper.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import Rank from 'interfaces/rank'; 5 + import SoloScoreJson from 'interfaces/solo-score-json'; 6 + 7 + interface CacheEntry { 8 + accuracy: number; 9 + rank: Rank; 10 + } 11 + let cache: Partial<Record<string, CacheEntry>> = {}; 12 + 13 + // reset cache on navigation 14 + document.addEventListener('turbolinks:load', () => { 15 + cache = {}; 16 + }); 17 + 18 + function shouldHaveHiddenRank(score: SoloScoreJson) { 19 + return score.mods.some((mod) => mod.acronym === 'FL' || mod.acronym === 'HD'); 20 + } 21 + 22 + export function legacyAccuracyAndRank(score: SoloScoreJson) { 23 + const key = `${score.type}:${score.id}`; 24 + let cached = cache[key]; 25 + 26 + if (cached == null) { 27 + const countMiss = score.statistics.miss ?? 0; 28 + const countGreat = score.statistics.great ?? 0; 29 + 30 + let accuracy: number; 31 + let rank: Rank; 32 + 33 + // Reference: https://github.com/ppy/osu/blob/e3ffea1b127cbd3171010972588a8b07cf049ba0/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs#L170-L274 34 + switch (score.ruleset_id) { 35 + // osu 36 + case 0: { 37 + const countMeh = score.statistics.meh ?? 0; 38 + const countOk = score.statistics.ok ?? 0; 39 + 40 + const totalHits = countMeh + countOk + countGreat + countMiss; 41 + accuracy = totalHits > 0 42 + ? (countMeh * 50 + countOk * 100 + countGreat * 300) / (totalHits * 300) 43 + : 1; 44 + 45 + const ratioGreat = totalHits > 0 ? countGreat / totalHits : 1; 46 + const ratioMeh = totalHits > 0 ? countMeh / totalHits : 1; 47 + 48 + if (score.rank === 'F') { 49 + rank = 'F'; 50 + } else if (ratioGreat === 1) { 51 + rank = shouldHaveHiddenRank(score) ? 'XH' : 'X'; 52 + } else if (ratioGreat > 0.9 && ratioMeh <= 0.01 && countMiss === 0) { 53 + rank = shouldHaveHiddenRank(score) ? 'SH' : 'S'; 54 + } else if ((ratioGreat > 0.8 && countMiss === 0) || ratioGreat > 0.9) { 55 + rank = 'A'; 56 + } else if ((ratioGreat > 0.7 && countMiss === 0) || ratioGreat > 0.8) { 57 + rank = 'B'; 58 + } else if (ratioGreat > 0.6) { 59 + rank = 'C'; 60 + } else { 61 + rank = 'D'; 62 + } 63 + break; 64 + } 65 + // taiko 66 + case 1: { 67 + const countOk = score.statistics.ok ?? 0; 68 + 69 + const totalHits = countOk + countGreat + countMiss; 70 + accuracy = totalHits > 0 71 + ? (countOk * 150 + countGreat * 300) / (totalHits * 300) 72 + : 1; 73 + 74 + const ratioGreat = totalHits > 0 ? countGreat / totalHits : 1; 75 + 76 + if (score.rank === 'F') { 77 + rank = 'F'; 78 + } else if (ratioGreat === 1) { 79 + rank = shouldHaveHiddenRank(score) ? 'XH' : 'X'; 80 + } else if (ratioGreat > 0.9 && countMiss === 0) { 81 + rank = shouldHaveHiddenRank(score) ? 'SH' : 'S'; 82 + } else if ((ratioGreat > 0.8 && countMiss === 0) || ratioGreat > 0.9) { 83 + rank = 'A'; 84 + } else if ((ratioGreat > 0.7 && countMiss === 0) || ratioGreat > 0.8) { 85 + rank = 'B'; 86 + } else if (ratioGreat > 0.6) { 87 + rank = 'C'; 88 + } else { 89 + rank = 'D'; 90 + } 91 + break; 92 + } 93 + // catch 94 + case 2: { 95 + const countLargeTickHit = score.statistics.large_tick_hit ?? 0; 96 + const countSmallTickHit = score.statistics.small_tick_hit ?? 0; 97 + const countSmallTickMiss = score.statistics.small_tick_miss ?? 0; 98 + 99 + const totalHits = countSmallTickHit + countLargeTickHit + countGreat + countMiss + countSmallTickMiss; 100 + accuracy = totalHits > 0 101 + ? (countSmallTickHit + countLargeTickHit + countGreat) / totalHits 102 + : 1; 103 + 104 + if (score.rank === 'F') { 105 + rank = 'F'; 106 + } else if (accuracy === 1) { 107 + rank = shouldHaveHiddenRank(score) ? 'XH' : 'X'; 108 + } else if (accuracy > 0.98) { 109 + rank = shouldHaveHiddenRank(score) ? 'SH' : 'S'; 110 + } else if (accuracy > 0.94) { 111 + rank = 'A'; 112 + } else if (accuracy > 0.9) { 113 + rank = 'B'; 114 + } else if (accuracy > 0.85) { 115 + rank = 'C'; 116 + } else { 117 + rank = 'D'; 118 + } 119 + break; 120 + } 121 + // mania 122 + case 3: { 123 + const countPerfect = score.statistics.perfect ?? 0; 124 + const countGood = score.statistics.good ?? 0; 125 + const countOk = score.statistics.ok ?? 0; 126 + const countMeh = score.statistics.meh ?? 0; 127 + 128 + const totalHits = countPerfect + countGood + countOk + countMeh + countGreat + countMiss; 129 + accuracy = totalHits > 0 130 + ? ((countGreat + countPerfect) * 300 + countGood * 200 + countOk * 100 + countMeh * 50) / (totalHits * 300) 131 + : 1; 132 + 133 + if (score.rank === 'F') { 134 + rank = 'F'; 135 + } else if (accuracy === 1) { 136 + rank = shouldHaveHiddenRank(score) ? 'XH' : 'X'; 137 + } else if (accuracy > 0.95) { 138 + rank = shouldHaveHiddenRank(score) ? 'SH' : 'S'; 139 + } else if (accuracy > 0.9) { 140 + rank = 'A'; 141 + } else if (accuracy > 0.8) { 142 + rank = 'B'; 143 + } else if (accuracy > 0.7) { 144 + rank = 'C'; 145 + } else { 146 + rank = 'D'; 147 + } 148 + break; 149 + } 150 + default: 151 + throw new Error('unknown score ruleset'); 152 + } 153 + 154 + cached = cache[key] = { accuracy, rank }; 155 + } 156 + 157 + return cached; 158 + }
+22
resources/js/utils/map.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + export function mapBy<T, K extends keyof T>(array: T[], key: K) { 5 + const map = new Map<T[K], T>(); 6 + 7 + for (const value of array) { 8 + map.set(value[key], value); 9 + } 10 + 11 + return map; 12 + } 13 + 14 + export function mapByWithNulls<T, K extends keyof T>(array: T[], key: K) { 15 + const map = new Map<T[K] | null | undefined, T>(); 16 + 17 + for (const value of array) { 18 + map.set(value[key], value); 19 + } 20 + 21 + return map; 22 + }
+31 -1
resources/js/utils/score-helper.ts
··· 7 7 import core from 'osu-core-singleton'; 8 8 import { rulesetName } from './beatmap-helper'; 9 9 import { trans } from './lang'; 10 + import { legacyAccuracyAndRank } from './legacy-score-helper'; 11 + 12 + export function accuracy(score: SoloScoreJson) { 13 + if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) { 14 + return score.accuracy; 15 + } 16 + 17 + return legacyAccuracyAndRank(score).accuracy; 18 + } 10 19 11 20 export function canBeReported(score: SoloScoreJson) { 12 21 return (score.best_id != null || score.type === 'solo_score') 13 22 && core.currentUser != null 14 23 && score.user_id !== core.currentUser.id; 24 + } 25 + 26 + // Removes CL mod on legacy score if user has lazer mode disabled 27 + export function filterMods(score: SoloScoreJson) { 28 + if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) { 29 + return score.mods; 30 + } 31 + 32 + return score.mods.filter((mod) => mod.acronym !== 'CL'); 15 33 } 16 34 17 35 // TODO: move to application state repository thingy later ··· 92 110 ], 93 111 }; 94 112 113 + export function rank(score: SoloScoreJson) { 114 + if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) { 115 + return score.rank; 116 + } 117 + 118 + return legacyAccuracyAndRank(score).rank; 119 + } 120 + 95 121 export function scoreDownloadUrl(score: SoloScoreJson) { 96 122 if (score.type === 'solo_score') { 97 123 return route('scores.download', { score: score.id }); ··· 123 149 } 124 150 125 151 export function totalScore(score: SoloScoreJson) { 126 - return score.legacy_total_score ?? score.total_score; 152 + if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) { 153 + return score.total_score; 154 + } 155 + 156 + return score.legacy_total_score; 127 157 }
+3
resources/lang/ar/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'أدخل اسم المستخدم أو عنوان البريد الإلكتروني', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'تحتاج دعم في المستقبل؟ تواصل معنا على :button.', 41 44 'button' => 'نظام الدعم',
+3
resources/lang/be/password_reset.php
··· 37 37 'starting' => [ 38 38 'username' => 'Увядзіце эл. пошту або імя карыстальніка', 39 39 40 + 'reason' => [ 41 + 'inactive_different_country' => "", 42 + ], 40 43 'support' => [ 41 44 '_' => 'Патрэбна дадатковая дапамога? Звяжыцеся з намі :button.', 42 45 'button' => 'сістэма падтрымкі',
+3
resources/lang/bg/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Въведете имейл или потребителско име', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Нуждаете се от допълнителна помощ? Свържете се с нашата :button.', 41 44 'button' => 'поддръжка',
+3
resources/lang/ca/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Adreça electrònica o nom d\'usuari', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Necessites més assistència? Contacta\'ns a través del nostre :button.', 41 44 'button' => 'sistema de suport',
+3
resources/lang/cs/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Zadejte Vaši e-mailovou adresu nebo uživatelské jméno', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Potřebujete další pomoc? Kontaktujte nás prostřednictvím :button.', 41 44 'button' => 'systém podpory',
+3
resources/lang/da/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Indtast email-adresse eller brugernavn', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Har du brug for yderligere assistance? Kontakt os via vores :button.', 41 44 'button' => 'support system',
+3
resources/lang/de/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Benutzername oder E-Mail eingeben', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Benötigst du weitere Hilfe? Kontaktiere uns über unser :button.', 41 44 'button' => 'Supportsystem',
+3
resources/lang/el/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Εισάγετε τη διεύθυνση ηλεκτρονικού ταχυδρομείου ή το όνομα χρήστη', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Χρειάζεστε περαιτέρω βοήθεια? Επικοινωνήστε μαζί μας μέσω :button.', 41 44 'button' => 'σύστημα υποστήριξης',
+2
resources/lang/en/layout.php
··· 195 195 'account-edit' => 'Settings', 196 196 'follows' => 'Watchlists', 197 197 'friends' => 'Friends', 198 + 'legacy_score_only_toggle' => 'Lazer mode', 199 + 'legacy_score_only_toggle_tooltip' => 'Lazer mode shows scores set from lazer with a new scoring algorithm', 198 200 'logout' => 'Sign Out', 199 201 'profile' => 'My Profile', 200 202 ],
+1
resources/lang/en/scores.php
··· 25 25 'status' => [ 26 26 'non_best' => 'Only personal best scores award pp', 27 27 'non_passing' => 'Only passing scores award pp', 28 + 'no_pp' => 'pp is not awarded for this score', 28 29 'processing' => 'This score is still being calculated and will be displayed soon', 29 30 ], 30 31 ];
+3
resources/lang/es/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Ingrese correo o nombre de usuario', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => '¿Necesita asistencia? Contáctenos a través de nuestro :button.', 41 44 'button' => 'sistema de soporte',
+3
resources/lang/fa-IR/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'ایمیل یا نام کاربری را وارد کتید', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'نیاز به کمک بیشتر دارید؟ با ما توسط :button تماس بگیرید.', 41 44 'button' => 'سیستم پشتیبانی',
+1 -1
resources/lang/fi/beatmaps.php
··· 293 293 'pending' => 'Vireillä', 294 294 'wip' => 'Työn alla', 295 295 'qualified' => 'Kelpuutettu', 296 - 'ranked' => 'Pisteytetty', 296 + 'ranked' => 'Rankattu', 297 297 ], 298 298 'genre' => [ 299 299 'any' => 'Kaikki',
+1 -1
resources/lang/fi/beatmapsets.php
··· 185 185 'friend' => 'Kukaan kavereistasi ei vielä ole saanut tulosta tässä mapissa!', 186 186 'global' => 'Tuloksia ei ole. Voisit hankkia niitä.', 187 187 'loading' => 'Ladataan tuloksia...', 188 - 'unranked' => 'Pisteyttämätön rytmikartta.', 188 + 'unranked' => 'Rankkaamaton rytmikartta.', 189 189 ], 190 190 'score' => [ 191 191 'first' => 'Johdossa',
+3 -3
resources/lang/fi/community.php
··· 32 32 'description' => 'Lahjoituksesi auttavat pitämään pelin itsenäisenä ja täysin vapaana mainoksista ja ulkopuolisista sponsoreista.', 33 33 ], 34 34 'tournaments' => [ 35 - 'title' => 'Virallisiin turnauksiin', 36 - 'description' => 'Auta osu! maailmancup -turnausten ylläpidon (sekä palkintojen) rahoituksessa.', 35 + 'title' => 'Viralliset turnaukset', 36 + 'description' => 'Auta osu!-maailmancup -turnausten ylläpidon (sekä palkintojen) rahoituksessa.', 37 37 'link_text' => 'Selaa turnauksia &raquo;', 38 38 ], 39 39 'bounty-program' => [ 40 - 'title' => 'Avoimen lähdekoodin palkkio -ohjelmaan', 40 + 'title' => 'Avoimen lähdekoodin palkkio -ohjelma', 41 41 'description' => 'Tue yhteisön osallistujia, jotka ovat käyttäneet aikaansa ja vaivaansa tekemään osu!sta paremman.', 42 42 'link_text' => 'Lue lisää &raquo;', 43 43 ],
+1 -1
resources/lang/fi/layout.php
··· 75 75 ], 76 76 'help' => [ 77 77 '_' => 'apua', 78 - 'getAbuse' => 'ilmoita häirinnästä', 78 + 'getAbuse' => 'ilmoita väärinkäytöstä', 79 79 'getFaq' => 'usein kysytyt', 80 80 'getRules' => 'säännöt', 81 81 'getSupport' => 'tarvitsen siis oikeasti apua!',
+3
resources/lang/fi/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Anna sähköposti tai käyttäjänimi', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Tarvitsetko lisäapua? Ota yhteyttä meihin: :button.', 41 44 'button' => 'tukijärjestelmä',
+1 -1
resources/lang/fi/users.php
··· 159 159 ], 160 160 161 161 'options' => [ 162 - 'cheating' => 'Väärin pelaaminen / Huijaaminen', 162 + 'cheating' => 'Huijaaminen', 163 163 'multiple_accounts' => 'Käyttää useita tilejä', 164 164 'insults' => 'Loukkaa minua / muita', 165 165 'spam' => 'Spämmii',
+2 -2
resources/lang/fil/legacy_api_key.php
··· 4 4 // See the LICENCE file in the repository root for full licence text. 5 5 6 6 return [ 7 - 'new' => '', 8 - 'none' => '', 7 + 'new' => 'Bagong Legacy API Key', 8 + 'none' => 'Walang key.', 9 9 10 10 'docs' => [ 11 11 '_' => '',
+10 -10
resources/lang/fil/legacy_irc_key.php
··· 4 4 // See the LICENCE file in the repository root for full licence text. 5 5 6 6 return [ 7 - 'confirm_new' => '', 8 - 'new' => '', 9 - 'none' => '', 7 + 'confirm_new' => 'Gumawa ng bagong IRC password?', 8 + 'new' => 'Bagong Legacy IRC Password', 9 + 'none' => 'Hindi na-set ang IRC Password.', 10 10 11 11 'form' => [ 12 - 'server_host' => '', 13 - 'server_port' => '', 14 - 'token' => '', 15 - 'username' => '', 12 + 'server_host' => 'server', 13 + 'server_port' => 'port', 14 + 'token' => 'server password', 15 + 'username' => 'username', 16 16 ], 17 17 18 18 'view' => [ 19 - 'hide' => '', 20 - 'show' => '', 21 - 'delete' => '', 19 + 'hide' => 'Itago ang password', 20 + 'show' => 'Ipakita ang password', 21 + 'delete' => 'Burahin', 22 22 ], 23 23 ];
+3
resources/lang/fil/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Itala ang email address o username', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Kailangan pa ng tulong? Makipag-usap sa amin sa :button.', 41 44 'button' => 'support system',
+2 -2
resources/lang/fil/store.php
··· 6 6 return [ 7 7 'cart' => [ 8 8 'checkout' => 'Checkout', 9 - 'empty_cart' => '', 9 + 'empty_cart' => 'Tanggalin lahat ng items sa cart', 10 10 'info' => ':count_delimited pirasong item sa kariton ($:subtotal)|:count_delimited pirasong mga item sa kariton ($:subtotal)', 11 11 'more_goodies' => 'Gusto kong tingnan ang higit pang mga goodies bago makumpleto ang order', 12 12 'shipping_fees' => 'mga bayarin sa pagpapadala', ··· 49 49 ], 50 50 51 51 'discount' => 'makatipid ng :percent%', 52 - 'free' => '', 52 + 'free' => 'free!', 53 53 54 54 'invoice' => [ 55 55 'contact' => '',
+2 -2
resources/lang/fr/chat.php
··· 23 23 'title' => [ 24 24 'ANNOUNCE' => 'Annonces', 25 25 'GROUP' => 'Groupes', 26 - 'PM' => 'Messages directs', 26 + 'PM' => 'Messages privés', 27 27 'PUBLIC' => 'Canaux', 28 28 ], 29 29 ], ··· 49 49 50 50 'input' => [ 51 51 'create' => 'Créer', 52 - 'disabled' => 'impossible d\'envoyer un message...', 52 + 'disabled' => 'impossible d’envoyer le message...', 53 53 'disconnected' => 'Déconnecté', 54 54 'placeholder' => 'saisissez votre message...', 55 55 'send' => 'Envoyer',
+3
resources/lang/fr/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Entrez une adresse e-mail ou un nom d\'utilisateur', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Vous avez besoin d\'aide supplémentaire ? Contactez-nous via notre :button.', 41 44 'button' => 'système de support',
+3
resources/lang/he/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'הכנס אימייל או שם משתמש', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'צריך עזרה נוספת? צור איתנו קשר דרך ה:button שלנו.', 41 44 'button' => 'מערכת תמיכה',
+3
resources/lang/hr-HR/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Unesi svoju adresu e-pošte ili korisničko ime', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Trebaš dodatnu pomoć? Kontaktiraj nas putem naše :button.', 41 44 'button' => 'sistema za podršku',
+3
resources/lang/hu/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Add meg az e-mail címed vagy felhasználóneved', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Segítség kéne? Lépj kapcsolatba velünk itt :botton.', 41 44 'button' => 'támogatói rendszer',
+1 -1
resources/lang/id/artist.php
··· 4 4 // See the LICENCE file in the repository root for full licence text. 5 5 6 6 return [ 7 - 'page_description' => 'Featured artist di osu!', 7 + 'page_description' => 'Featured Artist di osu!', 8 8 'title' => 'Featured Artist', 9 9 10 10 'admin' => [
+1 -1
resources/lang/id/authorization.php
··· 48 48 'edit' => [ 49 49 'not_owner' => 'Hanya pemilik topik yang diperbolehkan untuk menyunting kiriman.', 50 50 'resolved' => 'Kamu tidak dapat menyunting postingan pada topik diskusi yang telah terjawab.', 51 - 'system_generated' => 'Post yang dihasilkan secara otomatis tidak dapat disunting.', 51 + 'system_generated' => 'Postingan yang dihasilkan secara otomatis tidak dapat disunting.', 52 52 ], 53 53 ], 54 54
+1 -1
resources/lang/id/beatmap_discussions.php
··· 13 13 ], 14 14 15 15 'events' => [ 16 - 'empty' => 'Belum ada hal apapun yang terjadi... hingga saat ini.', 16 + 'empty' => 'Belum ada hal apa pun yang terjadi... hingga saat ini.', 17 17 ], 18 18 19 19 'index' => [
+1 -1
resources/lang/id/follows.php
··· 24 24 ], 25 25 26 26 'mapping' => [ 27 - 'empty' => 'Kamu tidak sedang mengikuti siapapun.', 27 + 'empty' => 'Kamu tidak sedang mengikuti siapa pun.', 28 28 'followers' => 'pengikut mapping', 29 29 'page_title' => 'mapper yang diikuti', 30 30 'title' => 'mapper',
+1 -1
resources/lang/id/forum.php
··· 33 33 ], 34 34 35 35 'topics' => [ 36 - 'empty' => 'Tidak ada topik!', 36 + 'empty' => 'Tidak ada topik apa pun di sini!', 37 37 ], 38 38 ], 39 39
+1 -1
resources/lang/id/home.php
··· 7 7 'landing' => [ 8 8 'download' => 'Unduh sekarang', 9 9 'online' => 'dengan <strong>:players</strong> pemain yang saat ini terhubung dalam <strong>:games</strong> ruang permainan', 10 - 'peak' => 'Jumlah pengguna online terbanyak: :count', 10 + 'peak' => 'Puncak aktivitas: :count pengguna online', 11 11 'players' => '<strong>:count</strong> pengguna terdaftar', 12 12 'title' => 'selamat datang', 13 13 'see_more_news' => 'lihat lebih banyak berita',
+1 -1
resources/lang/id/legacy_api_key.php
··· 23 23 ], 24 24 25 25 'warning' => [ 26 - 'line1' => 'Jangan berikan informasi ini pada siapapun.', 26 + 'line1' => 'Jangan berikan informasi ini kepada siapa pun.', 27 27 'line2' => "Ini sama halnya membagikan akunmu pada yang lain.", 28 28 'line3' => 'Harap untuk tidak membagikan informasi ini.', 29 29 ],
+7 -7
resources/lang/id/notifications.php
··· 45 45 46 46 'beatmapset_discussion' => [ 47 47 '_' => 'Laman diskusi beatmap', 48 - 'beatmapset_discussion_lock' => 'Diskusi untuk beatmap ":title" telah ditutup.', 48 + 'beatmapset_discussion_lock' => 'Diskusi pada beatmap ":title" telah dikunci', 49 49 'beatmapset_discussion_lock_compact' => 'Diskusi beatmap telah dikunci', 50 50 'beatmapset_discussion_post_new' => 'Postingan baru pada ":title" oleh :username: ":content"', 51 51 'beatmapset_discussion_post_new_empty' => 'Postingan baru pada ":title" oleh :username', 52 52 'beatmapset_discussion_post_new_compact' => 'Postingan baru oleh :username: ":content"', 53 53 'beatmapset_discussion_post_new_compact_empty' => 'Postingan baru oleh :username', 54 - 'beatmapset_discussion_review_new' => 'Terdapat ulasan baru pada ":title" oleh :username yang menyinggung seputar masalah: :problems, saran: :suggestions, dan pujian berupa: :praises', 55 - 'beatmapset_discussion_review_new_compact' => 'Terdapat ulasan baru oleh :username yang menyinggung seputar masalah: :problems, saran: :suggestions, dan pujian berupa: :praises', 56 - 'beatmapset_discussion_unlock' => 'Diskusi untuk beatmap ":title" telah dibuka kembali.', 54 + 'beatmapset_discussion_review_new' => 'Kajian baru pada ":title" oleh :username yang mengandung :review_counts', 55 + 'beatmapset_discussion_review_new_compact' => 'Kajian baru oleh :username yang mengandung :review_counts', 56 + 'beatmapset_discussion_unlock' => 'Diskusi pada beatmap ":title" telah kembali dibuka', 57 57 'beatmapset_discussion_unlock_compact' => 'Diskusi beatmap telah dibuka', 58 58 59 59 'review_count' => [ ··· 80 80 'beatmapset_nominate' => '":title" telah dinominasikan', 81 81 'beatmapset_nominate_compact' => 'Beatmap telah dinominasikan', 82 82 'beatmapset_qualify' => '":title" telah memperoleh jumlah nominasi yang dibutuhkan untuk dapat memasuki antrian ranking', 83 - 'beatmapset_qualify_compact' => 'Beatmap telah memasuki antrian ranking', 83 + 'beatmapset_qualify_compact' => 'Beatmap memasuki antrian ranking', 84 84 'beatmapset_rank' => '":title" telah berstatus Ranked', 85 85 'beatmapset_rank_compact' => 'Beatmap telah berstatus Ranked', 86 86 'beatmapset_remove_from_loved' => '":title" telah dilepas dari Loved', 87 87 'beatmapset_remove_from_loved_compact' => 'Beatmap telah dilepas dari Loved', 88 - 'beatmapset_reset_nominations' => 'Masalah yang dikemukakan oleh :username menganulir nominasi sebelumnya pada beatmap ":title" ', 88 + 'beatmapset_reset_nominations' => 'Nominasi pada beatmap ":title" telah dianulir', 89 89 'beatmapset_reset_nominations_compact' => 'Nominasi beatmap dianulir', 90 90 ], 91 91 ··· 207 207 'beatmapset_qualify' => '":title" telah memperoleh jumlah nominasi yang dibutuhkan untuk dapat memasuki antrian ranking', 208 208 'beatmapset_rank' => '":title" telah berstatus Ranked', 209 209 'beatmapset_remove_from_loved' => ':title telah dilepas dari Loved', 210 - 'beatmapset_reset_nominations' => 'Status nominasi pada ":title" telah dianulir', 210 + 'beatmapset_reset_nominations' => 'Nominasi pada beatmap ":title" telah dianulir', 211 211 ], 212 212 213 213 'comment' => [
+3
resources/lang/id/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Masukkan alamat email atau nama pengguna', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Butuh bantuan lebih lanjut? Hubungi :button kami.', 41 44 'button' => 'layanan dukungan',
+3 -3
resources/lang/id/store.php
··· 15 15 16 16 'errors_no_checkout' => [ 17 17 'line_1' => 'Uh-oh, terdapat masalah dengan keranjangmu yang menghalangi proses checkout!', 18 - 'line_2' => 'Hapus atau perbarui item-item di atas untuk melanjutkan.', 18 + 'line_2' => 'Hapus atau perbarui rangkaian item di atas untuk melanjutkan.', 19 19 ], 20 20 21 21 'empty' => [ ··· 43 43 ], 44 44 45 45 'pending_checkout' => [ 46 - 'line_1' => 'Transaksi sebelumnya belum dituntaskan.', 46 + 'line_1' => 'Terdapat transaksi terdahulu yang belum dituntaskan.', 47 47 'line_2' => 'Lanjutkan pembayaranmu dengan memilih metode pembayaran.', 48 48 ], 49 49 ], ··· 77 77 ], 78 78 ], 79 79 'prepared' => [ 80 - 'title' => 'Pesananmu sedang disiapkan!', 80 + 'title' => 'Pesananmu sedang dipersiapkan!', 81 81 'line_1' => 'Harap tunggu sedikit lebih lama untuk pengiriman. Informasi pelacakan akan muncul di sini setelah pesanan telah diolah dan dikirim. Ini bisa perlu sampai 5 hari (tetapi biasanya lebih cepat!) tergantung kesibukan kami.', 82 82 'line_2' => 'Kami mengirim seluruh pesanan dari Jepang dengan berbagai macam layanan pengiriman tergantung berat dan nilai. Bagian ini akan diperbarui dengan perincian setelah kami mengirimkan pesanan.', 83 83 ],
+1 -1
resources/lang/id/wiki.php
··· 21 21 ], 22 22 23 23 'translation' => [ 24 - 'legal' => 'Terjemahan ini diberikan semata-mata hanya untuk memudahkan. :default dari artikel ini merupakan satu-satunya versi artikel yang mengikat secara hukum.', 24 + 'legal' => 'Terjemahan ini diberikan semata-mata untuk memudahkan. :default dari artikel ini merupakan satu-satunya versi artikel yang mengikat secara hukum.', 25 25 'outdated' => 'Laman ini mengandung terjemahan yang telah kedaluwarsa dari artikel aslinya. Mohon periksa :default dari artikel ini untuk mendapatkan informasi yang paling akurat (dan apabila kamu berkenan, mohon bantu kami untuk memperbarui terjemahan ini)!', 26 26 27 27 'default' => 'Versi Bahasa Inggris',
+3
resources/lang/it/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Inserisci l\'indirizzo email o il nome utente', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Hai bisogno di ulteriore assistenza? Contattaci col nostro :button.', 41 44 'button' => 'sistema di supporto',
+1 -1
resources/lang/it/store.php
··· 93 93 'title' => 'Il tuo ordine è stato spedito!', 94 94 'tracking_details' => '', 95 95 'no_tracking_details' => [ 96 - '_' => "Non disponiamo dei dettagli di tracciabilità poiché abbiamo inviato il tuo pacco tramite posta aerea, ma puoi aspettarti di riceverlo entro 1-3 settimane. Per l'Europa, a volte la dogana può ritardare l'ordine senza il nostro controllo. Se hai qualche dubbio, rispondi all'e-mail di conferma dell'ordine che hai ricevuto :link.", 96 + '_' => "Non disponiamo dei dettagli di tracciabilità poiché abbiamo inviato il tuo pacco tramite posta aerea, ma puoi aspettarti di riceverlo entro 1-3 settimane. Per l'Europa, a volte la dogana può ritardare l'ordine senza il nostro controllo. Se hai qualche dubbio, rispondi all'e-mail di conferma dell'ordine che hai ricevuto (o :link).", 97 97 'link_text' => 'inviaci un\'email', 98 98 ], 99 99 ],
+3
resources/lang/ja/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'メールアドレスまたはユーザー名を入力してください', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'さらにサポートが必要ですか? :buttonからお問い合わせください。', 41 44 'button' => 'サポートシステム',
+3
resources/lang/kk-KZ/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => '', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => '', 41 44 'button' => '',
+3
resources/lang/ko/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => '아이디나 이메일 주소를 입력하세요.', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => '도움이 필요하신가요? :button을 통해 문의해보세요.', 41 44 'button' => '지원 시스템',
+3
resources/lang/lt/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Įrašykite el. pašto adresą arba naudotojo vardą', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Reikia tolimesnės pagalbos? Susisiekite su mumis per :button.', 41 44 'button' => 'pagalbos sistemą',
+3
resources/lang/lv-LV/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Ievadiet e-pasta adresi vai lietotājvārdu', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Nepieciešams tālāks atbalsts? Sazinieties ar mums caur :button.', 41 44 'button' => 'atbalsta sistēma',
+3
resources/lang/ms-MY/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Masukkan alamat e-mel atau nama pengguna', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Perlukan bantuan lebih lanjut? Hubungi :button kami.', 41 44 'button' => 'layanan dukungan',
+3
resources/lang/nl/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Vul e-mail adres of gebruikersnaam in', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Meer hulp nodig? Neem contact met ons op via onze :button.', 41 44 'button' => 'ondersteuningssysteem',
+3
resources/lang/no/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Skriv inn e-postadresse eller brukernavn', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Trenger du mer hjelp? Kontakt oss via vårt :button.', 41 44 'button' => 'støttesystem',
+3
resources/lang/pl/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Wprowadź e-mail lub nazwę użytkownika', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Potrzebujesz pomocy? Skontaktuj się z :button.', 41 44 'button' => 'pomocą techniczną',
+3
resources/lang/pt-br/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Insira endereço de email ou nome de usuário', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Precisa de mais assistência? Entre em contato conosco através do nosso :button.', 41 44 'button' => 'sistema de suporte',
+3
resources/lang/pt/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Introduz um endereço de email ou um nome de utilizador', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Precisas de mais assistência? Contacta-nos a partir do nosso :button.', 41 44 'button' => 'sistema de suporte',
+2 -2
resources/lang/ro/beatmapset_events.php
··· 30 30 'nomination_reset' => 'O problemă nouă :discussion (:text) a declanșat reluarea unei nominalizări.', 31 31 'nomination_reset_received' => 'Nominalizarea de :user a fost resetată de către :source_user (:text)', 32 32 'nomination_reset_received_profile' => 'Nominalizarea a fost resetată de :user (:text)', 33 - 'offset_edit' => 'Offset-ul online schimbat din :old la :new.', 33 + 'offset_edit' => 'Decalaj online schimbat din :old la :new.', 34 34 'qualify' => 'Acest beatmap a atins numărul limită de nominalizări și s-a calificat.', 35 35 'rank' => 'Clasat.', 36 36 'remove_from_loved' => 'Eliminat din Iubit de :user. (:text)', ··· 79 79 'nomination_reset' => 'Resetarea nominalizărilor', 80 80 'nomination_reset_received' => 'Resetare a nominalizării primită', 81 81 'nsfw_toggle' => 'Marcaj obscen', 82 - 'offset_edit' => 'Editare offset', 82 + 'offset_edit' => 'Editare decalaj', 83 83 'qualify' => 'Calificare', 84 84 'rank' => 'Clasament', 85 85 'remove_from_loved' => 'Scoaterea din Iubit',
+2 -2
resources/lang/ro/beatmapsets.php
··· 136 136 'no_scores' => 'Încă se calculează datele...', 137 137 'nominators' => 'Nominalizatori', 138 138 'nsfw' => 'Conținut obscen', 139 - 'offset' => 'Offset online', 139 + 'offset' => 'Decalaj online', 140 140 'points-of-failure' => 'Puncte de eșec', 141 141 'source' => 'Sursă', 142 142 'storyboard' => 'Acest beatmap conține un storyboard', ··· 209 209 'bpm' => 'BPM', 210 210 'count_circles' => 'Număr Cercuri', 211 211 'count_sliders' => 'Număr Slidere', 212 - 'offset' => 'Offset online: :offset', 212 + 'offset' => 'Decalaj online: :offset', 213 213 'user-rating' => 'Rating Utilizatori', 214 214 'rating-spread' => 'Grafic Rating-uri', 215 215 'nominations' => 'Nominalizări',
+3
resources/lang/ro/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Introduceți adresa de e-mail sau numele de utilizator', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Aveți nevoie de asistență suplimentară? Contactați-ne prin intermediul :button.', 41 44 'button' => 'sistem de ajutor',
+3
resources/lang/ru/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Введите почту или ник', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Нужна дополнительная помощь? Свяжитесь с нами через :button.', 41 44 'button' => 'систему поддержки',
+1 -1
resources/lang/ru/store.php
··· 93 93 'title' => 'Ваш заказ отправлен!', 94 94 'tracking_details' => 'Подробности отслеживания:', 95 95 'no_tracking_details' => [ 96 - '_' => "У нас нет данных отслеживания, поскольку мы отправили ваш заказ авиапочтой, однако вы можете рассчитывать на их получение в течение 1-3 недель. Иногда таможня в Европе может задержать заказ вне нашего контроля. Если у вас остались вопросы, ответьте на полученное вами письмо с подтверждением заказа :link.", 96 + '_' => "У нас нет данных отслеживания, поскольку мы отправили ваш заказ авиапочтой, однако вы можете рассчитывать на их получение в течение 1-3 недель. Иногда таможня в Европе может задержать заказ вне нашего контроля. Если у вас остались вопросы, ответьте на полученное вами письмо с подтверждением заказа (или :link).", 97 97 'link_text' => 'отправьте нам письмо', 98 98 ], 99 99 ],
+3
resources/lang/si-LK/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => '', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => '', 41 44 'button' => '',
+3
resources/lang/sk/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Zadajte e-mailovú adresu alebo užívateľské meno', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => '', 41 44 'button' => '',
+3
resources/lang/sl/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Vnesi e-poštni naslov ali uporabniško ime', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Potrebuješ nadaljno pomoč? Kontaktiraj nas preko našega :button.', 41 44 'button' => 'sistema podpore',
+3
resources/lang/sr/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Унесите адресу е-поште или корисничко име', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Треба Вам додатна помоћ? Ступите у контакт преко нашег :button.', 41 44 'button' => 'систем за подршку',
+3
resources/lang/sv/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Fyll i din e-postadress eller ditt användarnamn', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Behöver du mer hjälp? Kontakta oss via vår :button.', 41 44 'button' => 'supportsystem',
+3
resources/lang/tg-TJ/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => '', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => '', 41 44 'button' => '',
+1 -1
resources/lang/tg-TJ/scores.php
··· 8 8 'title' => '', 9 9 10 10 'beatmap' => [ 11 - 'by' => '', 11 + 'by' => 'аз ҷониби :artist', 12 12 ], 13 13 14 14 'player' => [
+7 -7
resources/lang/tg-TJ/store.php
··· 8 8 'checkout' => '', 9 9 'empty_cart' => '', 10 10 'info' => '', 11 - 'more_goodies' => '', 12 - 'shipping_fees' => '', 13 - 'title' => '', 14 - 'total' => '', 11 + 'more_goodies' => 'Ман мехоҳам пеш аз ба итмом расонидани фармоиш чизҳои бештарро тафтиш кунам', 12 + 'shipping_fees' => 'ҳаққи интиқол', 13 + 'title' => 'Сабади харид', 14 + 'total' => 'умумии', 15 15 16 16 'errors_no_checkout' => [ 17 17 'line_1' => '', 18 - 'line_2' => '', 18 + 'line_2' => 'Барои идома додани ҷузъҳои боло хориҷ ё навсозӣ кунед.', 19 19 ], 20 20 21 21 'empty' => [ 22 - 'text' => '', 22 + 'text' => 'Аробаи шумо холист.', 23 23 'return_link' => [ 24 - '_' => '', 24 + '_' => 'Ба :link баргардед, то чизҳои хубро пайдо кунед!', 25 25 'link_text' => '', 26 26 ], 27 27 ],
+3 -3
resources/lang/tg-TJ/supporter_tag.php
··· 4 4 // See the LICENCE file in the repository root for full licence text. 5 5 6 6 return [ 7 - 'months' => '', 7 + 'months' => 'моҳҳо', 8 8 9 9 'user_search' => [ 10 - 'searching' => '', 11 - 'not_found' => "", 10 + 'searching' => 'чустучуй...', 11 + 'not_found' => "Ин корбар вуҷуд надорад", 12 12 ], 13 13 ];
+3
resources/lang/th/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'กรอกอีเมล หรือชื่อผู้ใช้', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => ' 41 44 ต้องการความช่วยเหลือเพิ่มเติมหรือไม่? ติดต่อเราผ่านทาง :button',
+4 -4
resources/lang/tr/accounts.php
··· 63 63 ], 64 64 65 65 'github_user' => [ 66 - 'info' => "", 66 + 'info' => "Eğer osu!'nun açık kaynaklı repository'lerinde katkılıysanız, GitHub hesabınızı bağlamanız sizin değişim günlüğü girişleriniz, osu! profilinizle ilişkilendirilecektir. osu! repository'lerinde katkı geçmişi olmayan GitHub hesapları bağlanamaz.", 67 67 'link' => 'GitHub Hesabını Bağla', 68 68 'title' => 'GitHub', 69 69 'unlink' => 'GitHub Hesabının bağlantısını Kaldır', 70 70 71 71 'error' => [ 72 - 'already_linked' => '', 73 - 'no_contribution' => '', 74 - 'unverified_email' => '', 72 + 'already_linked' => 'Bu GitHub hesabı zaten başka bir kullanıcıya bağlı.', 73 + 'no_contribution' => 'osu! repository\'lerinde katkı geçmişi olmayan GitHub hesabı bağlanamaz.', 74 + 'unverified_email' => 'Lütfen GitHub\'daki ana e-postanızı doğrulayın, sonra hesabınızı tekrar bağlamayı deneyin.', 75 75 ], 76 76 ], 77 77
+3 -3
resources/lang/tr/notifications.php
··· 57 57 'beatmapset_discussion_unlock_compact' => 'Tartışmanın kilidi kaldırılmış', 58 58 59 59 'review_count' => [ 60 - 'praises' => '', 61 - 'problems' => '', 62 - 'suggestions' => '', 60 + 'praises' => ':count_delimited övgü|:count_delimited övgü', 61 + 'problems' => ':count_delimited sorun|:count_delimited sorun', 62 + 'suggestions' => ':count_delimited öneri|:count_delimited öneri', 63 63 ], 64 64 ], 65 65
+3
resources/lang/tr/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'E-posta adresi veya kullanıcı adı girin', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Yardıma mı ihtiyacınız var? :button üzerinden bizimle iletişime geçin.', 41 44 'button' => 'Destek sistemimiz',
+5 -5
resources/lang/tr/store.php
··· 65 65 'cancelled' => [ 66 66 'title' => 'Siparişiniz iptal edildi', 67 67 'line_1' => [ 68 - '_' => "", 68 + '_' => "Eğer iptal edilmesini talep etmediyseniz lütfen :link yoluyla sipariş numaranızı bahsederek ulaşınız. (#:order_number).", 69 69 'link_text' => 'osu!store destek', 70 70 ], 71 71 ], ··· 78 78 ], 79 79 'prepared' => [ 80 80 'title' => 'Siparişiniz hazılrlanıyor!', 81 - 'line_1' => '', 82 - 'line_2' => '', 81 + 'line_1' => 'Lütfen kargolanması için az daha bekleyiniz. Takip bilgisi, siparişiniz işlenip gönderildiğinde burada belirecektir. Meşgullük durumumuza göre 5 güne kadar sürebilir (ama genellikle daha az!).', 82 + 'line_2' => 'Siparişleri, ağırlığı ve değerine bağlı olarak çeşitli kargo şirketleri kullanarak gönderiyoruz. Bu alan, siparişi gönderdiğimizde detaylarla güncellenecektir.', 83 83 ], 84 84 'processing' => [ 85 85 'title' => 'Ödemeniz henüz onaylanmadı!', ··· 93 93 'title' => 'Siparişiniz kargoya verildi!', 94 94 'tracking_details' => 'Kargo takip detayları aşağıdadır:', 95 95 'no_tracking_details' => [ 96 - '_' => "", 96 + '_' => "Paketinizi uçak kargosu yoluyla gönderdiğimiz için takip ayrıntılarına sahip değiliz, ancak paketinizi 1-3 hafta içinde almayı bekleyebilirsiniz. Avrupa'da bazen gümrükler bizim kontrolümüz dışında siparişi geciktirebilir. Herhangi bir endişeniz varsa lütfen sana gelen sipariş onay e-postasını yanıtlayınız (ya da :link).", 97 97 'link_text' => 'bize bir e-mail yollayın', 98 98 ], 99 99 ], ··· 157 157 'thanks' => [ 158 158 'title' => 'Siparişiniz için teşekkür ederiz!', 159 159 'line_1' => [ 160 - '_' => '', 160 + '_' => 'Yakında bir onay e-postası alacaksınız. Sorunuz varsa, lütfen :link!', 161 161 'link_text' => 'bizimle iletişime geçin', 162 162 ], 163 163 ],
+3
resources/lang/uk/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Введіть пошту або нікнейм', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Потрібна додаткова допомога? Зв\'яжіться з нами через :button.', 41 44 'button' => 'система підтримки',
+3
resources/lang/vi/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => 'Nhập địa chỉ email hoặc tên tài khoản', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => 'Cần nhiều sự giúp đỡ hơn? Liên hệ với chúng tôi bằng :button.', 41 44 'button' => 'hệ thống hỗ trợ',
+3
resources/lang/zh-tw/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => '輸入郵件地址或使用者名稱', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => '需要進一步的幫助?通過我們的 :button 聯繫我們。', 41 44 'button' => '支持系統',
+3
resources/lang/zh/password_reset.php
··· 36 36 'starting' => [ 37 37 'username' => '输入邮箱或用户名', 38 38 39 + 'reason' => [ 40 + 'inactive_different_country' => "", 41 + ], 39 42 'support' => [ 40 43 '_' => '需要进一步的帮助?通过我们的 :button 联系我们。', 41 44 'button' => '支持系统',
+6 -3
resources/views/beatmapsets/discussion.blade.php
··· 16 16 @section ("script") 17 17 @parent 18 18 19 - <script id="json-beatmapset-discussion" type="application/json"> 20 - {!! json_encode($initialData) !!} 21 - </script> 19 + @foreach ($initialData as $name => $data) 20 + <script id="json-{{ $name }}" type="application/json"> 21 + {!! json_encode($data) !!} 22 + </script> 23 + @endforeach 24 + 22 25 23 26 @include('beatmapsets._recommended_star_difficulty_all') 24 27 @include('layout._react_js', ['src' => 'js/beatmap-discussions.js'])
+1
resources/views/docs/_structures/user.md
··· 70 70 scores_best_count | integer | | 71 71 scores_first_count | integer | | 72 72 scores_recent_count | integer | | 73 + session_verified | boolean | | 73 74 statistics | | | 74 75 statistics_rulesets | UserStatisticsRulesets | | 75 76 support_level | | |
+4
resources/views/layout/_popup_user.blade.php
··· 16 16 <div class="u-relative">{{ Auth::user()->username }}</div> 17 17 </a> 18 18 19 + <div class="simple-menu__extra"> 20 + @include('layout._score_mode_toggle', ['class' => 'simple-menu__item']) 21 + </div> 22 + 19 23 <a 20 24 class="simple-menu__item" 21 25 href="{{ route('users.show', Auth::user()) }}"
+24
resources/views/layout/_score_mode_toggle.blade.php
··· 1 + {{-- 2 + Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 3 + See the LICENCE file in the repository root for full licence text. 4 + --}} 5 + @php 6 + $legacyScoreMode ??= App\Libraries\Search\ScoreSearchParams::showLegacyForUser(Auth::user()) === true; 7 + $icon = $legacyScoreMode 8 + ? 'far fa-square' 9 + : 'fas fa-check-square'; 10 + @endphp 11 + <button 12 + class="{{ $class }}" 13 + type="button" 14 + data-url="{{ route('account.options', ['user_profile_customization[legacy_score_only]' => !$legacyScoreMode]) }}" 15 + data-method="PUT" 16 + data-remote="1" 17 + data-reload-on-success="1" 18 + title="{{ osu_trans("layout.popup_user.links.legacy_score_only_toggle_tooltip") }}" 19 + > 20 + <span> 21 + <span class="{{ $icon }}"></span> 22 + {{ osu_trans("layout.popup_user.links.legacy_score_only_toggle") }} 23 + </span> 24 + </button>
+2
resources/views/layout/header_mobile/user.blade.php
··· 9 9 data-is-current-user="1" 10 10 ></div> 11 11 12 + @include('layout._score_mode_toggle', ['class' => 'navbar-mobile-item__main']) 13 + 12 14 <a 13 15 class="navbar-mobile-item__main" 14 16 href="{{ route('users.show', Auth::user()) }}"
+3 -2
resources/views/master.blade.php
··· 10 10 11 11 $currentUser = Auth::user(); 12 12 13 + $legacyScoreMode = App\Libraries\Search\ScoreSearchParams::showLegacyForUser($currentUser) === true; 14 + 13 15 $titleTree = []; 14 16 15 17 if (isset($titleOverride)) { ··· 50 52 51 53 <body 52 54 class=" 53 - osu-layout 54 - osu-layout--body 55 55 t-section 56 + {{ class_with_modifiers('osu-layout', 'body', ['body-lazer' => !$legacyScoreMode]) }} 56 57 {{ $bodyAdditionalClasses ?? '' }} 57 58 " 58 59 >
+3 -5
resources/views/users/beatmapset_activities.blade.php
··· 16 16 @section ("script") 17 17 @parent 18 18 19 - @foreach ($jsonChunks as $name => $data) 20 - <script id="json-{{$name}}" type="application/json"> 21 - {!! json_encode($data) !!} 22 - </script> 23 - @endforeach 19 + <script id="json-bundle" type="application/json"> 20 + {!! json_encode($jsonChunks) !!} 21 + </script> 24 22 25 23 @include('layout._react_js', ['src' => 'js/modding-profile.js']) 26 24 @endsection
+7 -2
routes/web.php
··· 407 407 // There's also a different group which skips throttle middleware. 408 408 Route::group(['as' => 'api.', 'prefix' => 'api', 'middleware' => ['api', ThrottleRequests::getApiThrottle(), 'require-scopes']], function () { 409 409 Route::group(['prefix' => 'v2'], function () { 410 + Route::group(['middleware' => ['require-scopes:any']], function () { 411 + Route::post('session/verify', 'AccountController@verify')->name('verify'); 412 + Route::post('session/verify/reissue', 'AccountController@reissueCode')->name('verify.reissue'); 413 + }); 414 + 410 415 Route::group(['as' => 'beatmaps.', 'prefix' => 'beatmaps'], function () { 411 416 Route::get('lookup', 'BeatmapsController@lookup')->name('lookup'); 412 417 413 418 Route::apiResource('packs', 'BeatmapPacksController', ['only' => ['index', 'show']]); 414 419 415 420 Route::group(['prefix' => '{beatmap}'], function () { 416 - Route::get('scores/users/{user}', 'BeatmapsController@userScore'); 417 - Route::get('scores/users/{user}/all', 'BeatmapsController@userScoreAll'); 421 + Route::get('scores/users/{user}', 'BeatmapsController@userScore')->name('user.score'); 422 + Route::get('scores/users/{user}/all', 'BeatmapsController@userScoreAll')->name('user.scores'); 418 423 Route::get('scores', 'BeatmapsController@scores')->name('scores'); 419 424 Route::get('solo-scores', 'BeatmapsController@soloScores')->name('solo-scores'); 420 425
+17 -7
tests/Browser/BeatmapDiscussionPostsTest.php
··· 15 15 16 16 class BeatmapDiscussionPostsTest extends DuskTestCase 17 17 { 18 - private $new_reply_widget_selector = '.beatmap-discussion-post--new-reply'; 18 + private const NEW_REPLY_SELECTOR = '.beatmap-discussion-post--new-reply'; 19 + private const RESOLVE_BUTTON_SELECTOR = '.btn-osu-big[data-action=reply_resolve]'; 20 + 21 + private Beatmap $beatmap; 22 + private BeatmapDiscussion $beatmapDiscussion; 23 + private Beatmapset $beatmapset; 24 + private User $mapper; 25 + private User $user; 19 26 20 27 public function testConcurrentPostAfterResolve() 21 28 { ··· 41 48 42 49 protected function writeReply(Browser $browser, $reply) 43 50 { 44 - $browser->with($this->new_reply_widget_selector, function ($new_reply) use ($reply) { 45 - $new_reply->press('Respond') 51 + $browser->with(static::NEW_REPLY_SELECTOR, function (Browser $newReply) use ($reply) { 52 + $newReply->press(trans('beatmap_discussions.reply.open.user')) 46 53 ->waitFor('textarea') 47 54 ->type('textarea', $reply); 48 55 }); ··· 50 57 51 58 protected function postReply(Browser $browser, $action) 52 59 { 53 - $browser->with($this->new_reply_widget_selector, function ($new_reply) use ($action) { 60 + $browser->with(static::NEW_REPLY_SELECTOR, function (Browser $newReply) use ($action) { 54 61 switch ($action) { 55 62 case 'resolve': 56 - $new_reply->press('Reply and Resolve'); 63 + // button may be covered by dev banner; 64 + // ->element->($selector)->getLocationOnScreenOnceScrolledIntoView() uses { block: 'end', inline: 'nearest' } which isn't enough. 65 + $newReply->scrollIntoView(static::RESOLVE_BUTTON_SELECTOR); 66 + $newReply->element(static::RESOLVE_BUTTON_SELECTOR)->click(); 57 67 break; 58 68 default: 59 - $new_reply->keys('textarea', '{enter}'); 69 + $newReply->keys('textarea', '{enter}'); 60 70 break; 61 71 } 62 72 }); ··· 110 120 $post = BeatmapDiscussionPost::factory()->timeline()->make([ 111 121 'user_id' => $this->user, 112 122 ]); 113 - $this->beatmapDiscussionPost = $this->beatmapDiscussion->beatmapDiscussionPosts()->save($post); 123 + $this->beatmapDiscussion->beatmapDiscussionPosts()->save($post); 114 124 115 125 $this->beforeApplicationDestroyed(function () { 116 126 // Similar case to SanityTest, cleanup the models we created during the test.
+3
tests/Browser/SanityTest.php
··· 294 294 } elseif ($line['message'] === "security - Error with Permissions-Policy header: Unrecognized feature: 'ch-ua-form-factor'.") { 295 295 // we don't use ch-ua-* crap and this error is thrown by youtube.com as of 2023-05-16 296 296 continue; 297 + } elseif (str_ends_with($line['message'], ' Third-party cookie will be blocked. Learn more in the Issues tab.')) { 298 + // thanks, youtube 299 + continue; 297 300 } 298 301 299 302 $return[] = $line;
+153 -92
tests/Controllers/BeatmapsControllerSoloScoresTest.php
··· 14 14 use App\Models\Genre; 15 15 use App\Models\Group; 16 16 use App\Models\Language; 17 + use App\Models\OAuth; 17 18 use App\Models\Solo\Score as SoloScore; 18 19 use App\Models\User; 19 20 use App\Models\UserGroup; ··· 40 41 41 42 $countryAcronym = static::$user->country_acronym; 42 43 44 + $otherUser2 = User::factory()->create(['country_acronym' => Country::factory()]); 45 + $otherUser3SameCountry = User::factory()->create(['country_acronym' => $countryAcronym]); 46 + 43 47 static::$scores = []; 44 - $scoreFactory = SoloScore::factory(); 45 - foreach (['solo' => 0, 'legacy' => null] as $type => $buildId) { 46 - $defaultData = ['build_id' => $buildId]; 48 + $scoreFactory = SoloScore::factory()->state(['build_id' => 0]); 49 + foreach (['solo' => false, 'legacy' => true] as $type => $isLegacy) { 50 + $scoreFactory = $scoreFactory->state([ 51 + 'legacy_score_id' => $isLegacy ? 1 : null, 52 + ]); 53 + $makeMods = fn (array $modNames): array => array_map( 54 + fn (string $modName): array => [ 55 + 'acronym' => $modName, 56 + 'settings' => [], 57 + ], 58 + [...$modNames, ...($isLegacy ? ['CL'] : [])], 59 + ); 60 + 61 + $makeTotalScores = fn (int $totalScore): array => [ 62 + 'legacy_total_score' => $totalScore * ($isLegacy ? 1 : 0), 63 + 'total_score' => $totalScore + ($isLegacy ? -1 : 0), 64 + ]; 47 65 48 - static::$scores = array_merge(static::$scores, [ 49 - "{$type}:user" => $scoreFactory->withData($defaultData, ['total_score' => 1100])->create([ 66 + static::$scores = [ 67 + ...static::$scores, 68 + "{$type}:userModsLowerScore" => $scoreFactory->withData([ 69 + 'mods' => $makeMods(['DT', 'HD']), 70 + ])->create([ 71 + ...$makeTotalScores(1000), 50 72 'beatmap_id' => static::$beatmap, 51 73 'preserve' => true, 52 74 'user_id' => static::$user, 53 75 ]), 54 - "{$type}:userMods" => $scoreFactory->withData($defaultData, [ 55 - 'total_score' => 1050, 56 - 'mods' => static::defaultMods(['DT', 'HD']), 76 + "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData([ 77 + 'mods' => $makeMods(['NC', 'PF']), 57 78 ])->create([ 79 + ...$makeTotalScores(1010), 58 80 'beatmap_id' => static::$beatmap, 59 81 'preserve' => true, 60 - 'user_id' => static::$user, 82 + 'user_id' => static::$otherUser, 61 83 ]), 62 - "{$type}:userModsNC" => $scoreFactory->withData($defaultData, [ 63 - 'total_score' => 1050, 64 - 'mods' => static::defaultMods(['NC']), 84 + "{$type}:userMods" => $scoreFactory->withData([ 85 + 'mods' => $makeMods(['DT', 'HD']), 65 86 ])->create([ 87 + ...$makeTotalScores(1050), 66 88 'beatmap_id' => static::$beatmap, 67 89 'preserve' => true, 68 90 'user_id' => static::$user, 69 91 ]), 70 - "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData($defaultData, [ 71 - 'total_score' => 1010, 72 - 'mods' => static::defaultMods(['NC', 'PF']), 92 + "{$type}:userModsNC" => $scoreFactory->withData([ 93 + 'mods' => $makeMods(['NC']), 73 94 ])->create([ 95 + ...$makeTotalScores(1050), 74 96 'beatmap_id' => static::$beatmap, 75 97 'preserve' => true, 76 - 'user_id' => static::$otherUser, 98 + 'user_id' => static::$user, 77 99 ]), 78 - "{$type}:userModsLowerScore" => $scoreFactory->withData($defaultData, [ 79 - 'total_score' => 1000, 80 - 'mods' => static::defaultMods(['DT', 'HD']), 81 - ])->create([ 100 + "{$type}:user" => $scoreFactory->create([ 101 + ...$makeTotalScores(1100), 82 102 'beatmap_id' => static::$beatmap, 83 103 'preserve' => true, 84 104 'user_id' => static::$user, 85 105 ]), 86 - "{$type}:friend" => $scoreFactory->withData($defaultData, ['total_score' => 1000])->create([ 106 + "{$type}:friend" => $scoreFactory->create([ 107 + ...$makeTotalScores(1000), 87 108 'beatmap_id' => static::$beatmap, 88 109 'preserve' => true, 89 110 'user_id' => $friend, 90 111 ]), 91 112 // With preference mods 92 - "{$type}:otherUser" => $scoreFactory->withData($defaultData, [ 93 - 'total_score' => 1000, 94 - 'mods' => static::defaultMods(['PF']), 113 + "{$type}:otherUser" => $scoreFactory->withData([ 114 + 'mods' => $makeMods(['PF']), 95 115 ])->create([ 116 + ...$makeTotalScores(1000), 96 117 'beatmap_id' => static::$beatmap, 97 118 'preserve' => true, 98 119 'user_id' => static::$otherUser, 99 120 ]), 100 - "{$type}:otherUserMods" => $scoreFactory->withData($defaultData, [ 101 - 'total_score' => 1000, 102 - 'mods' => static::defaultMods(['HD', 'PF', 'NC']), 121 + "{$type}:otherUserMods" => $scoreFactory->withData([ 122 + 'mods' => $makeMods(['HD', 'PF', 'NC']), 103 123 ])->create([ 124 + ...$makeTotalScores(1000), 104 125 'beatmap_id' => static::$beatmap, 105 126 'preserve' => true, 106 127 'user_id' => static::$otherUser, 107 128 ]), 108 - "{$type}:otherUserModsExtraNonPreferences" => $scoreFactory->withData($defaultData, [ 109 - 'total_score' => 1000, 110 - 'mods' => static::defaultMods(['DT', 'HD', 'HR']), 129 + "{$type}:otherUserModsExtraNonPreferences" => $scoreFactory->withData([ 130 + 'mods' => $makeMods(['DT', 'HD', 'HR']), 111 131 ])->create([ 132 + ...$makeTotalScores(1000), 112 133 'beatmap_id' => static::$beatmap, 113 134 'preserve' => true, 114 135 'user_id' => static::$otherUser, 115 136 ]), 116 - "{$type}:otherUserModsUnrelated" => $scoreFactory->withData($defaultData, [ 117 - 'total_score' => 1000, 118 - 'mods' => static::defaultMods(['FL']), 137 + "{$type}:otherUserModsUnrelated" => $scoreFactory->withData([ 138 + 'mods' => $makeMods(['FL']), 119 139 ])->create([ 140 + ...$makeTotalScores(1000), 120 141 'beatmap_id' => static::$beatmap, 121 142 'preserve' => true, 122 143 'user_id' => static::$otherUser, 123 144 ]), 124 145 // Same total score but achieved later so it should come up after earlier score 125 - "{$type}:otherUser2Later" => $scoreFactory->withData($defaultData, ['total_score' => 1000])->create([ 146 + "{$type}:otherUser2Later" => $scoreFactory->create([ 147 + ...$makeTotalScores(1000), 126 148 'beatmap_id' => static::$beatmap, 127 149 'preserve' => true, 128 - 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), 150 + 'user_id' => $otherUser2, 129 151 ]), 130 - "{$type}:otherUser3SameCountry" => $scoreFactory->withData($defaultData, ['total_score' => 1000])->create([ 152 + "{$type}:otherUser3SameCountry" => $scoreFactory->create([ 153 + ...$makeTotalScores(1000), 131 154 'beatmap_id' => static::$beatmap, 132 155 'preserve' => true, 133 - 'user_id' => User::factory()->state(['country_acronym' => $countryAcronym]), 156 + 'user_id' => $otherUser3SameCountry, 134 157 ]), 135 158 // Non-preserved score should be filtered out 136 - "{$type}:nonPreserved" => $scoreFactory->withData($defaultData)->create([ 159 + "{$type}:nonPreserved" => $scoreFactory->create([ 137 160 'beatmap_id' => static::$beatmap, 138 161 'preserve' => false, 139 162 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), 140 163 ]), 141 164 // Unrelated score 142 - "{$type}:unrelated" => $scoreFactory->withData($defaultData)->create([ 165 + "{$type}:unrelated" => $scoreFactory->create([ 143 166 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), 144 167 ]), 145 - ]); 168 + ]; 146 169 } 147 170 148 171 UserRelation::create([ ··· 165 188 Country::truncate(); 166 189 Genre::truncate(); 167 190 Language::truncate(); 191 + OAuth\Client::truncate(); 192 + OAuth\Token::truncate(); 168 193 SoloScore::select()->delete(); // TODO: revert to truncate after the table is actually renamed 169 194 User::truncate(); 170 195 UserGroup::truncate(); ··· 174 199 }); 175 200 } 176 201 177 - private static function defaultMods(array $modNames): array 178 - { 179 - return array_map( 180 - fn ($modName) => [ 181 - 'acronym' => $modName, 182 - 'settings' => [], 183 - ], 184 - $modNames, 185 - ); 186 - } 187 - 188 202 /** 189 203 * @dataProvider dataProviderForTestQuery 190 204 * @group RequiresScoreIndexer 191 205 */ 192 - public function testQuery(array $scoreKeys, array $params) 206 + public function testQuery(array $scoreKeys, array $params, string $route) 193 207 { 194 208 $resp = $this->actingAs(static::$user) 195 - ->json('GET', route('beatmaps.solo-scores', static::$beatmap), $params) 209 + ->json('GET', route("beatmaps.{$route}", static::$beatmap), $params) 196 210 ->assertSuccessful(); 197 211 198 212 $json = json_decode($resp->getContent(), true); ··· 202 216 } 203 217 } 204 218 219 + /** 220 + * @group RequiresScoreIndexer 221 + */ 222 + public function testUserScore() 223 + { 224 + $url = route('api.beatmaps.user.score', [ 225 + 'beatmap' => static::$beatmap->getKey(), 226 + 'legacy_only' => 1, 227 + 'mods' => ['DT', 'HD'], 228 + 'user' => static::$user->getKey(), 229 + ]); 230 + $this->actAsScopedUser(static::$user); 231 + $this 232 + ->json('GET', $url) 233 + ->assertJsonPath('score.id', static::$scores['legacy:userMods']->getKey()); 234 + } 235 + 236 + /** 237 + * @group RequiresScoreIndexer 238 + */ 239 + public function testUserScoreAll() 240 + { 241 + $url = route('api.beatmaps.user.scores', [ 242 + 'beatmap' => static::$beatmap->getKey(), 243 + 'legacy_only' => 1, 244 + 'user' => static::$user->getKey(), 245 + ]); 246 + $this->actAsScopedUser(static::$user); 247 + $this 248 + ->json('GET', $url) 249 + ->assertJsonCount(4, 'scores') 250 + ->assertJsonPath( 251 + 'scores.*.id', 252 + array_map(fn (string $key): int => static::$scores[$key]->getKey(), [ 253 + 'legacy:user', 254 + 'legacy:userMods', 255 + 'legacy:userModsNC', 256 + 'legacy:userModsLowerScore', 257 + ]) 258 + ); 259 + } 260 + 205 261 public static function dataProviderForTestQuery(): array 206 262 { 207 - return [ 208 - 'no parameters' => [[ 209 - 'solo:user', 210 - 'solo:otherUserModsNCPFHigherScore', 211 - 'solo:friend', 212 - 'solo:otherUser2Later', 213 - 'solo:otherUser3SameCountry', 214 - ], []], 215 - 'by country' => [[ 216 - 'solo:user', 217 - 'solo:otherUser3SameCountry', 218 - ], ['type' => 'country']], 219 - 'by friend' => [[ 220 - 'solo:user', 221 - 'solo:friend', 222 - ], ['type' => 'friend']], 223 - 'mods filter' => [[ 224 - 'solo:userMods', 225 - 'solo:otherUserMods', 226 - ], ['mods' => ['DT', 'HD']]], 227 - 'mods with implied filter' => [[ 228 - 'solo:userModsNC', 229 - 'solo:otherUserModsNCPFHigherScore', 230 - ], ['mods' => ['NC']]], 231 - 'mods with nomods' => [[ 232 - 'solo:user', 233 - 'solo:otherUserModsNCPFHigherScore', 234 - 'solo:friend', 235 - 'solo:otherUser2Later', 236 - 'solo:otherUser3SameCountry', 237 - ], ['mods' => ['NC', 'NM']]], 238 - 'nomods filter' => [[ 239 - 'solo:user', 240 - 'solo:friend', 241 - 'solo:otherUser', 242 - 'solo:otherUser2Later', 243 - 'solo:otherUser3SameCountry', 244 - ], ['mods' => ['NM']]], 245 - ]; 263 + $ret = []; 264 + foreach (['solo' => 'solo-scores', 'legacy' => 'scores'] as $type => $route) { 265 + $ret = array_merge($ret, [ 266 + "{$type}: no parameters" => [[ 267 + "{$type}:user", 268 + "{$type}:otherUserModsNCPFHigherScore", 269 + "{$type}:friend", 270 + "{$type}:otherUser2Later", 271 + "{$type}:otherUser3SameCountry", 272 + ], [], $route], 273 + "{$type}: by country" => [[ 274 + "{$type}:user", 275 + "{$type}:otherUser3SameCountry", 276 + ], ['type' => 'country'], $route], 277 + "{$type}: by friend" => [[ 278 + "{$type}:user", 279 + "{$type}:friend", 280 + ], ['type' => 'friend'], $route], 281 + "{$type}: mods filter" => [[ 282 + "{$type}:userMods", 283 + "{$type}:otherUserMods", 284 + ], ['mods' => ['DT', 'HD']], $route], 285 + "{$type}: mods with implied filter" => [[ 286 + "{$type}:userModsNC", 287 + "{$type}:otherUserModsNCPFHigherScore", 288 + ], ['mods' => ['NC']], $route], 289 + "{$type}: mods with nomods" => [[ 290 + "{$type}:user", 291 + "{$type}:otherUserModsNCPFHigherScore", 292 + "{$type}:friend", 293 + "{$type}:otherUser2Later", 294 + "{$type}:otherUser3SameCountry", 295 + ], ['mods' => ['NC', 'NM']], $route], 296 + "{$type}: nomods filter" => [[ 297 + "{$type}:user", 298 + "{$type}:friend", 299 + "{$type}:otherUser", 300 + "{$type}:otherUser2Later", 301 + "{$type}:otherUser3SameCountry", 302 + ], ['mods' => ['NM']], $route], 303 + ]); 304 + } 305 + 306 + return $ret; 246 307 } 247 308 }
+1 -269
tests/Controllers/BeatmapsControllerTest.php
··· 10 10 use App\Models\Beatmap; 11 11 use App\Models\Beatmapset; 12 12 use App\Models\BeatmapsetEvent; 13 - use App\Models\Country; 14 - use App\Models\Score\Best\Model as ScoreBest; 15 13 use App\Models\User; 16 - use App\Models\UserRelation; 17 14 use Illuminate\Testing\Fluent\AssertableJson; 18 - use Illuminate\Testing\TestResponse; 19 15 use Tests\TestCase; 20 16 21 17 class BeatmapsControllerTest extends TestCase ··· 106 102 { 107 103 $this->json('GET', route('beatmaps.scores', $this->beatmap), [ 108 104 'mode' => 'nope', 109 - ])->assertStatus(404); 105 + ])->assertStatus(422); 110 106 } 111 107 112 108 /** ··· 177 173 ])->assertStatus(200); 178 174 } 179 175 180 - public function testScores() 181 - { 182 - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); 183 - $scores = [ 184 - $scoreClass::factory()->create([ 185 - 'beatmap_id' => $this->beatmap, 186 - 'score' => 1100, 187 - 'user_id' => $this->user, 188 - ]), 189 - $scoreClass::factory()->create([ 190 - 'beatmap_id' => $this->beatmap, 191 - 'score' => 1000, 192 - ]), 193 - // Same total score but achieved later so it should come up after earlier score 194 - $scoreClass::factory()->create([ 195 - 'beatmap_id' => $this->beatmap, 196 - 'score' => 1000, 197 - ]), 198 - ]; 199 - // Hidden score should be filtered out 200 - $scoreClass::factory()->create([ 201 - 'beatmap_id' => $this->beatmap, 202 - 'hidden' => true, 203 - 'score' => 800, 204 - ]); 205 - // Another score from scores[0] user (should be filtered out) 206 - $scoreClass::factory()->create([ 207 - 'beatmap_id' => $this->beatmap, 208 - 'score' => 800, 209 - 'user_id' => $this->user, 210 - ]); 211 - // Unrelated score 212 - ScoreBest::getClass(array_rand(Beatmap::MODES))::factory()->create(); 213 - 214 - $resp = $this->actingAs($this->user) 215 - ->json('GET', route('beatmaps.scores', $this->beatmap)) 216 - ->assertSuccessful(); 217 - 218 - $this->assertSameScoresFromResponse($scores, $resp); 219 - } 220 - 221 - public function testScoresByCountry() 222 - { 223 - $countryAcronym = $this->user->country_acronym; 224 - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); 225 - $scores = [ 226 - $scoreClass::factory()->create([ 227 - 'beatmap_id' => $this->beatmap, 228 - 'country_acronym' => $countryAcronym, 229 - 'score' => 1100, 230 - 'user_id' => $this->user, 231 - ]), 232 - $scoreClass::factory()->create([ 233 - 'beatmap_id' => $this->beatmap, 234 - 'score' => 1000, 235 - 'country_acronym' => $countryAcronym, 236 - 'user_id' => User::factory()->state(['country_acronym' => $countryAcronym]), 237 - ]), 238 - ]; 239 - $otherCountry = Country::factory()->create(); 240 - $otherCountryAcronym = $otherCountry->acronym; 241 - $scoreClass::factory()->create([ 242 - 'beatmap_id' => $this->beatmap, 243 - 'country_acronym' => $otherCountryAcronym, 244 - 'user_id' => User::factory()->state(['country_acronym' => $otherCountryAcronym]), 245 - ]); 246 - 247 - $this->user->update(['osu_subscriber' => true]); 248 - $resp = $this->actingAs($this->user) 249 - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'type' => 'country'])) 250 - ->assertSuccessful(); 251 - 252 - $this->assertSameScoresFromResponse($scores, $resp); 253 - } 254 - 255 - public function testScoresByFriend() 256 - { 257 - $friend = User::factory()->create(); 258 - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); 259 - $scores = [ 260 - $scoreClass::factory()->create([ 261 - 'beatmap_id' => $this->beatmap, 262 - 'score' => 1100, 263 - 'user_id' => $friend, 264 - ]), 265 - // Own score is included 266 - $scoreClass::factory()->create([ 267 - 'beatmap_id' => $this->beatmap, 268 - 'score' => 1000, 269 - 'user_id' => $this->user, 270 - ]), 271 - ]; 272 - UserRelation::create([ 273 - 'friend' => true, 274 - 'user_id' => $this->user->getKey(), 275 - 'zebra_id' => $friend->getKey(), 276 - ]); 277 - // Non-friend score is filtered out 278 - $scoreClass::factory()->create([ 279 - 'beatmap_id' => $this->beatmap, 280 - ]); 281 - 282 - $this->user->update(['osu_subscriber' => true]); 283 - $resp = $this->actingAs($this->user) 284 - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'type' => 'friend'])) 285 - ->assertSuccessful(); 286 - 287 - $this->assertSameScoresFromResponse($scores, $resp); 288 - } 289 - 290 - public function testScoresModsFilter() 291 - { 292 - $modsHelper = app('mods'); 293 - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); 294 - $scores = [ 295 - $scoreClass::factory()->create([ 296 - 'beatmap_id' => $this->beatmap, 297 - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD']), 298 - 'score' => 1500, 299 - ]), 300 - // Score with preference mods is included 301 - $scoreClass::factory()->create([ 302 - 'beatmap_id' => $this->beatmap, 303 - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD', 'NC', 'PF']), 304 - 'score' => 1100, 305 - 'user_id' => $this->user, 306 - ]), 307 - ]; 308 - // No mod is filtered out 309 - $scoreClass::factory()->create([ 310 - 'beatmap_id' => $this->beatmap, 311 - 'enabled_mods' => 0, 312 - ]); 313 - // Unrelated mod is filtered out 314 - $scoreClass::factory()->create([ 315 - 'beatmap_id' => $this->beatmap, 316 - 'enabled_mods' => $modsHelper->idsToBitset(['FL']), 317 - ]); 318 - // Extra non-preference mod is filtered out 319 - $scoreClass::factory()->create([ 320 - 'beatmap_id' => $this->beatmap, 321 - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD', 'HR']), 322 - ]); 323 - // From same user but lower score is filtered out 324 - $scoreClass::factory()->create([ 325 - 'beatmap_id' => $this->beatmap, 326 - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD']), 327 - 'score' => 1000, 328 - 'user_id' => $this->user, 329 - ]); 330 - 331 - $this->user->update(['osu_subscriber' => true]); 332 - $resp = $this->actingAs($this->user) 333 - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['DT', 'HD']])) 334 - ->assertSuccessful(); 335 - 336 - $this->assertSameScoresFromResponse($scores, $resp); 337 - } 338 - 339 - public function testScoresModsWithImpliedFilter() 340 - { 341 - $modsHelper = app('mods'); 342 - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); 343 - $scores = [ 344 - $scoreClass::factory()->create([ 345 - 'beatmap_id' => $this->beatmap, 346 - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC']), 347 - 'score' => 1500, 348 - ]), 349 - // Score with preference mods is included 350 - $scoreClass::factory()->create([ 351 - 'beatmap_id' => $this->beatmap, 352 - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC', 'PF']), 353 - 'score' => 1100, 354 - 'user_id' => $this->user, 355 - ]), 356 - ]; 357 - // No mod is filtered out 358 - $scoreClass::factory()->create([ 359 - 'beatmap_id' => $this->beatmap, 360 - 'enabled_mods' => 0, 361 - ]); 362 - 363 - $this->user->update(['osu_subscriber' => true]); 364 - $resp = $this->actingAs($this->user) 365 - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['NC']])) 366 - ->assertSuccessful(); 367 - 368 - $this->assertSameScoresFromResponse($scores, $resp); 369 - } 370 - 371 - public function testScoresModsWithNomodsFilter() 372 - { 373 - $modsHelper = app('mods'); 374 - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); 375 - $scores = [ 376 - $scoreClass::factory()->create([ 377 - 'beatmap_id' => $this->beatmap, 378 - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC']), 379 - 'score' => 1500, 380 - ]), 381 - $scoreClass::factory()->create([ 382 - 'beatmap_id' => $this->beatmap, 383 - 'enabled_mods' => 0, 384 - 'score' => 1100, 385 - 'user_id' => $this->user, 386 - ]), 387 - ]; 388 - // With unrelated mod 389 - $scoreClass::factory()->create([ 390 - 'beatmap_id' => $this->beatmap, 391 - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC', 'HD']), 392 - 'score' => 1500, 393 - ]); 394 - 395 - $this->user->update(['osu_subscriber' => true]); 396 - $resp = $this->actingAs($this->user) 397 - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['DT', 'NC', 'NM']])) 398 - ->assertSuccessful(); 399 - 400 - $this->assertSameScoresFromResponse($scores, $resp); 401 - } 402 - 403 - public function testScoresNomodsFilter() 404 - { 405 - $modsHelper = app('mods'); 406 - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); 407 - $scores = [ 408 - $scoreClass::factory()->create([ 409 - 'beatmap_id' => $this->beatmap, 410 - 'score' => 1500, 411 - 'enabled_mods' => 0, 412 - ]), 413 - // Preference mod is included 414 - $scoreClass::factory()->create([ 415 - 'beatmap_id' => $this->beatmap, 416 - 'score' => 1100, 417 - 'user_id' => $this->user, 418 - 'enabled_mods' => $modsHelper->idsToBitset(['PF']), 419 - ]), 420 - ]; 421 - // Non-preference mod is filtered out 422 - $scoreClass::factory()->create([ 423 - 'beatmap_id' => $this->beatmap, 424 - 'enabled_mods' => $modsHelper->idsToBitset(['DT']), 425 - ]); 426 - 427 - $this->user->update(['osu_subscriber' => true]); 428 - $resp = $this->actingAs($this->user) 429 - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['NM']])) 430 - ->assertSuccessful(); 431 - 432 - $this->assertSameScoresFromResponse($scores, $resp); 433 - } 434 - 435 176 public function testShowForApi() 436 177 { 437 178 $beatmap = Beatmap::factory()->create(); ··· 619 360 620 361 $this->user = User::factory()->create(); 621 362 $this->beatmap = Beatmap::factory()->qualified()->create(); 622 - } 623 - 624 - private function assertSameScoresFromResponse(array $scores, TestResponse $response): void 625 - { 626 - $json = json_decode($response->getContent(), true); 627 - $this->assertSame(count($scores), count($json['scores'])); 628 - foreach ($json['scores'] as $i => $jsonScore) { 629 - $this->assertSame($scores[$i]->getKey(), $jsonScore['id']); 630 - } 631 363 } 632 364 633 365 private function createExistingFruitsBeatmap()
+21 -9
tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php
··· 23 23 $scoreLinks[] = ScoreLink 24 24 ::factory() 25 25 ->state(['playlist_item_id' => $playlist]) 26 - ->completed([], ['passed' => true, 'total_score' => 30]) 26 + ->completed(['passed' => true, 'total_score' => 30]) 27 27 ->create(); 28 28 $scoreLinks[] = $userScoreLink = ScoreLink 29 29 ::factory() 30 30 ->state([ 31 31 'playlist_item_id' => $playlist, 32 32 'user_id' => $user, 33 - ])->completed([], ['passed' => true, 'total_score' => 20]) 33 + ])->completed(['passed' => true, 'total_score' => 20]) 34 34 ->create(); 35 35 $scoreLinks[] = ScoreLink 36 36 ::factory() 37 37 ->state(['playlist_item_id' => $playlist]) 38 - ->completed([], ['passed' => true, 'total_score' => 10]) 38 + ->completed(['passed' => true, 'total_score' => 10]) 39 39 ->create(); 40 40 41 41 foreach ($scoreLinks as $scoreLink) { ··· 65 65 $scoreLinks[] = ScoreLink 66 66 ::factory() 67 67 ->state(['playlist_item_id' => $playlist]) 68 - ->completed([], ['passed' => true, 'total_score' => 30]) 68 + ->completed(['passed' => true, 'total_score' => 30]) 69 69 ->create(); 70 70 $scoreLinks[] = $userScoreLink = ScoreLink 71 71 ::factory() 72 72 ->state([ 73 73 'playlist_item_id' => $playlist, 74 74 'user_id' => $user, 75 - ])->completed([], ['passed' => true, 'total_score' => 20]) 75 + ])->completed(['passed' => true, 'total_score' => 20]) 76 76 ->create(); 77 77 $scoreLinks[] = ScoreLink 78 78 ::factory() 79 79 ->state(['playlist_item_id' => $playlist]) 80 - ->completed([], ['passed' => true, 'total_score' => 10]) 80 + ->completed(['passed' => true, 'total_score' => 10]) 81 81 ->create(); 82 82 83 83 foreach ($scoreLinks as $scoreLink) { ··· 103 103 */ 104 104 public function testStore($allowRanking, $hashParam, $status) 105 105 { 106 + $origClientCheckVersion = $GLOBALS['cfg']['osu']['client']['check_version']; 107 + config_set('osu.client.check_version', true); 106 108 $user = User::factory()->create(); 107 109 $playlistItem = PlaylistItem::factory()->create(); 108 110 $build = Build::factory()->create(['allow_ranking' => $allowRanking]); 109 111 110 112 $this->actAsScopedUser($user, ['*']); 111 113 112 - $params = []; 113 114 if ($hashParam !== null) { 114 - $params['version_hash'] = $hashParam ? bin2hex($build->hash) : md5('invalid_'); 115 + $this->withHeaders([ 116 + 'x-token' => $hashParam ? static::createClientToken($build) : strtoupper(md5('invalid_')), 117 + ]); 115 118 } 116 119 117 120 $countDiff = ((string) $status)[0] === '2' ? 1 : 0; ··· 120 123 $this->json('POST', route('api.rooms.playlist.scores.store', [ 121 124 'room' => $playlistItem->room_id, 122 125 'playlist' => $playlistItem->getKey(), 123 - ]), $params)->assertStatus($status); 126 + ]))->assertStatus($status); 127 + 128 + config_set('osu.client.check_version', $origClientCheckVersion); 124 129 } 125 130 126 131 /** ··· 133 138 $room = $playlistItem->room; 134 139 $build = Build::factory()->create(['allow_ranking' => true]); 135 140 $scoreToken = $room->startPlay($user, $playlistItem, 0); 141 + 142 + $this->withHeaders(['x-token' => static::createClientToken($build)]); 143 + 144 + $this->expectCountChange( 145 + fn () => \LaravelRedis::llen($GLOBALS['cfg']['osu']['client']['token_queue']), 146 + $status === 200 ? 1 : 0, 147 + ); 136 148 137 149 $this->actAsScopedUser($user, ['*']); 138 150
+81
tests/Controllers/OAuth/TokensControllerTest.php
··· 5 5 6 6 namespace Tests\Controllers\OAuth; 7 7 8 + use App\Libraries\OAuth\EncodeToken; 9 + use App\Mail\UserVerification as UserVerificationMail; 10 + use App\Models\OAuth\Client; 8 11 use App\Models\OAuth\Token; 12 + use App\Models\User; 13 + use Database\Factories\OAuth\ClientFactory; 9 14 use Database\Factories\OAuth\RefreshTokenFactory; 15 + use Database\Factories\UserFactory; 10 16 use Tests\TestCase; 11 17 12 18 class TokensControllerTest extends TestCase 13 19 { 20 + public static function dataProviderForTestIssueTokenWithRefreshTokenInheritsVerified(): array 21 + { 22 + return [ 23 + [true], 24 + [false], 25 + ]; 26 + } 27 + 14 28 public function testDestroyCurrent() 15 29 { 16 30 $refreshToken = (new RefreshTokenFactory())->create(); ··· 35 49 ->assertSuccessful(); 36 50 37 51 $this->assertTrue($token->fresh()->revoked); 52 + } 53 + 54 + public function testIssueTokenWithPassword(): void 55 + { 56 + \Mail::fake(); 57 + 58 + $user = User::factory()->create(); 59 + $client = (new ClientFactory())->create([ 60 + 'password_client' => true, 61 + ]); 62 + 63 + $this->expectCountChange(fn () => $user->tokens()->count(), 1); 64 + 65 + $tokenJson = $this->json('POST', route('oauth.passport.token'), [ 66 + 'grant_type' => 'password', 67 + 'client_id' => $client->getKey(), 68 + 'client_secret' => $client->secret, 69 + 'scope' => '*', 70 + 'username' => $user->username, 71 + 'password' => UserFactory::DEFAULT_PASSWORD, 72 + ])->assertSuccessful() 73 + ->decodeResponseJson(); 74 + 75 + $this->json('GET', route('api.me'), [], [ 76 + 'Authorization' => "Bearer {$tokenJson['access_token']}", 77 + ])->assertSuccessful() 78 + ->assertJsonPath('session_verified', false); 79 + 80 + // unverified access to api should trigger this but not necessarily return 401 81 + \Mail::assertQueued(UserVerificationMail::class); 82 + } 83 + 84 + /** 85 + * @dataProvider dataProviderForTestIssueTokenWithRefreshTokenInheritsVerified 86 + */ 87 + public function testIssueTokenWithRefreshTokenInheritsVerified(bool $verified): void 88 + { 89 + \Mail::fake(); 90 + 91 + $client = Client::factory()->create(['password_client' => true]); 92 + $accessToken = Token::factory()->create([ 93 + 'client_id' => $client, 94 + 'scopes' => ['*'], 95 + 'verified' => $verified, 96 + ]); 97 + $refreshToken = (new RefreshTokenFactory()) 98 + ->create(['access_token_id' => $accessToken]); 99 + $refreshTokenString = EncodeToken::encodeRefreshToken($refreshToken, $accessToken); 100 + $user = $accessToken->user; 101 + 102 + $this->expectCountChange(fn () => $user->tokens()->count(), 1); 103 + 104 + $tokenJson = $this->json('POST', route('oauth.passport.token'), [ 105 + 'grant_type' => 'refresh_token', 106 + 'client_id' => $client->getKey(), 107 + 'client_secret' => $client->secret, 108 + 'refresh_token' => $refreshTokenString, 109 + 'scope' => implode(' ', $accessToken->scopes), 110 + ])->assertSuccessful() 111 + ->decodeResponseJson(); 112 + 113 + $this->json('GET', route('api.me'), [], [ 114 + 'Authorization' => "Bearer {$tokenJson['access_token']}", 115 + ])->assertSuccessful() 116 + ->assertJsonPath('session_verified', $verified); 117 + 118 + \Mail::assertQueued(UserVerificationMail::class, $verified ? 0 : 1); 38 119 } 39 120 }
+11
tests/Controllers/PasswordResetControllerTest.php
··· 17 17 18 18 class PasswordResetControllerTest extends TestCase 19 19 { 20 + private string $origCacheDefault; 21 + 20 22 private static function randomPassword(): string 21 23 { 22 24 return str_random(10); ··· 283 285 { 284 286 parent::setUp(); 285 287 $this->withoutMiddleware(ThrottleRequests::class); 288 + // There's no easy way to clear data cache in redis otherwise 289 + $this->origCacheDefault = $GLOBALS['cfg']['cache']['default']; 290 + config_set('cache.default', 'array'); 291 + } 292 + 293 + protected function tearDown(): void 294 + { 295 + parent::tearDown(); 296 + config_set('cache.default', $this->origCacheDefault); 286 297 } 287 298 288 299 private function generateKey(User $user): string
+19 -11
tests/Controllers/ScoreTokensControllerTest.php
··· 29 29 'beatmap' => $beatmap->getKey(), 30 30 'ruleset_id' => $beatmap->playmode, 31 31 ]; 32 - $bodyParams = [ 33 - 'beatmap_hash' => $beatmap->checksum, 34 - 'version_hash' => bin2hex($this->build->hash), 35 - ]; 32 + $bodyParams = ['beatmap_hash' => $beatmap->checksum]; 33 + $this->withHeaders(['x-token' => static::createClientToken($this->build)]); 36 34 37 35 $this->expectCountChange(fn () => ScoreToken::count(), $status >= 200 && $status < 300 ? 1 : 0); 38 36 ··· 49 47 */ 50 48 public function testStoreInvalidParameter(string $paramKey, ?string $paramValue, int $status): void 51 49 { 50 + $origClientCheckVersion = $GLOBALS['cfg']['osu']['client']['check_version']; 51 + config_set('osu.client.check_version', true); 52 52 $beatmap = Beatmap::factory()->ranked()->create(); 53 53 54 54 $this->actAsScopedUser($this->user, ['*']); ··· 56 56 $params = [ 57 57 'beatmap' => $beatmap->getKey(), 58 58 'ruleset_id' => $beatmap->playmode, 59 - 'version_hash' => bin2hex($this->build->hash), 60 59 'beatmap_hash' => $beatmap->checksum, 61 60 ]; 62 - $params[$paramKey] = $paramValue; 61 + $this->withHeaders([ 62 + 'x-token' => $paramKey === 'client_token' 63 + ? $paramValue 64 + : static::createClientToken($this->build), 65 + ]); 66 + 67 + if ($paramKey !== 'client_token') { 68 + $params[$paramKey] = $paramValue; 69 + } 63 70 64 71 $routeParams = [ 65 72 'beatmap' => $params['beatmap'], ··· 67 74 ]; 68 75 $bodyParams = [ 69 76 'beatmap_hash' => $params['beatmap_hash'], 70 - 'version_hash' => $params['version_hash'], 71 77 ]; 72 78 73 79 $this->expectCountChange(fn () => ScoreToken::count(), 0); 74 80 75 81 $errorMessage = $paramValue === null ? 'missing' : 'invalid'; 76 82 $errorMessage .= ' '; 77 - $errorMessage .= $paramKey === 'version_hash' 83 + $errorMessage .= $paramKey === 'client_token' 78 84 ? ($paramValue === null 79 - ? 'client version' 85 + ? 'token header' 80 86 : 'client hash' 81 87 ) : $paramKey; 82 88 ··· 88 94 ->assertJson([ 89 95 'error' => $errorMessage, 90 96 ]); 97 + 98 + config_set('osu.client.check_version', $origClientCheckVersion); 91 99 } 92 100 93 101 public static function dataProviderForTestStore(): array ··· 104 112 public static function dataProviderForTestStoreInvalidParameter(): array 105 113 { 106 114 return [ 107 - 'invalid build hash' => ['version_hash', md5('invalid_'), 422], 108 - 'missing build hash' => ['version_hash', null, 422], 115 + 'invalid client token' => ['client_token', md5('invalid_'), 422], 116 + 'missing client token' => ['client_token', null, 422], 109 117 110 118 'invalid ruleset id' => ['ruleset_id', '5', 422], 111 119 'missing ruleset id' => ['ruleset_id', null, 422],
+1 -1
tests/Controllers/ScoresControllerTest.php
··· 33 33 public function testDownloadSoloScore() 34 34 { 35 35 $soloScore = SoloScore::factory() 36 - ->withData(['legacy_score_id' => $this->score->getKey()]) 37 36 ->create([ 37 + 'legacy_score_id' => $this->score->getKey(), 38 38 'ruleset_id' => Beatmap::MODES[$this->score->getMode()], 39 39 'has_replay' => true, 40 40 ]);
+8 -4
tests/Controllers/Solo/ScoresControllerTest.php
··· 5 5 6 6 namespace Tests\Controllers\Solo; 7 7 8 - use App\Models\Score as LegacyScore; 8 + use App\Models\Build; 9 9 use App\Models\ScoreToken; 10 10 use App\Models\Solo\Score; 11 11 use App\Models\User; ··· 16 16 { 17 17 public function testStore() 18 18 { 19 - $scoreToken = ScoreToken::factory()->create(); 20 - $legacyScoreClass = LegacyScore\Model::getClassByRulesetId($scoreToken->beatmap->playmode); 19 + $build = Build::factory()->create(['allow_ranking' => true]); 20 + $scoreToken = ScoreToken::factory()->create(['build_id' => $build]); 21 21 22 22 $this->expectCountChange(fn () => Score::count(), 1); 23 - $this->expectCountChange(fn () => $legacyScoreClass::count(), 1); 24 23 $this->expectCountChange(fn () => $this->processingQueueCount(), 1); 24 + $this->expectCountChange( 25 + fn () => \LaravelRedis::llen($GLOBALS['cfg']['osu']['client']['token_queue']), 26 + 1, 27 + ); 25 28 29 + $this->withHeaders(['x-token' => static::createClientToken($build)]); 26 30 $this->actAsScopedUser($scoreToken->user, ['*']); 27 31 28 32 $this->json(
+10 -6
tests/Controllers/UsersControllerTest.php
··· 50 50 $this->assertSame($previousCount + 1, User::count()); 51 51 } 52 52 53 - public function testStoreRegModeWeb() 53 + public function testStoreRegModeWebOnly() 54 54 { 55 - config_set('osu.user.registration_mode', 'web'); 55 + config_set('osu.user.registration_mode.client', false); 56 + config_set('osu.user.registration_mode.web', true); 56 57 $this->expectCountChange(fn () => User::count(), 0); 57 58 58 59 $this ··· 131 132 $this->assertSame($previousCount, User::count()); 132 133 } 133 134 134 - public function testStoreWebRegModeClient() 135 + public function testStoreWebRegModeClientOnly() 135 136 { 137 + config_set('osu.user.registration_mode.client', true); 138 + config_set('osu.user.registration_mode.web', false); 139 + 136 140 $this->expectCountChange(fn () => User::count(), 0); 137 141 138 142 $this->post(route('users.store'), [ ··· 149 153 150 154 public function testStoreWeb(): void 151 155 { 152 - config_set('osu.user.registration_mode', 'web'); 156 + config_set('osu.user.registration_mode.web', true); 153 157 $this->expectCountChange(fn () => User::count(), 1); 154 158 155 159 $this->post(route('users.store-web'), [ ··· 168 172 */ 169 173 public function testStoreWebInvalidParams($username, $email, $emailConfirmation, $password, $passwordConfirmation): void 170 174 { 171 - config_set('osu.user.registration_mode', 'web'); 175 + config_set('osu.user.registration_mode.web', true); 172 176 $this->expectCountChange(fn () => User::count(), 0); 173 177 174 178 $this->post(route('users.store-web'), [ ··· 184 188 185 189 public function testStoreWebLoggedIn(): void 186 190 { 187 - config_set('osu.user.registration_mode', 'web'); 191 + config_set('osu.user.registration_mode.web', true); 188 192 $user = User::factory()->create(); 189 193 190 194 $this->expectCountChange(fn () => User::count(), 0);
+1 -8
tests/Jobs/RemoveBeatmapsetSoloScoresTest.php
··· 16 16 use App\Models\Group; 17 17 use App\Models\Language; 18 18 use App\Models\Solo\Score; 19 - use App\Models\Solo\ScorePerformance; 20 19 use App\Models\User; 21 20 use App\Models\UserGroup; 22 21 use App\Models\UserGroupEvent; ··· 36 35 fn (): Score => $this->createScore($beatmapset), 37 36 array_fill(0, 10, null), 38 37 ); 39 - foreach ($scores as $i => $score) { 40 - $score->performance()->create(['pp' => rand(0, 1000)]); 41 - } 42 38 $userAdditionalScores = array_map( 43 39 fn (Score $score) => $this->createScore($beatmapset, $score->user_id, $score->ruleset_id), 44 40 $scores, ··· 48 44 49 45 // These scores shouldn't be deleted 50 46 for ($i = 0; $i < 10; $i++) { 51 - $score = $this->createScore($beatmapset); 52 - $score->performance()->create(['pp' => rand(0, 1000)]); 47 + $this->createScore($beatmapset); 53 48 } 54 49 55 50 $this->expectCountChange(fn () => Score::count(), count($scores) * -2, 'removes scores'); 56 - $this->expectCountChange(fn () => ScorePerformance::count(), count($scores) * -1, 'removes score performances'); 57 51 58 52 static::reindexScores(); 59 53 ··· 71 65 Genre::truncate(); 72 66 Language::truncate(); 73 67 Score::select()->delete(); // TODO: revert to truncate after the table is actually renamed 74 - ScorePerformance::select()->delete(); // TODO: revert to truncate after the table is actually renamed 75 68 User::truncate(); 76 69 UserGroup::truncate(); 77 70 UserGroupEvent::truncate();
+18 -59
tests/Libraries/ClientCheckTest.php
··· 7 7 8 8 use App\Libraries\ClientCheck; 9 9 use App\Models\Build; 10 - use App\Models\User; 11 10 use Tests\TestCase; 12 11 13 12 class ClientCheckTest extends TestCase 14 13 { 15 - public function testFindBuild() 14 + public function testParseToken(): void 16 15 { 17 - $user = User::factory()->withGroup('default')->create(); 18 16 $build = Build::factory()->create(['allow_ranking' => true]); 17 + $request = \Request::instance(); 18 + $request->headers->set('x-token', static::createClientToken($build)); 19 19 20 - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => bin2hex($build->hash)]); 20 + $parsed = ClientCheck::parseToken($request); 21 21 22 - $this->assertSame($build->getKey(), $foundBuild->getKey()); 22 + $this->assertSame($build->getKey(), $parsed['buildId']); 23 + $this->assertNotNull($parsed['token']); 23 24 } 24 25 25 - public function testFindBuildAsAdmin() 26 + public function testParseTokenExpired() 26 27 { 27 - $user = User::factory()->withGroup('admin')->create(); 28 28 $build = Build::factory()->create(['allow_ranking' => true]); 29 + $request = \Request::instance(); 30 + $request->headers->set('x-token', static::createClientToken($build, 0)); 29 31 30 - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => bin2hex($build->hash)]); 32 + $parsed = ClientCheck::parseToken($request); 31 33 32 - $this->assertSame($build->getKey(), $foundBuild->getKey()); 34 + $this->assertSame($build->getKey(), $parsed['buildId']); 35 + $this->assertNull($parsed['token']); 33 36 } 34 37 35 - public function testFindBuildDisallowedRanking() 38 + public function testParseTokenNonRankedBuild(): void 36 39 { 37 - $user = User::factory()->withGroup('default')->create(); 38 40 $build = Build::factory()->create(['allow_ranking' => false]); 41 + $request = \Request::instance(); 42 + $request->headers->set('x-token', static::createClientToken($build)); 39 43 40 - $this->expectExceptionMessage('invalid client hash'); 41 - ClientCheck::findBuild($user, ['version_hash' => bin2hex($build->hash)]); 42 - } 44 + $parsed = ClientCheck::parseToken($request); 43 45 44 - public function testFindBuildMissingParam() 45 - { 46 - $user = User::factory()->withGroup('default')->create(); 47 - 48 - $this->expectExceptionMessage('missing client version'); 49 - ClientCheck::findBuild($user, []); 50 - } 51 - 52 - public function testFindBuildNonexistent() 53 - { 54 - $user = User::factory()->withGroup('default')->create(); 55 - 56 - $this->expectExceptionMessage('invalid client hash'); 57 - ClientCheck::findBuild($user, ['version_hash' => 'stuff']); 58 - } 59 - 60 - public function testFindBuildNonexistentAsAdmin() 61 - { 62 - $user = User::factory()->withGroup('admin')->create(); 63 - 64 - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => 'stuff']); 65 - 66 - $this->assertNull($foundBuild); 67 - } 68 - 69 - public function testFindBuildNonexistentWithDisabledAssertion() 70 - { 71 - config_set('osu.client.check_version', false); 72 - 73 - $user = User::factory()->withGroup('default')->create(); 74 - 75 - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => 'stuff']); 76 - 77 - $this->assertNull($foundBuild); 78 - } 79 - 80 - public function testFindBuildStringHash() 81 - { 82 - $user = User::factory()->withGroup('default')->create(); 83 - $hashString = 'hello'; 84 - $build = Build::factory()->create(['allow_ranking' => true, 'hash' => md5($hashString, true)]); 85 - 86 - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => $hashString]); 87 - 88 - $this->assertSame($build->getKey(), $foundBuild->getKey()); 46 + $this->assertSame($GLOBALS['cfg']['osu']['client']['default_build_id'], $parsed['buildId']); 47 + $this->assertNull($parsed['token']); 89 48 } 90 49 }
+115
tests/Libraries/SessionVerification/ControllerTest.php
··· 11 11 use App\Libraries\SessionVerification; 12 12 use App\Mail\UserVerification as UserVerificationMail; 13 13 use App\Models\LoginAttempt; 14 + use App\Models\OAuth\Client; 15 + use App\Models\OAuth\Token; 14 16 use App\Models\User; 15 17 use Tests\TestCase; 16 18 ··· 56 58 $this->assertNotSame($state->key, SessionVerification\State::fromSession($session)->key); 57 59 } 58 60 61 + public function testReissueOAuthVerified(): void 62 + { 63 + \Mail::fake(); 64 + $token = Token::factory()->create(['verified' => true]); 65 + 66 + $this 67 + ->actingWithToken($token) 68 + ->post(route('api.verify.reissue')) 69 + ->assertStatus(422); 70 + 71 + \Mail::assertNotQueued(UserVerificationMail::class); 72 + $this->assertNull(SessionVerification\State::fromSession($token)); 73 + } 74 + 75 + public function testReissueVerified(): void 76 + { 77 + \Mail::fake(); 78 + $user = User::factory()->create(); 79 + $session = \Session::instance(); 80 + $session->markVerified(); 81 + 82 + $this 83 + ->be($user) 84 + ->withPersistentSession($session) 85 + ->post(route('account.reissue-code')) 86 + ->assertStatus(422); 87 + 88 + \Mail::assertNotQueued(UserVerificationMail::class); 89 + $this->assertNull(SessionVerification\State::fromSession($session)); 90 + } 91 + 59 92 public function testVerify(): void 60 93 { 61 94 $user = User::factory()->create(); ··· 131 164 $this->assertFalse(SessionStore::findOrNew($sessionId)->isVerified()); 132 165 } 133 166 167 + public function testVerifyLinkOAuth(): void 168 + { 169 + $token = Token::factory()->create([ 170 + 'client_id' => Client::factory()->create(['password_client' => true]), 171 + 'verified' => false, 172 + ]); 173 + 174 + $this 175 + ->actingWithToken($token) 176 + ->get(route('api.me')) 177 + ->assertSuccessful(); 178 + 179 + $linkKey = SessionVerification\State::fromSession($token)->linkKey; 180 + 181 + \Auth::logout(); 182 + $this 183 + ->withPersistentSession(SessionStore::findOrNew()) 184 + ->get(route('account.verify', ['key' => $linkKey])) 185 + ->assertSuccessful(); 186 + 187 + $record = LoginAttempt::find('127.0.0.1'); 188 + 189 + $this->assertFalse($record->containsUser($token->user, 'verify-mismatch:')); 190 + $this->assertTrue($token->fresh()->isVerified()); 191 + } 192 + 134 193 public function testVerifyMismatch(): void 135 194 { 136 195 $user = User::factory()->create(); ··· 155 214 156 215 $this->assertTrue($record->containsUser($user, 'verify-mismatch:')); 157 216 $this->assertFalse($session->isVerified()); 217 + } 218 + 219 + public function testVerifyOAuth(): void 220 + { 221 + $token = Token::factory()->create([ 222 + 'client_id' => Client::factory()->create(['password_client' => true]), 223 + 'verified' => false, 224 + ]); 225 + 226 + $this 227 + ->actingWithToken($token) 228 + ->get(route('api.me')) 229 + ->assertSuccessful(); 230 + 231 + $key = SessionVerification\State::fromSession($token)->key; 232 + 233 + $this 234 + ->actingWithToken($token) 235 + ->post(route('api.verify', ['verification_key' => $key])) 236 + ->assertSuccessful(); 237 + 238 + $record = LoginAttempt::find('127.0.0.1'); 239 + 240 + $this->assertFalse($record->containsUser($token->user, 'verify-mismatch:')); 241 + $this->assertTrue($token->fresh()->isVerified()); 242 + } 243 + 244 + public function testVerifyOAuthVerified(): void 245 + { 246 + \Mail::fake(); 247 + $token = Token::factory()->create(['verified' => true]); 248 + 249 + $this 250 + ->actingWithToken($token) 251 + ->post(route('api.verify', ['verification_key' => 'invalid'])) 252 + ->assertSuccessful(); 253 + 254 + $this->assertNull(SessionVerification\State::fromSession($token)); 255 + \Mail::assertNotQueued(UserVerificationMail::class); 256 + } 257 + 258 + public function testVerifyVerified(): void 259 + { 260 + \Mail::fake(); 261 + $user = User::factory()->create(); 262 + $session = \Session::instance(); 263 + $session->markVerified(); 264 + 265 + $this 266 + ->be($user) 267 + ->withPersistentSession($session) 268 + ->post(route('account.verify'), ['verification_key' => 'invalid']) 269 + ->assertSuccessful(); 270 + 271 + $this->assertNull(SessionVerification\State::fromSession($session)); 272 + \Mail::assertNotQueued(UserVerificationMail::class); 158 273 } 159 274 }
+76 -32
tests/Models/BeatmapPackUserCompletionTest.php
··· 7 7 8 8 namespace Tests\Models; 9 9 10 + use App\Libraries\Search\ScoreSearch; 10 11 use App\Models\Beatmap; 11 12 use App\Models\BeatmapPack; 12 - use App\Models\Score\Best as ScoreBest; 13 + use App\Models\BeatmapPackItem; 14 + use App\Models\Beatmapset; 15 + use App\Models\Country; 16 + use App\Models\Genre; 17 + use App\Models\Group; 18 + use App\Models\Language; 19 + use App\Models\Solo\Score; 13 20 use App\Models\User; 21 + use App\Models\UserGroup; 22 + use App\Models\UserGroupEvent; 14 23 use Tests\TestCase; 15 24 25 + /** 26 + * @group RequiresScoreIndexer 27 + */ 16 28 class BeatmapPackUserCompletionTest extends TestCase 17 29 { 30 + private static array $users; 31 + private static BeatmapPack $pack; 32 + 33 + public static function setUpBeforeClass(): void 34 + { 35 + parent::setUpBeforeClass(); 36 + 37 + static::withDbAccess(function () { 38 + $beatmap = Beatmap::factory()->ranked()->state([ 39 + 'playmode' => Beatmap::MODES['taiko'], 40 + ])->create(); 41 + static::$pack = BeatmapPack::factory()->create(); 42 + static::$pack->items()->create(['beatmapset_id' => $beatmap->beatmapset_id]); 43 + 44 + static::$users = [ 45 + 'convertOsu' => User::factory()->create(), 46 + 'default' => User::factory()->create(), 47 + 'null' => null, 48 + 'unrelated' => User::factory()->create(), 49 + ]; 50 + 51 + Score::factory()->create([ 52 + 'beatmap_id' => $beatmap, 53 + 'ruleset_id' => Beatmap::MODES['osu'], 54 + 'preserve' => true, 55 + 'user_id' => static::$users['convertOsu'], 56 + ]); 57 + Score::factory()->create([ 58 + 'beatmap_id' => $beatmap, 59 + 'preserve' => true, 60 + 'user_id' => static::$users['default'], 61 + ]); 62 + 63 + static::reindexScores(); 64 + }); 65 + } 66 + 67 + public static function tearDownAfterClass(): void 68 + { 69 + static::withDbAccess(function () { 70 + Beatmap::truncate(); 71 + BeatmapPack::truncate(); 72 + BeatmapPackItem::truncate(); 73 + Beatmapset::truncate(); 74 + Country::truncate(); 75 + Genre::truncate(); 76 + Language::truncate(); 77 + Score::select()->delete(); // TODO: revert to truncate after the table is actually renamed 78 + User::truncate(); 79 + UserGroup::truncate(); 80 + UserGroupEvent::truncate(); 81 + (new ScoreSearch())->deleteAll(); 82 + }); 83 + 84 + parent::tearDownAfterClass(); 85 + } 86 + 87 + protected $connectionsToTransact = []; 88 + 18 89 /** 19 90 * @dataProvider dataProviderForTestBasic 20 91 */ 21 92 public function testBasic(string $userType, ?string $packRuleset, bool $completed): void 22 93 { 23 - $beatmap = Beatmap::factory()->ranked()->state([ 24 - 'playmode' => Beatmap::MODES['taiko'], 25 - ])->create(); 26 - $pack = BeatmapPack::factory()->create(); 27 - $pack->items()->create(['beatmapset_id' => $beatmap->beatmapset_id]); 28 - 29 - $scoreUser = User::factory()->create(); 30 - $scoreClass = ScoreBest\Taiko::class; 31 - switch ($userType) { 32 - case 'convertOsu': 33 - $checkUser = $scoreUser; 34 - $scoreClass = ScoreBest\Osu::class; 35 - break; 36 - case 'default': 37 - $checkUser = $scoreUser; 38 - break; 39 - case 'null': 40 - $checkUser = null; 41 - break; 42 - case 'unrelated': 43 - $checkUser = User::factory()->create(); 44 - break; 45 - } 46 - 47 - $scoreClass::factory()->create([ 48 - 'beatmap_id' => $beatmap, 49 - 'user_id' => $scoreUser->getKey(), 50 - ]); 94 + $user = static::$users[$userType]; 51 95 52 96 $rulesetId = $packRuleset === null ? null : Beatmap::MODES[$packRuleset]; 53 - $pack->update(['playmode' => $rulesetId]); 54 - $pack->refresh(); 97 + static::$pack->update(['playmode' => $rulesetId]); 98 + static::$pack->refresh(); 55 99 56 - $data = $pack->userCompletionData($checkUser); 100 + $data = static::$pack->userCompletionData($user, null); 57 101 $this->assertSame($completed ? 1 : 0, count($data['beatmapset_ids'])); 58 102 $this->assertSame($completed, $data['completed']); 59 103 }
+1 -1
tests/Models/ContestTest.php
··· 78 78 MultiplayerScoreLink::factory()->state([ 79 79 'playlist_item_id' => $playlistItem, 80 80 'user_id' => $userId, 81 - ])->completed([], [ 81 + ])->completed([ 82 82 'ended_at' => $endedAt, 83 83 'passed' => $passed, 84 84 ])->create();
+29 -33
tests/Models/Multiplayer/ScoreLinkTest.php
··· 12 12 use App\Models\Multiplayer\PlaylistItem; 13 13 use App\Models\Multiplayer\ScoreLink; 14 14 use App\Models\ScoreToken; 15 - use Carbon\Carbon; 16 15 use Tests\TestCase; 17 16 18 17 class ScoreLinkTest extends TestCase 19 18 { 19 + private static array $commonScoreParams; 20 + 21 + public static function setUpBeforeClass(): void 22 + { 23 + parent::setUpBeforeClass(); 24 + static::$commonScoreParams = [ 25 + 'accuracy' => 0.5, 26 + 'ended_at' => new \DateTime(), 27 + 'max_combo' => 1, 28 + 'statistics' => [ 29 + 'great' => 1, 30 + ], 31 + 'total_score' => 1, 32 + ]; 33 + } 34 + 20 35 public function testRequiredModsMissing() 21 36 { 22 37 $playlistItem = PlaylistItem::factory()->create([ ··· 32 47 $this->expectException(InvariantException::class); 33 48 $this->expectExceptionMessage('This play does not include the mods required.'); 34 49 ScoreLink::complete($scoreToken, [ 50 + ...static::$commonScoreParams, 35 51 'beatmap_id' => $playlistItem->beatmap_id, 36 52 'ruleset_id' => $playlistItem->ruleset_id, 37 53 'user_id' => $scoreToken->user_id, 38 - 'ended_at' => json_date(Carbon::now()), 39 - 'mods' => [], 40 - 'statistics' => [ 41 - 'great' => 1, 42 - ], 43 54 ]); 44 55 } 45 56 ··· 57 68 58 69 $this->expectNotToPerformAssertions(); 59 70 ScoreLink::complete($scoreToken, [ 71 + ...static::$commonScoreParams, 60 72 'beatmap_id' => $playlistItem->beatmap_id, 73 + 'mods' => [['acronym' => 'HD']], 61 74 'ruleset_id' => $playlistItem->ruleset_id, 62 75 'user_id' => $scoreToken->user_id, 63 - 'ended_at' => json_date(Carbon::now()), 64 - 'mods' => [['acronym' => 'HD']], 65 - 'statistics' => [ 66 - 'great' => 1, 67 - ], 68 76 ]); 69 77 } 70 78 ··· 85 93 86 94 $this->expectNotToPerformAssertions(); 87 95 ScoreLink::complete($scoreToken, [ 96 + ...static::$commonScoreParams, 88 97 'beatmap_id' => $playlistItem->beatmap_id, 89 - 'ruleset_id' => $playlistItem->ruleset_id, 90 - 'user_id' => $scoreToken->user_id, 91 - 'ended_at' => json_date(Carbon::now()), 92 98 'mods' => [ 93 99 ['acronym' => 'DT'], 94 100 ['acronym' => 'HD'], 95 101 ], 96 - 'statistics' => [ 97 - 'great' => 1, 98 - ], 102 + 'ruleset_id' => $playlistItem->ruleset_id, 103 + 'user_id' => $scoreToken->user_id, 99 104 ]); 100 105 } 101 106 ··· 117 122 $this->expectException(InvariantException::class); 118 123 $this->expectExceptionMessage('This play includes mods that are not allowed.'); 119 124 ScoreLink::complete($scoreToken, [ 125 + ...static::$commonScoreParams, 120 126 'beatmap_id' => $playlistItem->beatmap_id, 121 - 'ruleset_id' => $playlistItem->ruleset_id, 122 - 'user_id' => $scoreToken->user_id, 123 - 'ended_at' => json_date(Carbon::now()), 124 127 'mods' => [ 125 128 ['acronym' => 'DT'], 126 129 ['acronym' => 'HD'], 127 130 ], 128 - 'statistics' => [ 129 - 'great' => 1, 130 - ], 131 + 'ruleset_id' => $playlistItem->ruleset_id, 132 + 'user_id' => $scoreToken->user_id, 131 133 ]); 132 134 } 133 135 ··· 142 144 $this->expectException(InvariantException::class); 143 145 $this->expectExceptionMessage('This play includes mods that are not allowed.'); 144 146 ScoreLink::complete($scoreToken, [ 147 + ...static::$commonScoreParams, 145 148 'beatmap_id' => $playlistItem->beatmap_id, 149 + 'mods' => [['acronym' => 'HD']], 146 150 'ruleset_id' => $playlistItem->ruleset_id, 147 151 'user_id' => $scoreToken->user_id, 148 - 'ended_at' => json_date(Carbon::now()), 149 - 'mods' => [['acronym' => 'HD']], 150 - 'statistics' => [ 151 - 'great' => 1, 152 - ], 153 152 ]); 154 153 } 155 154 ··· 170 169 171 170 $this->expectNotToPerformAssertions(); 172 171 ScoreLink::complete($scoreToken, [ 172 + ...static::$commonScoreParams, 173 173 'beatmap_id' => $playlistItem->beatmap_id, 174 + 'mods' => [['acronym' => 'TD']], 174 175 'ruleset_id' => $playlistItem->ruleset_id, 175 176 'user_id' => $scoreToken->user_id, 176 - 'ended_at' => json_date(Carbon::now()), 177 - 'mods' => [['acronym' => 'TD']], 178 - 'statistics' => [ 179 - 'great' => 1, 180 - ], 181 177 ]); 182 178 } 183 179 }
+2 -1
tests/Models/Multiplayer/UserScoreAggregateTest.php
··· 240 240 [ 241 241 'beatmap_id' => $playlistItem->beatmap_id, 242 242 'ended_at' => json_time(new \DateTime()), 243 + 'max_combo' => 1, 244 + 'statistics' => ['good' => 1], 243 245 'ruleset_id' => $playlistItem->ruleset_id, 244 - 'statistics' => ['good' => 1], 245 246 'user_id' => $user->getKey(), 246 247 ...$params, 247 248 ],
+12 -12
tests/Models/Solo/ScoreEsIndexTest.php
··· 40 40 static::$beatmap = Beatmap::factory()->qualified()->create(); 41 41 42 42 $scoreFactory = Score::factory()->state(['preserve' => true]); 43 - $defaultData = ['build_id' => 1]; 44 43 45 44 $mods = [ 46 45 ['acronym' => 'DT', 'settings' => []], ··· 51 50 ]; 52 51 53 52 static::$scores = [ 54 - 'otherUser' => $scoreFactory->withData($defaultData, [ 55 - 'total_score' => 1150, 53 + 'otherUser' => $scoreFactory->withData([ 56 54 'mods' => $unrelatedMods, 57 55 ])->create([ 58 56 'beatmap_id' => static::$beatmap, 57 + 'total_score' => 1150, 59 58 'user_id' => $otherUser, 60 59 ]), 61 - 'otherUserMods' => $scoreFactory->withData($defaultData, [ 62 - 'total_score' => 1140, 60 + 'otherUserMods' => $scoreFactory->withData([ 63 61 'mods' => $mods, 64 62 ])->create([ 65 63 'beatmap_id' => static::$beatmap, 64 + 'total_score' => 1140, 66 65 'user_id' => $otherUser, 67 66 ]), 68 - 'otherUser2' => $scoreFactory->withData($defaultData, [ 69 - 'total_score' => 1150, 67 + 'otherUser2' => $scoreFactory->withData([ 70 68 'mods' => $mods, 71 69 ])->create([ 72 70 'beatmap_id' => static::$beatmap, 71 + 'total_score' => 1150, 73 72 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), 74 73 ]), 75 - 'otherUser3SameCountry' => $scoreFactory->withData($defaultData, [ 76 - 'total_score' => 1130, 74 + 'otherUser3SameCountry' => $scoreFactory->withData([ 77 75 'mods' => $unrelatedMods, 78 76 ])->create([ 79 77 'beatmap_id' => static::$beatmap, 78 + 'total_score' => 1130, 80 79 'user_id' => User::factory()->state(['country_acronym' => static::$user->country_acronym]), 81 80 ]), 82 - 'user' => $scoreFactory->withData($defaultData, ['total_score' => 1100])->create([ 81 + 'user' => $scoreFactory->create([ 83 82 'beatmap_id' => static::$beatmap, 83 + 'total_score' => 1100, 84 84 'user_id' => static::$user, 85 85 ]), 86 - 'userMods' => $scoreFactory->withData($defaultData, [ 87 - 'total_score' => 1050, 86 + 'userMods' => $scoreFactory->withData([ 88 87 'mods' => $mods, 89 88 ])->create([ 90 89 'beatmap_id' => static::$beatmap, 90 + 'total_score' => 1050, 91 91 'user_id' => static::$user, 92 92 ]), 93 93 ];
+16 -16
tests/Models/Solo/ScoreTest.php
··· 50 50 'user_id' => 1, 51 51 ]); 52 52 53 - $this->assertTrue($score->data->passed); 54 - $this->assertSame($score->data->rank, 'S'); 53 + $this->assertTrue($score->passed); 54 + $this->assertSame($score->rank, 'S'); 55 55 56 - $legacy = $score->createLegacyEntryOrExplode(); 56 + $legacy = $score->makeLegacyEntry(); 57 57 58 58 $this->assertTrue($legacy->perfect); 59 59 $this->assertSame($legacy->rank, 'S'); ··· 75 75 'user_id' => 1, 76 76 ]); 77 77 78 - $this->assertFalse($score->data->passed); 79 - $this->assertSame($score->data->rank, 'F'); 78 + $this->assertFalse($score->passed); 79 + $this->assertSame($score->rank, 'F'); 80 80 81 - $legacy = $score->createLegacyEntryOrExplode(); 81 + $legacy = $score->makeLegacyEntry(); 82 82 83 83 $this->assertFalse($legacy->perfect); 84 84 $this->assertSame($legacy->rank, 'F'); ··· 98 98 'statistics' => ['great' => 10, 'ok' => 20, 'meh' => 30, 'miss' => 40], 99 99 'total_score' => 1000, 100 100 'user_id' => 1, 101 - ])->createLegacyEntryOrExplode(); 101 + ])->makeLegacyEntry(); 102 102 103 103 $this->assertFalse($legacy->perfect); 104 104 $this->assertSame($legacy->count300, 10); ··· 121 121 'statistics' => ['Great' => 10, 'Ok' => 20, 'Meh' => 30, 'Miss' => 40], 122 122 'total_score' => 1000, 123 123 'user_id' => 1, 124 - ])->createLegacyEntryOrExplode(); 124 + ])->makeLegacyEntry(); 125 125 126 126 $this->assertFalse($legacy->perfect); 127 127 $this->assertSame($legacy->count300, 10); ··· 132 132 133 133 public function testModsPropertyType() 134 134 { 135 - $score = new Score(['data' => [ 135 + $score = new Score([ 136 136 'beatmap_id' => 0, 137 + 'data' => [ 138 + 'mods' => [['acronym' => 'DT']], 139 + ], 137 140 'ended_at' => json_time(now()), 138 - 'mods' => [['acronym' => 'DT']], 139 141 'ruleset_id' => 0, 140 142 'user_id' => 0, 141 - ]]); 143 + ]); 142 144 143 145 $this->assertTrue($score->data->mods[0] instanceof stdClass, 'mods entry should be of type stdClass'); 144 146 } ··· 147 149 { 148 150 $pp = 10; 149 151 $weight = 0.5; 150 - $score = Score::factory()->create(); 151 - $score->performance()->create(['pp' => $pp]); 152 + $score = Score::factory()->create(['pp' => $pp]); 152 153 $score->weight = $weight; 153 154 154 155 $this->assertSame($score->weightedPp(), $pp * $weight); ··· 156 157 157 158 public function testWeightedPpWithoutPerformance(): void 158 159 { 159 - $score = Score::factory()->create(); 160 + $score = Score::factory()->create(['pp' => null]); 160 161 $score->weight = 0.5; 161 162 162 163 $this->assertNull($score->weightedPp()); ··· 164 165 165 166 public function testWeightedPpWithoutWeight(): void 166 167 { 167 - $score = Score::factory()->create(); 168 - $score->performance()->create(['pp' => 10]); 168 + $score = Score::factory()->create(['pp' => 10]); 169 169 170 170 $this->assertNull($score->weightedPp()); 171 171 }
+16 -27
tests/TestCase.php
··· 8 8 use App\Events\NewPrivateNotificationEvent; 9 9 use App\Http\Middleware\AuthApi; 10 10 use App\Jobs\Notifications\BroadcastNotificationBase; 11 + use App\Libraries\OAuth\EncodeToken; 11 12 use App\Libraries\Search\ScoreSearch; 12 13 use App\Libraries\Session\Store as SessionStore; 13 14 use App\Models\Beatmapset; 15 + use App\Models\Build; 14 16 use App\Models\OAuth\Client; 15 17 use App\Models\User; 16 18 use Artisan; 17 19 use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; 18 - use Firebase\JWT\JWT; 19 20 use Illuminate\Database\DatabaseManager; 20 21 use Illuminate\Database\Eloquent\Model; 21 22 use Illuminate\Foundation\Testing\DatabaseTransactions; ··· 38 39 $callback(); 39 40 40 41 static::resetAppDb($db); 42 + } 43 + 44 + protected static function createClientToken(Build $build, ?int $clientTime = null): string 45 + { 46 + $data = strtoupper(bin2hex($build->hash).bin2hex(pack('V', $clientTime ?? time()))); 47 + $expected = hash_hmac('sha1', $data, ''); 48 + 49 + return strtoupper(bin2hex(random_bytes(40)).$data.$expected.'00'); 41 50 } 42 51 43 52 protected static function fileList($path, $suffix) ··· 175 184 return $this; 176 185 } 177 186 178 - // FIXME: figure out how to generate the encrypted token without doing it 179 - // manually here. Or alternatively some other way to authenticate 180 - // with token. 181 187 protected function actingWithToken($token) 182 188 { 183 - static $privateKey; 189 + $this->actAsUserWithToken($token); 184 190 185 - if ($privateKey === null) { 186 - $privateKey = $GLOBALS['cfg']['passport']['private_key'] ?? file_get_contents(Passport::keyPath('oauth-private.key')); 187 - } 188 - 189 - $encryptedToken = JWT::encode([ 190 - 'aud' => $token->client_id, 191 - 'exp' => $token->expires_at->timestamp, 192 - 'iat' => $token->created_at->timestamp, // issued at 193 - 'jti' => $token->getKey(), 194 - 'nbf' => $token->created_at->timestamp, // valid after 195 - 'sub' => $token->user_id, 196 - 'scopes' => $token->scopes, 197 - ], $privateKey, 'RS256'); 198 - 199 - $this->actAsUserWithToken($token); 191 + $encodedToken = EncodeToken::encodeAccessToken($token); 200 192 201 193 return $this->withHeaders([ 202 - 'Authorization' => "Bearer {$encryptedToken}", 194 + 'Authorization' => "Bearer {$encodedToken}", 203 195 ]); 204 196 } 205 197 ··· 245 237 */ 246 238 protected function createToken(?User $user, ?array $scopes = null, ?Client $client = null) 247 239 { 248 - $client ??= Client::factory()->create(); 249 - 250 - $token = $client->tokens()->create([ 240 + return ($client ?? Client::factory()->create())->tokens()->create([ 251 241 'expires_at' => now()->addDays(1), 252 242 'id' => uniqid(), 253 243 'revoked' => false, 254 244 'scopes' => $scopes, 255 - 'user_id' => optional($user)->getKey(), 245 + 'user_id' => $user?->getKey(), 246 + 'verified' => true, 256 247 ]); 257 - 258 - return $token; 259 248 } 260 249 261 250 protected function expectCountChange(callable $callback, int $change, string $message = '')
+36
tests/api_routes.json
··· 1 1 [ 2 2 { 3 + "uri": "api/v2/session/verify", 4 + "methods": [ 5 + "POST" 6 + ], 7 + "controller": "App\\Http\\Controllers\\AccountController@verify", 8 + "middlewares": [ 9 + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", 10 + "App\\Http\\Middleware\\RequireScopes", 11 + "App\\Http\\Middleware\\RequireScopes:any", 12 + "Illuminate\\Auth\\Middleware\\Authenticate", 13 + "App\\Http\\Middleware\\VerifyUser", 14 + "App\\Http\\Middleware\\ThrottleRequests:60,10" 15 + ], 16 + "scopes": [ 17 + "any" 18 + ] 19 + }, 20 + { 21 + "uri": "api/v2/session/verify/reissue", 22 + "methods": [ 23 + "POST" 24 + ], 25 + "controller": "App\\Http\\Controllers\\AccountController@reissueCode", 26 + "middlewares": [ 27 + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", 28 + "App\\Http\\Middleware\\RequireScopes", 29 + "App\\Http\\Middleware\\RequireScopes:any", 30 + "Illuminate\\Auth\\Middleware\\Authenticate", 31 + "App\\Http\\Middleware\\VerifyUser", 32 + "App\\Http\\Middleware\\ThrottleRequests:60,10" 33 + ], 34 + "scopes": [ 35 + "any" 36 + ] 37 + }, 38 + { 3 39 "uri": "api/v2/beatmaps/lookup", 4 40 "methods": [ 5 41 "GET",