···11+<?php
22+33+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44+// See the LICENCE file in the repository root for full licence text.
55+66+declare(strict_types=1);
77+88+namespace App\Exceptions;
99+1010+class ClientCheckParseTokenException extends \Exception
1111+{
1212+}
···2222 /**
2323 * A tag to use when logging timing of fetches.
2424 * FIXME: context-based tagging would be nicer.
2525- *
2626- * @var string|null
2725 */
2828- public $loggingTag;
2626+ public ?string $loggingTag;
29273028 protected $aggregations;
3129 protected $index;
+45
app/Libraries/OAuth/EncodeToken.php
···11+<?php
22+33+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44+// See the LICENCE file in the repository root for full licence text.
55+66+declare(strict_types=1);
77+88+namespace App\Libraries\OAuth;
99+1010+use App\Models\OAuth\Token;
1111+use Defuse\Crypto\Crypto;
1212+use Firebase\JWT\JWT;
1313+use Laravel\Passport\Passport;
1414+use Laravel\Passport\RefreshToken;
1515+1616+class EncodeToken
1717+{
1818+ public static function encodeAccessToken(Token $token): string
1919+ {
2020+ $privateKey = $GLOBALS['cfg']['passport']['private_key']
2121+ ?? file_get_contents(Passport::keyPath('oauth-private.key'));
2222+2323+ return JWT::encode([
2424+ 'aud' => $token->client_id,
2525+ 'exp' => $token->expires_at->timestamp,
2626+ 'iat' => $token->created_at->timestamp, // issued at
2727+ 'jti' => $token->getKey(),
2828+ 'nbf' => $token->created_at->timestamp, // valid after
2929+ 'sub' => $token->user_id,
3030+ 'scopes' => $token->scopes,
3131+ ], $privateKey, 'RS256');
3232+ }
3333+3434+ public static function encodeRefreshToken(RefreshToken $refreshToken, Token $accessToken): string
3535+ {
3636+ return Crypto::encryptWithPassword(json_encode([
3737+ 'client_id' => (string) $accessToken->client_id,
3838+ 'refresh_token_id' => $refreshToken->getKey(),
3939+ 'access_token_id' => $accessToken->getKey(),
4040+ 'scopes' => $accessToken->scopes,
4141+ 'user_id' => $accessToken->user_id,
4242+ 'expire_time' => $refreshToken->expires_at->timestamp,
4343+ ]), \Crypt::getKey());
4444+ }
4545+}
+40
app/Libraries/OAuth/RefreshTokenGrant.php
···11+<?php
22+33+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44+// See the LICENCE file in the repository root for full licence text.
55+66+declare(strict_types=1);
77+88+namespace App\Libraries\OAuth;
99+1010+use App\Models\OAuth\Token;
1111+use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant;
1212+use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
1313+use Psr\Http\Message\ServerRequestInterface;
1414+1515+class RefreshTokenGrant extends BaseRefreshTokenGrant
1616+{
1717+ private ?array $oldRefreshToken = null;
1818+1919+ public function respondToAccessTokenRequest(
2020+ ServerRequestInterface $request,
2121+ ResponseTypeInterface $responseType,
2222+ \DateInterval $accessTokenTTL
2323+ ) {
2424+ $refreshTokenData = parent::respondToAccessTokenRequest($request, $responseType, $accessTokenTTL);
2525+2626+ // Copy previous verification state
2727+ $accessToken = (new \ReflectionProperty($refreshTokenData, 'accessToken'))->getValue($refreshTokenData);
2828+ Token::where('id', $accessToken->getIdentifier())->update([
2929+ 'verified' => Token::select('verified')->find($this->oldRefreshToken['access_token_id'])->verified,
3030+ ]);
3131+ $this->oldRefreshToken = null;
3232+3333+ return $refreshTokenData;
3434+ }
3535+3636+ protected function validateOldRefreshToken(ServerRequestInterface $request, $clientId)
3737+ {
3838+ return $this->oldRefreshToken = parent::validateOldRefreshToken($request, $clientId);
3939+ }
4040+}
···7788namespace App\Models\Solo;
991010+use App\Enums\ScoreRank;
1111+use App\Exceptions\InvariantException;
1012use App\Libraries\Score\UserRank;
1113use App\Libraries\Search\ScoreSearchParams;
1214use App\Models\Beatmap;
1515+use App\Models\Beatmapset;
1316use App\Models\Model;
1417use App\Models\Multiplayer\ScoreLink as MultiplayerScoreLink;
1518use App\Models\Score as LegacyScore;
1619use App\Models\ScoreToken;
1720use App\Models\Traits;
1821use App\Models\User;
1919-use Carbon\Carbon;
2022use Illuminate\Database\Eloquent\Builder;
2123use LaravelRedis;
2224use Storage;
23252426/**
2727+ * @property float $accuracy
2528 * @property int $beatmap_id
2626- * @property \Carbon\Carbon|null $created_at
2727- * @property string|null $created_at_json
2929+ * @property int $build_id
2830 * @property ScoreData $data
3131+ * @property \Carbon\Carbon|null $ended_at
3232+ * @property string|null $ended_at_json
2933 * @property bool $has_replay
3034 * @property int $id
3535+ * @property int $legacy_score_id
3636+ * @property int $legacy_total_score
3737+ * @property int $max_combo
3838+ * @property bool $passed
3939+ * @property float $pp
3140 * @property bool $preserve
4141+ * @property string $rank
3242 * @property bool $ranked
3343 * @property int $ruleset_id
4444+ * @property \Carbon\Carbon|null $started_at
4545+ * @property string|null $started_at_json
4646+ * @property int $total_score
3447 * @property int $unix_updated_at
3548 * @property User $user
3649 * @property int $user_id
···43564457 protected $casts = [
4558 'data' => ScoreData::class,
5959+ 'ended_at' => 'datetime',
4660 'has_replay' => 'boolean',
6161+ 'passed' => 'boolean',
4762 'preserve' => 'boolean',
6363+ 'ranked' => 'boolean',
6464+ 'started_at' => 'datetime',
4865 ];
49665050- public static function createFromJsonOrExplode(array $params)
6767+ public static function createFromJsonOrExplode(array $params): static
5168 {
5252- $score = new static([
5353- 'beatmap_id' => $params['beatmap_id'],
5454- 'ruleset_id' => $params['ruleset_id'],
5555- 'user_id' => $params['user_id'],
5656- 'data' => $params,
5757- ]);
6969+ $params['data'] = [
7070+ 'maximum_statistics' => $params['maximum_statistics'] ?? [],
7171+ 'mods' => $params['mods'] ?? [],
7272+ 'statistics' => $params['statistics'] ?? [],
7373+ ];
7474+ unset(
7575+ $params['maximum_statistics'],
7676+ $params['mods'],
7777+ $params['statistics'],
7878+ );
7979+8080+ $score = new static($params);
58815959- $score->data->assertCompleted();
8282+ $score->assertCompleted();
60836184 // this should potentially just be validation rather than applying this logic here, but
6285 // older lazer builds potentially submit incorrect details here (and we still want to
6386 // accept their scores.
6464- if (!$score->data->passed) {
6565- $score->data->rank = 'F';
8787+ if (!$score->passed) {
8888+ $score->rank = 'F';
6689 }
67906891 $score->saveOrExplode();
···7093 return $score;
7194 }
72957373- public static function extractParams(array $params, ScoreToken|MultiplayerScoreLink $scoreToken): array
9696+ public static function extractParams(array $rawParams, ScoreToken|MultiplayerScoreLink $scoreToken): array
7497 {
7575- return [
7676- ...get_params($params, null, [
7777- 'accuracy:float',
7878- 'max_combo:int',
7979- 'maximum_statistics:array',
8080- 'passed:bool',
8181- 'rank:string',
8282- 'statistics:array',
8383- 'total_score:int',
8484- ]),
8585- 'beatmap_id' => $scoreToken->beatmap_id,
8686- 'build_id' => $scoreToken->build_id,
8787- 'ended_at' => json_time(Carbon::now()),
8888- 'mods' => app('mods')->parseInputArray($scoreToken->ruleset_id, get_arr($params['mods'] ?? null) ?? []),
8989- 'ruleset_id' => $scoreToken->ruleset_id,
9090- 'started_at' => $scoreToken->created_at_json,
9191- 'user_id' => $scoreToken->user_id,
9292- ];
9393- }
9898+ $params = get_params($rawParams, null, [
9999+ 'accuracy:float',
100100+ 'max_combo:int',
101101+ 'maximum_statistics:array',
102102+ 'mods:array',
103103+ 'passed:bool',
104104+ 'rank:string',
105105+ 'statistics:array',
106106+ 'total_score:int',
107107+ ]);
941089595- /**
9696- * Queue the item for score processing
9797- *
9898- * @param array $scoreJson JSON of the score generated using ScoreTransformer of type Solo
9999- */
100100- public static function queueForProcessing(array $scoreJson): void
101101- {
102102- LaravelRedis::lpush($GLOBALS['cfg']['osu']['scores']['processing_queue'], json_encode([
103103- 'Score' => [
104104- 'beatmap_id' => $scoreJson['beatmap_id'],
105105- 'id' => $scoreJson['id'],
106106- 'ruleset_id' => $scoreJson['ruleset_id'],
107107- 'user_id' => $scoreJson['user_id'],
108108- // TODO: processor is currently order dependent and requires
109109- // this to be located at the end
110110- 'data' => json_encode($scoreJson),
111111- ],
112112- ]));
109109+ $params['maximum_statistics'] ??= [];
110110+ $params['statistics'] ??= [];
111111+112112+ $params['mods'] = app('mods')->parseInputArray($scoreToken->ruleset_id, $params['mods'] ?? []);
113113+114114+ $params['beatmap_id'] = $scoreToken->beatmap_id;
115115+ $params['build_id'] = $scoreToken->build_id;
116116+ $params['ended_at'] = new \DateTime();
117117+ $params['ruleset_id'] = $scoreToken->ruleset_id;
118118+ $params['started_at'] = $scoreToken->created_at;
119119+ $params['user_id'] = $scoreToken->user_id;
120120+121121+ $beatmap = $scoreToken->beatmap;
122122+ $params['ranked'] = $beatmap !== null && in_array($beatmap->approved, [
123123+ Beatmapset::STATES['approved'],
124124+ Beatmapset::STATES['ranked'],
125125+ ], true);
126126+127127+ return $params;
113128 }
114129115130 public function beatmap()
116131 {
117132 return $this->belongsTo(Beatmap::class, 'beatmap_id');
118118- }
119119-120120- public function performance()
121121- {
122122- return $this->hasOne(ScorePerformance::class, 'score_id');
123133 }
124134125135 public function user()
···132142 return $query->whereHas('beatmap.beatmapset');
133143 }
134144145145+ public function scopeForRuleset(Builder $query, string $ruleset): Builder
146146+ {
147147+ return $query->where('ruleset_id', Beatmap::MODES[$ruleset]);
148148+ }
149149+150150+ public function scopeIncludeFails(Builder $query, bool $includeFails): Builder
151151+ {
152152+ return $includeFails
153153+ ? $query
154154+ : $query->where('passed', true);
155155+ }
156156+135157 /**
136158 * This should match the one used in osu-elastic-indexer.
137159 */
···142164 ->whereHas('user', fn (Builder $q): Builder => $q->default());
143165 }
144166167167+ public function scopeRecent(Builder $query, string $ruleset, bool $includeFails): Builder
168168+ {
169169+ return $query
170170+ ->default()
171171+ ->forRuleset($ruleset)
172172+ ->includeFails($includeFails)
173173+ // 2 days (2 * 24 * 3600)
174174+ ->where('unix_updated_at', '>', time() - 172_800);
175175+ }
176176+145177 public function getAttribute($key)
146178 {
147179 return match ($key) {
180180+ 'accuracy',
148181 'beatmap_id',
182182+ 'build_id',
149183 'id',
184184+ 'legacy_score_id',
185185+ 'legacy_total_score',
186186+ 'max_combo',
187187+ 'pp',
150188 'ruleset_id',
189189+ 'total_score',
151190 'unix_updated_at',
152191 'user_id' => $this->getRawAttribute($key),
153192193193+ 'rank' => $this->getRawAttribute($key) ?? 'F',
194194+154195 'data' => $this->getClassCastableAttributeValue($key, $this->getRawAttribute($key)),
155196156197 'has_replay',
157157- 'preserve',
158158- 'ranked' => (bool) $this->getRawAttribute($key),
198198+ 'passed',
199199+ 'preserve' => (bool) $this->getRawAttribute($key),
159200160160- 'created_at' => $this->getTimeFast($key),
161161- 'created_at_json' => $this->getJsonTimeFast($key),
201201+ 'ranked' => (bool) ($this->getRawAttribute($key) ?? true),
162202163163- 'pp' => $this->performance?->pp,
203203+ 'ended_at',
204204+ 'started_at' => $this->getTimeFast($key),
205205+206206+ 'ended_at_json',
207207+ 'started_at_json' => $this->getJsonTimeFast($key),
208208+209209+ 'best_id' => null,
210210+ 'legacy_perfect' => null,
164211165212 'beatmap',
166213 'performance',
···169216 };
170217 }
171218172172- public function createLegacyEntryOrExplode()
219219+ public function assertCompleted(): void
173220 {
174174- $score = $this->makeLegacyEntry();
221221+ if (ScoreRank::tryFrom($this->rank ?? '') === null) {
222222+ throw new InvariantException("'{$this->rank}' is not a valid rank.");
223223+ }
175224176176- $score->saveOrExplode();
225225+ foreach (['total_score', 'accuracy', 'max_combo', 'passed'] as $field) {
226226+ if (!present($this->$field)) {
227227+ throw new InvariantException("field missing: '{$field}'");
228228+ }
229229+ }
177230178178- return $score;
231231+ if ($this->data->statistics->isEmpty()) {
232232+ throw new InvariantException("field cannot be empty: 'statistics'");
233233+ }
179234 }
180235181236 public function getMode(): string
···191246192247 public function isLegacy(): bool
193248 {
194194- return $this->data->buildId === null;
249249+ return $this->legacy_score_id !== null;
195250 }
196251197252 public function legacyScore(): ?LegacyScore\Best\Model
198253 {
199199- $id = $this->data->legacyScoreId;
254254+ $id = $this->legacy_score_id;
200255201256 return $id === null
202257 ? null
···214269 'beatmapset_id' => $this->beatmap?->beatmapset_id ?? 0,
215270 'countmiss' => $statistics->miss,
216271 'enabled_mods' => app('mods')->idsToBitset(array_column($data->mods, 'acronym')),
217217- 'maxcombo' => $data->maxCombo,
218218- 'pass' => $data->passed,
219219- 'perfect' => $data->passed && $statistics->miss + $statistics->large_tick_miss === 0,
220220- 'rank' => $data->rank,
221221- 'score' => $data->totalScore,
272272+ 'maxcombo' => $this->max_combo,
273273+ 'pass' => $this->passed,
274274+ 'perfect' => $this->passed && $statistics->miss + $statistics->large_tick_miss === 0,
275275+ 'rank' => $this->rank,
276276+ 'score' => $this->total_score,
222277 'scorechecksum' => "\0",
223278 'user_id' => $this->user_id,
224279 ]);
···251306 return $score;
252307 }
253308309309+ public function queueForProcessing(): void
310310+ {
311311+ LaravelRedis::lpush($GLOBALS['cfg']['osu']['scores']['processing_queue'], json_encode([
312312+ 'Score' => $this->getAttributes(),
313313+ ]));
314314+ }
315315+254316 public function trashed(): bool
255317 {
256318 return false;
···263325264326 public function userRank(?array $params = null): int
265327 {
266266- return UserRank::getRank(ScoreSearchParams::fromArray(array_merge($params ?? [], [
328328+ // Non-legacy score always has its rank checked against all score types.
329329+ if (!$this->isLegacy()) {
330330+ $params['is_legacy'] = null;
331331+ }
332332+333333+ return UserRank::getRank(ScoreSearchParams::fromArray([
334334+ ...($params ?? []),
267335 'beatmap_ids' => [$this->beatmap_id],
268336 'before_score' => $this,
269269- 'is_legacy' => $this->isLegacy(),
270337 'ruleset_id' => $this->ruleset_id,
271338 'user' => $this->user,
272272- ])));
339339+ ]));
273340 }
274341275342 protected function newReportableExtraParams(): array
+3-81
app/Models/Solo/ScoreData.php
···7788namespace App\Models\Solo;
991010-use App\Enums\ScoreRank;
1111-use App\Exceptions\InvariantException;
1210use Illuminate\Contracts\Database\Eloquent\Castable;
1311use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
1412use JsonSerializable;
15131614class ScoreData implements Castable, JsonSerializable
1715{
1818- public float $accuracy;
1919- public int $beatmapId;
2020- public ?int $buildId;
2121- public string $endedAt;
2222- public ?int $legacyScoreId;
2323- public ?int $legacyTotalScore;
2424- public int $maxCombo;
2516 public ScoreDataStatistics $maximumStatistics;
2617 public array $mods;
2727- public bool $passed;
2828- public string $rank;
2929- public int $rulesetId;
3030- public ?string $startedAt;
3118 public ScoreDataStatistics $statistics;
3232- public int $totalScore;
3333- public int $userId;
34193520 public function __construct(array $data)
3621 {
···5136 }
5237 }
53385454- $this->accuracy = $data['accuracy'] ?? 0;
5555- $this->beatmapId = $data['beatmap_id'];
5656- $this->buildId = $data['build_id'] ?? null;
5757- $this->endedAt = $data['ended_at'];
5858- $this->legacyScoreId = $data['legacy_score_id'] ?? null;
5959- $this->legacyTotalScore = $data['legacy_total_score'] ?? null;
6060- $this->maxCombo = $data['max_combo'] ?? 0;
6139 $this->maximumStatistics = new ScoreDataStatistics($data['maximum_statistics'] ?? []);
6240 $this->mods = $mods;
6363- $this->passed = $data['passed'] ?? false;
6464- $this->rank = $data['rank'] ?? 'F';
6565- $this->rulesetId = $data['ruleset_id'];
6666- $this->startedAt = $data['started_at'] ?? null;
6741 $this->statistics = new ScoreDataStatistics($data['statistics'] ?? []);
6868- $this->totalScore = $data['total_score'] ?? 0;
6969- $this->userId = $data['user_id'];
7042 }
71437244 public static function castUsing(array $arguments)
···7547 {
7648 public function get($model, $key, $value, $attributes)
7749 {
7878- $dataJson = json_decode($value, true);
7979- $dataJson['beatmap_id'] ??= $attributes['beatmap_id'];
8080- $dataJson['ended_at'] ??= $model->created_at_json;
8181- $dataJson['ruleset_id'] ??= $attributes['ruleset_id'];
8282- $dataJson['user_id'] ??= $attributes['user_id'];
8383-8484- return new ScoreData($dataJson);
5050+ return new ScoreData(json_decode($value, true));
8551 }
86528753 public function set($model, $key, $value, $attributes)
8854 {
8955 if (!($value instanceof ScoreData)) {
9090- $value = new ScoreData([
9191- 'beatmap_id' => $attributes['beatmap_id'] ?? null,
9292- 'ended_at' => $attributes['created_at'] ?? null,
9393- 'ruleset_id' => $attributes['ruleset_id'] ?? null,
9494- 'user_id' => $attributes['user_id'] ?? null,
9595- ...$value,
9696- ]);
5656+ $value = new ScoreData($value);
9757 }
98589959 return ['data' => json_encode($value)];
···10161 };
10262 }
10363104104- public function assertCompleted(): void
105105- {
106106- if (ScoreRank::tryFrom($this->rank) === null) {
107107- throw new InvariantException("'{$this->rank}' is not a valid rank.");
108108- }
109109-110110- foreach (['totalScore', 'accuracy', 'maxCombo', 'passed'] as $field) {
111111- if (!present($this->$field)) {
112112- throw new InvariantException("field missing: '{$field}'");
113113- }
114114- }
115115-116116- if ($this->statistics->isEmpty()) {
117117- throw new InvariantException("field cannot be empty: 'statistics'");
118118- }
119119- }
120120-12164 public function jsonSerialize(): array
12265 {
123123- $ret = [
124124- 'accuracy' => $this->accuracy,
125125- 'beatmap_id' => $this->beatmapId,
126126- 'build_id' => $this->buildId,
127127- 'ended_at' => $this->endedAt,
128128- 'legacy_score_id' => $this->legacyScoreId,
129129- 'legacy_total_score' => $this->legacyTotalScore,
130130- 'max_combo' => $this->maxCombo,
6666+ return [
13167 'maximum_statistics' => $this->maximumStatistics,
13268 'mods' => $this->mods,
133133- 'passed' => $this->passed,
134134- 'rank' => $this->rank,
135135- 'ruleset_id' => $this->rulesetId,
136136- 'started_at' => $this->startedAt,
13769 'statistics' => $this->statistics,
138138- 'total_score' => $this->totalScore,
139139- 'user_id' => $this->userId,
14070 ];
141141-142142- foreach ($ret as $field => $value) {
143143- if ($value === null) {
144144- unset($ret[$field]);
145145- }
146146- }
147147-148148- return $ret;
14971 }
15072}
-21
app/Models/Solo/ScorePerformance.php
···11-<?php
22-33-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44-// See the LICENCE file in the repository root for full licence text.
55-66-namespace App\Models\Solo;
77-88-use App\Models\Model;
99-1010-/**
1111- * @property int $score_id
1212- * @property float|null $pp
1313- */
1414-class ScorePerformance extends Model
1515-{
1616- public $incrementing = false;
1717- public $timestamps = false;
1818-1919- protected $primaryKey = 'score_id';
2020- protected $table = 'score_performance';
2121-}
···11+<?php
22+33+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
44+// See the LICENCE file in the repository root for full licence text.
55+66+declare(strict_types=1);
77+88+use Illuminate\Database\Migrations\Migration;
99+use Illuminate\Database\Schema\Blueprint;
1010+use Illuminate\Support\Facades\Schema;
1111+1212+return new class extends Migration
1313+{
1414+ public function up(): void
1515+ {
1616+ Schema::table('oauth_access_tokens', function (Blueprint $table) {
1717+ $table->boolean('verified')->default(true);
1818+ });
1919+ }
2020+2121+ public function down(): void
2222+ {
2323+ Schema::table('oauth_access_tokens', function (Blueprint $table) {
2424+ $table->dropColumn('verified');
2525+ });
2626+ }
2727+};
···11-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22-// See the LICENCE file in the repository root for full licence text.
33-44-import BeatmapExtendedJson from 'interfaces/beatmap-extended-json';
55-import * as React from 'react';
66-77-const defaultValue: Partial<Record<number, BeatmapExtendedJson>> = {};
88-99-export const BeatmapsContext = React.createContext(defaultValue);
···11-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22-// See the LICENCE file in the repository root for full licence text.
33-44-import BeatmapetExtendedJson from 'interfaces/beatmapset-extended-json';
55-import * as React from 'react';
66-77-const defaultValue: Partial<Record<number, BeatmapetExtendedJson>> = {};
88-99-export const BeatmapsetsContext = React.createContext(defaultValue);
···11// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22// See the LICENCE file in the repository root for full licence text.
3344-// In display order on discussion page tabs
55-export const discussionPages = ['reviews', 'generalAll', 'general', 'timeline', 'events'] as const;
66-export type DiscussionPage = (typeof discussionPages)[number];
44+import DiscussionPage from './discussion-page';
7586type DiscussionMode = Exclude<DiscussionPage, 'events'>;
77+export const discussionModes: Readonly<DiscussionMode[]> = ['reviews', 'generalAll', 'general', 'timeline'] as const;
98109export default DiscussionMode;
···11+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22+// See the LICENCE file in the repository root for full licence text.
33+44+// In display order on discussion page tabs
55+export const discussionPages = ['reviews', 'generalAll', 'general', 'timeline', 'events'] as const;
66+type DiscussionPage = (typeof discussionPages)[number];
77+88+const discussionPageSet = new Set<unknown>(discussionPages);
99+1010+export function isDiscussionPage(value: unknown): value is DiscussionPage {
1111+ return discussionPageSet.has(value);
1212+}
1313+1414+export default DiscussionPage;
···11-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22-// See the LICENCE file in the repository root for full licence text.
33-44-import { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json';
55-import { createContext } from 'react';
66-77-// TODO: needs discussions need flattening / normalization
88-export const DiscussionsContext = createContext({} as Partial<Record<number, BeatmapsetDiscussionJsonForBundle | BeatmapsetDiscussionJsonForShow>>);
···11-// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22-// See the LICENCE file in the repository root for full licence text.
33-44-import { createContext } from 'react';
55-import DiscussionsState from './discussions-state';
66-77-// TODO: combine with DiscussionsContext, BeatmapsetContext, etc into a store with properties.
88-const DiscussionsStateContext = createContext(new DiscussionsState());
99-1010-export default DiscussionsStateContext;
···11// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22// See the LICENCE file in the repository root for full licence text.
3344-import { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json';
44+import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json';
55import remarkParse from 'remark-parse';
66import disableConstructs from 'remark-plugins/disable-constructs';
77import { Element, Text } from 'slate';
···3030 return node.type === 'text';
3131}
32323333-export function parseFromJson(json: string, discussions: Partial<Record<number, BeatmapsetDiscussionJsonForBundle | BeatmapsetDiscussionJsonForShow>>) {
3333+export function parseFromJson(json: string, discussions: Map<number | null | undefined, BeatmapsetDiscussionJson>) {
3434 let srcDoc: BeatmapDiscussionReview;
35353636 try {
···8787 case 'embed': {
8888 // embed
8989 const existingEmbedBlock = block as PersistedDocumentIssueEmbed;
9090- const discussion = discussions[existingEmbedBlock.discussion_id];
9090+ const discussion = discussions.get(existingEmbedBlock.discussion_id);
9191 if (discussion == null) {
9292 console.error('unknown/external discussion referenced', existingEmbedBlock.discussion_id);
9393 break;
···9999 }
100100101101 const post = startingPost(discussion);
102102- if (post.system) {
103103- console.error('embed should not have system starting post', existingEmbedBlock.discussion_id);
102102+ if (post == null || post.system) {
103103+ console.error('embed starting post is missing or is system post', existingEmbedBlock.discussion_id);
104104 break;
105105 }
106106
···11-# Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22-# See the LICENCE file in the repository root for full licence text.
33-44-import core from 'osu-core-singleton'
55-import { createElement } from 'react'
66-import { parseJson } from 'utils/json'
77-import { Main } from 'beatmap-discussions/main'
88-99-core.reactTurbolinks.register 'beatmap-discussions', (container) ->
1010- createElement Main,
1111- initial: parseJson 'json-beatmapset-discussion'
1212- container: container
+12
resources/js/entrypoints/beatmap-discussions.tsx
···11+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22+// See the LICENCE file in the repository root for full licence text.
33+44+import Main from 'beatmap-discussions/main';
55+import core from 'osu-core-singleton';
66+import React from 'react';
77+import { parseJson } from 'utils/json';
88+99+1010+core.reactTurbolinks.register('beatmap-discussions', () => (
1111+ <Main reviewsConfig={parseJson('json-reviews_config')} />
1212+));
+2-12
resources/js/entrypoints/modding-profile.tsx
···11// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22// See the LICENCE file in the repository root for full licence text.
3344+import { BeatmapsetDiscussionsBundleJsonForModdingProfile } from 'interfaces/beatmapset-discussions-bundle-json';
45import Main from 'modding-profile/main';
56import core from 'osu-core-singleton';
67import React from 'react';
78import { parseJson } from 'utils/json';
89910core.reactTurbolinks.register('modding-profile', () => (
1010- <Main
1111- beatmaps={parseJson('json-beatmaps')}
1212- beatmapsets={parseJson('json-beatmapsets')}
1313- discussions={parseJson('json-discussions')}
1414- events={parseJson('json-events')}
1515- extras={parseJson('json-extras')}
1616- perPage={parseJson('json-perPage')}
1717- posts={parseJson('json-posts')}
1818- user={parseJson('json-user')}
1919- users={parseJson('json-users')}
2020- votes={parseJson('json-votes')}
2121- />
1111+ <Main {...parseJson<BeatmapsetDiscussionsBundleJsonForModdingProfile>('json-bundle')} />
2212));
···11+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22+// See the LICENCE file in the repository root for full licence text.
33+44+import BeatmapExtendedJson from 'interfaces/beatmap-extended-json';
55+import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json';
66+import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json';
77+import UserJson from 'interfaces/user-json';
88+99+export default interface BeatmapsetDiscussionsStore {
1010+ beatmaps: Map<number, BeatmapExtendedJson>;
1111+ beatmapsets: Map<number, BeatmapsetExtendedJson>;
1212+ discussions: Map<number | null | undefined, BeatmapsetDiscussionJson>;
1313+ users: Map<number | null | undefined, UserJson>;
1414+}
···11+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22+// See the LICENCE file in the repository root for full licence text.
33+44+import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store';
55+import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json';
66+import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json';
77+import { computed, makeObservable, observable } from 'mobx';
88+import { mapBy, mapByWithNulls } from 'utils/map';
99+1010+export default class BeatmapsetDiscussionsShowStore implements BeatmapsetDiscussionsStore {
1111+ @observable beatmapset: BeatmapsetWithDiscussionsJson;
1212+1313+ @computed
1414+ get beatmaps() {
1515+ const hasDiscussion = new Set<number>();
1616+ for (const discussion of this.beatmapset.discussions) {
1717+ if (discussion?.beatmap_id != null) {
1818+ hasDiscussion.add(discussion.beatmap_id);
1919+ }
2020+ }
2121+2222+ return mapBy(
2323+ this.beatmapset.beatmaps.filter((beatmap) => beatmap.deleted_at == null || hasDiscussion.has(beatmap.id)),
2424+ 'id',
2525+ );
2626+ }
2727+2828+ @computed
2929+ get beatmapsets() {
3030+ return new Map<number, BeatmapsetExtendedJson>([[this.beatmapset.id, this.beatmapset]]);
3131+ }
3232+3333+ @computed
3434+ get discussions() {
3535+ // skipped discussions
3636+ // - not privileged (deleted discussion)
3737+ // - deleted beatmap
3838+3939+ // allow null for the key so we can use .get(null)
4040+ return mapByWithNulls(this.beatmapset.discussions, 'id');
4141+ }
4242+4343+ @computed
4444+ get users() {
4545+ return mapByWithNulls(this.beatmapset.related_users, 'id');
4646+ }
4747+4848+ constructor(beatmapset: BeatmapsetWithDiscussionsJson) {
4949+ this.beatmapset = beatmapset;
5050+ makeObservable(this);
5151+ }
5252+}
+6
resources/js/utils/array.ts
···11+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22+// See the LICENCE file in the repository root for full licence text.
33+44+export function mobxArrayGet<T>(array: T[] | null | undefined, index: number): T | undefined {
55+ return array != null && array.length > index ? array[index] : undefined;
66+}
···22// See the LICENCE file in the repository root for full licence text.
3344import { Filter, filters } from 'beatmap-discussions/current-discussions';
55-import DiscussionMode, { DiscussionPage, discussionPages } from 'beatmap-discussions/discussion-mode';
55+import DiscussionMode from 'beatmap-discussions/discussion-mode';
66+import DiscussionPage, { isDiscussionPage } from 'beatmap-discussions/discussion-page';
67import guestGroup from 'beatmap-discussions/guest-group';
78import mapperGroup from 'beatmap-discussions/mapper-group';
89import BeatmapJson from 'interfaces/beatmap-json';
99-import BeatmapsetDiscussionJson, { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json';
1010+import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json';
1011import BeatmapsetDiscussionPostJson from 'interfaces/beatmapset-discussion-post-json';
1112import BeatmapsetJson from 'interfaces/beatmapset-json';
1213import GameMode, { gameModes } from 'interfaces/game-mode';
···2021import { getInt } from './math';
21222223interface BadgeGroupParams {
2323- beatmapset: BeatmapsetJson;
2424- currentBeatmap: BeatmapJson | null;
2424+ beatmapset?: BeatmapsetJson;
2525+ currentBeatmap?: BeatmapJson | null;
2526 discussion: BeatmapsetDiscussionJson;
2627 user?: UserJson;
2728}
···7071// parseUrl and makeUrl lookups
7172const filterLookup = new Set<unknown>(filters);
7273const generalPages = new Set<unknown>(['events', 'generalAll', 'reviews']);
7373-const pageLookup = new Set<unknown>(discussionPages);
74747575const defaultBeatmapId = '-';
7676···8989 return null;
9090 }
91919292- if (user.id === beatmapset.user_id) {
9292+ if (user.id === beatmapset?.user_id) {
9393 return mapperGroup;
9494 }
9595···9797 return guestGroup;
9898 }
9999100100- return user.groups?.[0];
100100+ if (user.groups == null || user.groups.length === 0) {
101101+ return null;
102102+ }
103103+104104+ return user.groups[0];
101105}
102106103107export function canModeratePosts() {
···128132 const m = Math.floor(value / 1000 / 60);
129133130134 return `${padStart(m.toString(), 2, '0')}:${padStart(s.toString(), 2, '0')}:${padStart(ms.toString(), 3, '0')}`;
131131-}
132132-133133-134134-function isDiscussionPage(value: string): value is DiscussionPage {
135135- return pageLookup.has(value);
136135}
137136138137function isFilter(value: string): value is Filter {
···375374}
376375377376// Workaround for the discussion starting_post typing mess until the response gets refactored and normalized.
378378-export function startingPost(discussion: BeatmapsetDiscussionJsonForBundle | BeatmapsetDiscussionJsonForShow): BeatmapsetDiscussionPostJson {
379379- if (!('posts' in discussion)) {
380380- return discussion.starting_post;
377377+export function startingPost(discussion: BeatmapsetDiscussionJson) {
378378+ if ('posts' in discussion && discussion.posts != null) {
379379+ return discussion.posts[0];
381380 }
382381383383- return discussion.posts[0];
382382+ return discussion.starting_post;
384383}
385384386385export function stateFromDiscussion(discussion: BeatmapsetDiscussionJson) {
+4-4
resources/js/utils/json.ts
···5555 *
5656 * @param id id of the HTMLScriptElement.
5757 */
5858-export function parseJson<T>(id: string): T {
5959- const json = parseJsonNullable<T>(id);
5858+export function parseJson<T>(id: string, remove = false): T {
5959+ const json = parseJsonNullable<T>(id, remove);
6060 if (json == null) {
6161 throw new Error(`script element ${id} is missing or contains nullish value.`);
6262 }
···7171 * @param id id of the HTMLScriptElement.
7272 * @param remove true to remove the element after parsing; false, otherwise.
7373 */
7474-export function parseJsonNullable<T>(id: string, remove = false): T | undefined {
7474+export function parseJsonNullable<T>(id: string, remove = false, reviver?: (key: string, value: any) => any): T | undefined {
7575 const element = (window.newBody ?? document.body).querySelector(`#${id}`);
7676 if (!(element instanceof HTMLScriptElement)) return undefined;
7777- const json = JSON.parse(element.text) as T;
7777+ const json = JSON.parse(element.text, reviver) as T;
78787979 if (remove) {
8080 element.remove();
···11+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
22+// See the LICENCE file in the repository root for full licence text.
33+44+export function mapBy<T, K extends keyof T>(array: T[], key: K) {
55+ const map = new Map<T[K], T>();
66+77+ for (const value of array) {
88+ map.set(value[key], value);
99+ }
1010+1111+ return map;
1212+}
1313+1414+export function mapByWithNulls<T, K extends keyof T>(array: T[], key: K) {
1515+ const map = new Map<T[K] | null | undefined, T>();
1616+1717+ for (const value of array) {
1818+ map.set(value[key], value);
1919+ }
2020+2121+ return map;
2222+}
+31-1
resources/js/utils/score-helper.ts
···77import core from 'osu-core-singleton';
88import { rulesetName } from './beatmap-helper';
99import { trans } from './lang';
1010+import { legacyAccuracyAndRank } from './legacy-score-helper';
1111+1212+export function accuracy(score: SoloScoreJson) {
1313+ if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) {
1414+ return score.accuracy;
1515+ }
1616+1717+ return legacyAccuracyAndRank(score).accuracy;
1818+}
10191120export function canBeReported(score: SoloScoreJson) {
1221 return (score.best_id != null || score.type === 'solo_score')
1322 && core.currentUser != null
1423 && score.user_id !== core.currentUser.id;
2424+}
2525+2626+// Removes CL mod on legacy score if user has lazer mode disabled
2727+export function filterMods(score: SoloScoreJson) {
2828+ if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) {
2929+ return score.mods;
3030+ }
3131+3232+ return score.mods.filter((mod) => mod.acronym !== 'CL');
1533}
16341735// TODO: move to application state repository thingy later
···92110 ],
93111};
94112113113+export function rank(score: SoloScoreJson) {
114114+ if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) {
115115+ return score.rank;
116116+ }
117117+118118+ return legacyAccuracyAndRank(score).rank;
119119+}
120120+95121export function scoreDownloadUrl(score: SoloScoreJson) {
96122 if (score.type === 'solo_score') {
97123 return route('scores.download', { score: score.id });
···123149}
124150125151export function totalScore(score: SoloScoreJson) {
126126- return score.legacy_total_score ?? score.total_score;
152152+ if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) {
153153+ return score.total_score;
154154+ }
155155+156156+ return score.legacy_total_score;
127157}
+3
resources/lang/ar/password_reset.php
···3636 'starting' => [
3737 'username' => 'أدخل اسم المستخدم أو عنوان البريد الإلكتروني',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'تحتاج دعم في المستقبل؟ تواصل معنا على :button.',
4144 'button' => 'نظام الدعم',
···3636 'starting' => [
3737 'username' => 'Indtast email-adresse eller brugernavn',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Har du brug for yderligere assistance? Kontakt os via vores :button.',
4144 'button' => 'support system',
+3
resources/lang/de/password_reset.php
···3636 'starting' => [
3737 'username' => 'Benutzername oder E-Mail eingeben',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Benötigst du weitere Hilfe? Kontaktiere uns über unser :button.',
4144 'button' => 'Supportsystem',
+3
resources/lang/el/password_reset.php
···3636 'starting' => [
3737 'username' => 'Εισάγετε τη διεύθυνση ηλεκτρονικού ταχυδρομείου ή το όνομα χρήστη',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Χρειάζεστε περαιτέρω βοήθεια? Επικοινωνήστε μαζί μας μέσω :button.',
4144 'button' => 'σύστημα υποστήριξης',
+2
resources/lang/en/layout.php
···195195 'account-edit' => 'Settings',
196196 'follows' => 'Watchlists',
197197 'friends' => 'Friends',
198198+ 'legacy_score_only_toggle' => 'Lazer mode',
199199+ 'legacy_score_only_toggle_tooltip' => 'Lazer mode shows scores set from lazer with a new scoring algorithm',
198200 'logout' => 'Sign Out',
199201 'profile' => 'My Profile',
200202 ],
+1
resources/lang/en/scores.php
···2525 'status' => [
2626 'non_best' => 'Only personal best scores award pp',
2727 'non_passing' => 'Only passing scores award pp',
2828+ 'no_pp' => 'pp is not awarded for this score',
2829 'processing' => 'This score is still being calculated and will be displayed soon',
2930 ],
3031];
+3
resources/lang/es/password_reset.php
···3636 'starting' => [
3737 'username' => 'Ingrese correo o nombre de usuario',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => '¿Necesita asistencia? Contáctenos a través de nuestro :button.',
4144 'button' => 'sistema de soporte',
+3
resources/lang/fa-IR/password_reset.php
···3636 'starting' => [
3737 'username' => 'ایمیل یا نام کاربری را وارد کتید',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'نیاز به کمک بیشتر دارید؟ با ما توسط :button تماس بگیرید.',
4144 'button' => 'سیستم پشتیبانی',
···3636 'starting' => [
3737 'username' => 'Entrez une adresse e-mail ou un nom d\'utilisateur',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Vous avez besoin d\'aide supplémentaire ? Contactez-nous via notre :button.',
4144 'button' => 'système de support',
+3
resources/lang/he/password_reset.php
···3636 'starting' => [
3737 'username' => 'הכנס אימייל או שם משתמש',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'צריך עזרה נוספת? צור איתנו קשר דרך ה:button שלנו.',
4144 'button' => 'מערכת תמיכה',
+3
resources/lang/hr-HR/password_reset.php
···3636 'starting' => [
3737 'username' => 'Unesi svoju adresu e-pošte ili korisničko ime',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Trebaš dodatnu pomoć? Kontaktiraj nas putem naše :button.',
4144 'button' => 'sistema za podršku',
+3
resources/lang/hu/password_reset.php
···3636 'starting' => [
3737 'username' => 'Add meg az e-mail címed vagy felhasználóneved',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Segítség kéne? Lépj kapcsolatba velünk itt :botton.',
4144 'button' => 'támogatói rendszer',
+1-1
resources/lang/id/artist.php
···44// See the LICENCE file in the repository root for full licence text.
5566return [
77- 'page_description' => 'Featured artist di osu!',
77+ 'page_description' => 'Featured Artist di osu!',
88 'title' => 'Featured Artist',
991010 'admin' => [
+1-1
resources/lang/id/authorization.php
···4848 'edit' => [
4949 'not_owner' => 'Hanya pemilik topik yang diperbolehkan untuk menyunting kiriman.',
5050 'resolved' => 'Kamu tidak dapat menyunting postingan pada topik diskusi yang telah terjawab.',
5151- 'system_generated' => 'Post yang dihasilkan secara otomatis tidak dapat disunting.',
5151+ 'system_generated' => 'Postingan yang dihasilkan secara otomatis tidak dapat disunting.',
5252 ],
5353 ],
5454
+1-1
resources/lang/id/beatmap_discussions.php
···1313 ],
14141515 'events' => [
1616- 'empty' => 'Belum ada hal apapun yang terjadi... hingga saat ini.',
1616+ 'empty' => 'Belum ada hal apa pun yang terjadi... hingga saat ini.',
1717 ],
18181919 'index' => [
+1-1
resources/lang/id/follows.php
···2424 ],
25252626 'mapping' => [
2727- 'empty' => 'Kamu tidak sedang mengikuti siapapun.',
2727+ 'empty' => 'Kamu tidak sedang mengikuti siapa pun.',
2828 'followers' => 'pengikut mapping',
2929 'page_title' => 'mapper yang diikuti',
3030 'title' => 'mapper',
+1-1
resources/lang/id/forum.php
···3333 ],
34343535 'topics' => [
3636- 'empty' => 'Tidak ada topik!',
3636+ 'empty' => 'Tidak ada topik apa pun di sini!',
3737 ],
3838 ],
3939
+1-1
resources/lang/id/home.php
···77 'landing' => [
88 'download' => 'Unduh sekarang',
99 'online' => 'dengan <strong>:players</strong> pemain yang saat ini terhubung dalam <strong>:games</strong> ruang permainan',
1010- 'peak' => 'Jumlah pengguna online terbanyak: :count',
1010+ 'peak' => 'Puncak aktivitas: :count pengguna online',
1111 'players' => '<strong>:count</strong> pengguna terdaftar',
1212 'title' => 'selamat datang',
1313 'see_more_news' => 'lihat lebih banyak berita',
+1-1
resources/lang/id/legacy_api_key.php
···2323 ],
24242525 'warning' => [
2626- 'line1' => 'Jangan berikan informasi ini pada siapapun.',
2626+ 'line1' => 'Jangan berikan informasi ini kepada siapa pun.',
2727 'line2' => "Ini sama halnya membagikan akunmu pada yang lain.",
2828 'line3' => 'Harap untuk tidak membagikan informasi ini.',
2929 ],
+7-7
resources/lang/id/notifications.php
···45454646 'beatmapset_discussion' => [
4747 '_' => 'Laman diskusi beatmap',
4848- 'beatmapset_discussion_lock' => 'Diskusi untuk beatmap ":title" telah ditutup.',
4848+ 'beatmapset_discussion_lock' => 'Diskusi pada beatmap ":title" telah dikunci',
4949 'beatmapset_discussion_lock_compact' => 'Diskusi beatmap telah dikunci',
5050 'beatmapset_discussion_post_new' => 'Postingan baru pada ":title" oleh :username: ":content"',
5151 'beatmapset_discussion_post_new_empty' => 'Postingan baru pada ":title" oleh :username',
5252 'beatmapset_discussion_post_new_compact' => 'Postingan baru oleh :username: ":content"',
5353 'beatmapset_discussion_post_new_compact_empty' => 'Postingan baru oleh :username',
5454- 'beatmapset_discussion_review_new' => 'Terdapat ulasan baru pada ":title" oleh :username yang menyinggung seputar masalah: :problems, saran: :suggestions, dan pujian berupa: :praises',
5555- 'beatmapset_discussion_review_new_compact' => 'Terdapat ulasan baru oleh :username yang menyinggung seputar masalah: :problems, saran: :suggestions, dan pujian berupa: :praises',
5656- 'beatmapset_discussion_unlock' => 'Diskusi untuk beatmap ":title" telah dibuka kembali.',
5454+ 'beatmapset_discussion_review_new' => 'Kajian baru pada ":title" oleh :username yang mengandung :review_counts',
5555+ 'beatmapset_discussion_review_new_compact' => 'Kajian baru oleh :username yang mengandung :review_counts',
5656+ 'beatmapset_discussion_unlock' => 'Diskusi pada beatmap ":title" telah kembali dibuka',
5757 'beatmapset_discussion_unlock_compact' => 'Diskusi beatmap telah dibuka',
58585959 'review_count' => [
···8080 'beatmapset_nominate' => '":title" telah dinominasikan',
8181 'beatmapset_nominate_compact' => 'Beatmap telah dinominasikan',
8282 'beatmapset_qualify' => '":title" telah memperoleh jumlah nominasi yang dibutuhkan untuk dapat memasuki antrian ranking',
8383- 'beatmapset_qualify_compact' => 'Beatmap telah memasuki antrian ranking',
8383+ 'beatmapset_qualify_compact' => 'Beatmap memasuki antrian ranking',
8484 'beatmapset_rank' => '":title" telah berstatus Ranked',
8585 'beatmapset_rank_compact' => 'Beatmap telah berstatus Ranked',
8686 'beatmapset_remove_from_loved' => '":title" telah dilepas dari Loved',
8787 'beatmapset_remove_from_loved_compact' => 'Beatmap telah dilepas dari Loved',
8888- 'beatmapset_reset_nominations' => 'Masalah yang dikemukakan oleh :username menganulir nominasi sebelumnya pada beatmap ":title" ',
8888+ 'beatmapset_reset_nominations' => 'Nominasi pada beatmap ":title" telah dianulir',
8989 'beatmapset_reset_nominations_compact' => 'Nominasi beatmap dianulir',
9090 ],
9191···207207 'beatmapset_qualify' => '":title" telah memperoleh jumlah nominasi yang dibutuhkan untuk dapat memasuki antrian ranking',
208208 'beatmapset_rank' => '":title" telah berstatus Ranked',
209209 'beatmapset_remove_from_loved' => ':title telah dilepas dari Loved',
210210- 'beatmapset_reset_nominations' => 'Status nominasi pada ":title" telah dianulir',
210210+ 'beatmapset_reset_nominations' => 'Nominasi pada beatmap ":title" telah dianulir',
211211 ],
212212213213 'comment' => [
···15151616 'errors_no_checkout' => [
1717 'line_1' => 'Uh-oh, terdapat masalah dengan keranjangmu yang menghalangi proses checkout!',
1818- 'line_2' => 'Hapus atau perbarui item-item di atas untuk melanjutkan.',
1818+ 'line_2' => 'Hapus atau perbarui rangkaian item di atas untuk melanjutkan.',
1919 ],
20202121 'empty' => [
···4343 ],
44444545 'pending_checkout' => [
4646- 'line_1' => 'Transaksi sebelumnya belum dituntaskan.',
4646+ 'line_1' => 'Terdapat transaksi terdahulu yang belum dituntaskan.',
4747 'line_2' => 'Lanjutkan pembayaranmu dengan memilih metode pembayaran.',
4848 ],
4949 ],
···7777 ],
7878 ],
7979 'prepared' => [
8080- 'title' => 'Pesananmu sedang disiapkan!',
8080+ 'title' => 'Pesananmu sedang dipersiapkan!',
8181 '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.',
8282 '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.',
8383 ],
+1-1
resources/lang/id/wiki.php
···2121 ],
22222323 'translation' => [
2424- 'legal' => 'Terjemahan ini diberikan semata-mata hanya untuk memudahkan. :default dari artikel ini merupakan satu-satunya versi artikel yang mengikat secara hukum.',
2424+ 'legal' => 'Terjemahan ini diberikan semata-mata untuk memudahkan. :default dari artikel ini merupakan satu-satunya versi artikel yang mengikat secara hukum.',
2525 '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)!',
26262727 'default' => 'Versi Bahasa Inggris',
+3
resources/lang/it/password_reset.php
···3636 'starting' => [
3737 'username' => 'Inserisci l\'indirizzo email o il nome utente',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Hai bisogno di ulteriore assistenza? Contattaci col nostro :button.',
4144 'button' => 'sistema di supporto',
+1-1
resources/lang/it/store.php
···9393 'title' => 'Il tuo ordine è stato spedito!',
9494 'tracking_details' => '',
9595 'no_tracking_details' => [
9696- '_' => "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.",
9696+ '_' => "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).",
9797 'link_text' => 'inviaci un\'email',
9898 ],
9999 ],
···3636 'starting' => [
3737 'username' => 'Vul e-mail adres of gebruikersnaam in',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Meer hulp nodig? Neem contact met ons op via onze :button.',
4144 'button' => 'ondersteuningssysteem',
+3
resources/lang/no/password_reset.php
···3636 'starting' => [
3737 'username' => 'Skriv inn e-postadresse eller brukernavn',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Trenger du mer hjelp? Kontakt oss via vårt :button.',
4144 'button' => 'støttesystem',
+3
resources/lang/pl/password_reset.php
···3636 'starting' => [
3737 'username' => 'Wprowadź e-mail lub nazwę użytkownika',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Potrzebujesz pomocy? Skontaktuj się z :button.',
4144 'button' => 'pomocą techniczną',
+3
resources/lang/pt-br/password_reset.php
···3636 'starting' => [
3737 'username' => 'Insira endereço de email ou nome de usuário',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Precisa de mais assistência? Entre em contato conosco através do nosso :button.',
4144 'button' => 'sistema de suporte',
+3
resources/lang/pt/password_reset.php
···3636 'starting' => [
3737 'username' => 'Introduz um endereço de email ou um nome de utilizador',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Precisas de mais assistência? Contacta-nos a partir do nosso :button.',
4144 'button' => 'sistema de suporte',
+2-2
resources/lang/ro/beatmapset_events.php
···3030 'nomination_reset' => 'O problemă nouă :discussion (:text) a declanșat reluarea unei nominalizări.',
3131 'nomination_reset_received' => 'Nominalizarea de :user a fost resetată de către :source_user (:text)',
3232 'nomination_reset_received_profile' => 'Nominalizarea a fost resetată de :user (:text)',
3333- 'offset_edit' => 'Offset-ul online schimbat din :old la :new.',
3333+ 'offset_edit' => 'Decalaj online schimbat din :old la :new.',
3434 'qualify' => 'Acest beatmap a atins numărul limită de nominalizări și s-a calificat.',
3535 'rank' => 'Clasat.',
3636 'remove_from_loved' => 'Eliminat din Iubit de :user. (:text)',
···7979 'nomination_reset' => 'Resetarea nominalizărilor',
8080 'nomination_reset_received' => 'Resetare a nominalizării primită',
8181 'nsfw_toggle' => 'Marcaj obscen',
8282- 'offset_edit' => 'Editare offset',
8282+ 'offset_edit' => 'Editare decalaj',
8383 'qualify' => 'Calificare',
8484 'rank' => 'Clasament',
8585 'remove_from_loved' => 'Scoaterea din Iubit',
···9393 'title' => 'Ваш заказ отправлен!',
9494 'tracking_details' => 'Подробности отслеживания:',
9595 'no_tracking_details' => [
9696- '_' => "У нас нет данных отслеживания, поскольку мы отправили ваш заказ авиапочтой, однако вы можете рассчитывать на их получение в течение 1-3 недель. Иногда таможня в Европе может задержать заказ вне нашего контроля. Если у вас остались вопросы, ответьте на полученное вами письмо с подтверждением заказа :link.",
9696+ '_' => "У нас нет данных отслеживания, поскольку мы отправили ваш заказ авиапочтой, однако вы можете рассчитывать на их получение в течение 1-3 недель. Иногда таможня в Европе может задержать заказ вне нашего контроля. Если у вас остались вопросы, ответьте на полученное вами письмо с подтверждением заказа (или :link).",
9797 'link_text' => 'отправьте нам письмо',
9898 ],
9999 ],
···3636 'starting' => [
3737 'username' => 'Nhập địa chỉ email hoặc tên tài khoản',
38383939+ 'reason' => [
4040+ 'inactive_different_country' => "",
4141+ ],
3942 'support' => [
4043 '_' => 'Cần nhiều sự giúp đỡ hơn? Liên hệ với chúng tôi bằng :button.',
4144 'button' => 'hệ thống hỗ trợ',
···407407// There's also a different group which skips throttle middleware.
408408Route::group(['as' => 'api.', 'prefix' => 'api', 'middleware' => ['api', ThrottleRequests::getApiThrottle(), 'require-scopes']], function () {
409409 Route::group(['prefix' => 'v2'], function () {
410410+ Route::group(['middleware' => ['require-scopes:any']], function () {
411411+ Route::post('session/verify', 'AccountController@verify')->name('verify');
412412+ Route::post('session/verify/reissue', 'AccountController@reissueCode')->name('verify.reissue');
413413+ });
414414+410415 Route::group(['as' => 'beatmaps.', 'prefix' => 'beatmaps'], function () {
411416 Route::get('lookup', 'BeatmapsController@lookup')->name('lookup');
412417413418 Route::apiResource('packs', 'BeatmapPacksController', ['only' => ['index', 'show']]);
414419415420 Route::group(['prefix' => '{beatmap}'], function () {
416416- Route::get('scores/users/{user}', 'BeatmapsController@userScore');
417417- Route::get('scores/users/{user}/all', 'BeatmapsController@userScoreAll');
421421+ Route::get('scores/users/{user}', 'BeatmapsController@userScore')->name('user.score');
422422+ Route::get('scores/users/{user}/all', 'BeatmapsController@userScoreAll')->name('user.scores');
418423 Route::get('scores', 'BeatmapsController@scores')->name('scores');
419424 Route::get('solo-scores', 'BeatmapsController@soloScores')->name('solo-scores');
420425
+17-7
tests/Browser/BeatmapDiscussionPostsTest.php
···15151616class BeatmapDiscussionPostsTest extends DuskTestCase
1717{
1818- private $new_reply_widget_selector = '.beatmap-discussion-post--new-reply';
1818+ private const NEW_REPLY_SELECTOR = '.beatmap-discussion-post--new-reply';
1919+ private const RESOLVE_BUTTON_SELECTOR = '.btn-osu-big[data-action=reply_resolve]';
2020+2121+ private Beatmap $beatmap;
2222+ private BeatmapDiscussion $beatmapDiscussion;
2323+ private Beatmapset $beatmapset;
2424+ private User $mapper;
2525+ private User $user;
19262027 public function testConcurrentPostAfterResolve()
2128 {
···41484249 protected function writeReply(Browser $browser, $reply)
4350 {
4444- $browser->with($this->new_reply_widget_selector, function ($new_reply) use ($reply) {
4545- $new_reply->press('Respond')
5151+ $browser->with(static::NEW_REPLY_SELECTOR, function (Browser $newReply) use ($reply) {
5252+ $newReply->press(trans('beatmap_discussions.reply.open.user'))
4653 ->waitFor('textarea')
4754 ->type('textarea', $reply);
4855 });
···50575158 protected function postReply(Browser $browser, $action)
5259 {
5353- $browser->with($this->new_reply_widget_selector, function ($new_reply) use ($action) {
6060+ $browser->with(static::NEW_REPLY_SELECTOR, function (Browser $newReply) use ($action) {
5461 switch ($action) {
5562 case 'resolve':
5656- $new_reply->press('Reply and Resolve');
6363+ // button may be covered by dev banner;
6464+ // ->element->($selector)->getLocationOnScreenOnceScrolledIntoView() uses { block: 'end', inline: 'nearest' } which isn't enough.
6565+ $newReply->scrollIntoView(static::RESOLVE_BUTTON_SELECTOR);
6666+ $newReply->element(static::RESOLVE_BUTTON_SELECTOR)->click();
5767 break;
5868 default:
5959- $new_reply->keys('textarea', '{enter}');
6969+ $newReply->keys('textarea', '{enter}');
6070 break;
6171 }
6272 });
···110120 $post = BeatmapDiscussionPost::factory()->timeline()->make([
111121 'user_id' => $this->user,
112122 ]);
113113- $this->beatmapDiscussionPost = $this->beatmapDiscussion->beatmapDiscussionPosts()->save($post);
123123+ $this->beatmapDiscussion->beatmapDiscussionPosts()->save($post);
114124115125 $this->beforeApplicationDestroyed(function () {
116126 // Similar case to SanityTest, cleanup the models we created during the test.
+3
tests/Browser/SanityTest.php
···294294 } elseif ($line['message'] === "security - Error with Permissions-Policy header: Unrecognized feature: 'ch-ua-form-factor'.") {
295295 // we don't use ch-ua-* crap and this error is thrown by youtube.com as of 2023-05-16
296296 continue;
297297+ } elseif (str_ends_with($line['message'], ' Third-party cookie will be blocked. Learn more in the Issues tab.')) {
298298+ // thanks, youtube
299299+ continue;
297300 }
298301299302 $return[] = $line;