diff --git a/.env.example b/.env.example index 932cf21d3d2..63e9968c622 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,6 @@ APP_DEBUG=true APP_KEY= APP_LOG_LEVEL=debug # APP_SENTRY=https://... -# APP_SENTRY_PUBLIC=https://... # APP_SENTRY_ENVIRONMENT= # DOCS_URL= diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 5c7deb51d9d..b48e5e901ac 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -48,7 +48,7 @@ class Handler extends ExceptionHandler public static function exceptionMessage($e) { if ($e instanceof ModelNotFoundException) { - return; + return static::modelNotFoundMessage($e); } if (static::statusCode($e) >= 500) { @@ -90,6 +90,20 @@ private static function isOAuthSessionException(Throwable $e): bool && $e->getMessage() === 'Authorization request was not present in the session.'; } + private static function modelNotFoundMessage(ModelNotFoundException $e): string + { + $model = $e->getModel(); + $modelTransKey = "models.name.{$model}"; + + $params = [ + 'model' => trans_exists($modelTransKey, $GLOBALS['cfg']['app']['fallback_locale']) + ? osu_trans($modelTransKey) + : trim(strtr($model, ['App\Models\\' => '']), '\\'), + ]; + + return osu_trans('models.not_found', $params); + } + private static function reportWithSentry(Throwable $e): void { $ref = log_error_sentry($e, ['http_code' => (string) static::statusCode($e)]); diff --git a/app/Http/Controllers/BeatmapsController.php b/app/Http/Controllers/BeatmapsController.php index 090fc442761..5ed4f07be15 100644 --- a/app/Http/Controllers/BeatmapsController.php +++ b/app/Http/Controllers/BeatmapsController.php @@ -75,7 +75,7 @@ private static function beatmapScores(string $id, ?string $scoreTransformerType, 'type' => $type, 'user' => $currentUser, ]); - $scores = $esFetch->all()->loadMissing(['beatmap', 'user.country']); + $scores = $esFetch->all()->loadMissing(['beatmap', 'user.country', 'processHistory']); $userScore = $esFetch->userBest(); $scoreTransformer = new ScoreTransformer($scoreTransformerType); @@ -494,7 +494,7 @@ public function userScoreAll($beatmapId, $userId) 'sort' => 'score_desc', 'user_id' => (int) $userId, ]); - $scores = (new ScoreSearch($params))->records(); + $scores = (new ScoreSearch($params))->records()->loadMissing('processHistory'); return [ 'scores' => json_collection($scores, new ScoreTransformer()), diff --git a/app/Http/Controllers/ScoresController.php b/app/Http/Controllers/ScoresController.php index cfdae5f942c..5f295287198 100644 --- a/app/Http/Controllers/ScoresController.php +++ b/app/Http/Controllers/ScoresController.php @@ -91,12 +91,22 @@ public function download($rulesetOrSoloId, $id = null) public function show($rulesetOrSoloId, $legacyId = null) { - $scoreQuery = $legacyId === null - ? SoloScore::whereKey($rulesetOrSoloId) - : SoloScore::where([ + if ($legacyId === null) { + $scoreQuery = SoloScore::whereKey($rulesetOrSoloId); + } else { + // `SoloScore` tables can have records with `legacy_score_id = 0` + // which correspond to rows from `osu_scores_*` (non-high) tables. + // do not attempt to perform lookups for zero to avoid weird results. + // negative IDs should never occur (ID columns in score tables are all `bigint unsigned`). + if ($legacyId <= 0) { + abort(404, 'invalid score ID'); + } + + $scoreQuery = SoloScore::where([ 'ruleset_id' => Ruleset::tryFromName($rulesetOrSoloId) ?? abort(404, 'unknown ruleset name'), 'legacy_score_id' => $legacyId, ]); + } $score = $scoreQuery->whereHas('beatmap.beatmapset')->visibleUsers()->firstOrFail(); $userIncludes = array_map(function ($include) { diff --git a/app/Jobs/EsDocument.php b/app/Jobs/EsDocument.php index abde72d4860..b11a359d8cf 100644 --- a/app/Jobs/EsDocument.php +++ b/app/Jobs/EsDocument.php @@ -33,6 +33,11 @@ public function __construct($model) ]; } + public function displayName() + { + return static::class." ({$this->modelMeta['class']} {$this->modelMeta['id']})"; + } + /** * Execute the job. * diff --git a/app/Jobs/Notifications/BeatmapsetNotification.php b/app/Jobs/Notifications/BeatmapsetNotification.php index bea9c025905..9272f2275ba 100644 --- a/app/Jobs/Notifications/BeatmapsetNotification.php +++ b/app/Jobs/Notifications/BeatmapsetNotification.php @@ -28,6 +28,11 @@ public function __construct(Beatmapset $beatmapset, ?User $source = null) $this->beatmapset = $beatmapset; } + public function displayName() + { + return static::class." (Beatmapset {$this->beatmapset->getKey()})"; + } + public function getDetails(): array { return [ diff --git a/app/Jobs/RegenerateBeatmapsetCover.php b/app/Jobs/RegenerateBeatmapsetCover.php index b2f808c497b..3a949ecba1a 100644 --- a/app/Jobs/RegenerateBeatmapsetCover.php +++ b/app/Jobs/RegenerateBeatmapsetCover.php @@ -42,6 +42,11 @@ public function __construct(Beatmapset $beatmapset, array $sizesToRegenerate = n $this->sizesToRegenerate = $sizesToRegenerate; } + public function displayName() + { + return static::class." (Beatmapset {$this->beatmapset->getKey()})"; + } + /** * Execute the job. * diff --git a/app/Jobs/RemoveBeatmapsetBestScores.php b/app/Jobs/RemoveBeatmapsetBestScores.php index 0e1b85fe53e..19ef16944ca 100644 --- a/app/Jobs/RemoveBeatmapsetBestScores.php +++ b/app/Jobs/RemoveBeatmapsetBestScores.php @@ -18,7 +18,7 @@ class RemoveBeatmapsetBestScores implements ShouldQueue { use Queueable, SerializesModels; - public $timeout = 3600; + public $timeout = 36000; public $beatmapset; public $maxScoreIds = null; @@ -36,6 +36,11 @@ public function __construct(Beatmapset $beatmapset) } } + public function displayName() + { + return static::class." (Beatmapset {$this->beatmapset->getKey()})"; + } + /** * Execute the job. * diff --git a/app/Jobs/RemoveBeatmapsetSoloScores.php b/app/Jobs/RemoveBeatmapsetSoloScores.php index 673e422c5d0..7a464fcdee4 100644 --- a/app/Jobs/RemoveBeatmapsetSoloScores.php +++ b/app/Jobs/RemoveBeatmapsetSoloScores.php @@ -19,7 +19,7 @@ class RemoveBeatmapsetSoloScores implements ShouldQueue { use Queueable; - public $timeout = 3600; + public $timeout = 36000; private int $beatmapsetId; private int $maxScoreId; @@ -37,6 +37,11 @@ public function __construct(Beatmapset $beatmapset) $this->maxScoreId = Score::max('id') ?? 0; } + public function displayName() + { + return static::class." (Beatmapset {$this->beatmapsetId})"; + } + /** * Execute the job. * diff --git a/app/Libraries/Search/ArtistTrackSearchParams.php b/app/Libraries/Search/ArtistTrackSearchParams.php index 26f28ed7322..d8686c30089 100644 --- a/app/Libraries/Search/ArtistTrackSearchParams.php +++ b/app/Libraries/Search/ArtistTrackSearchParams.php @@ -27,10 +27,12 @@ class ArtistTrackSearchParams extends SearchParams public ?string $album; public ?string $artist; public ?array $bpm; + public ?array $bpmInput; public bool $exclusiveOnly = false; public ?string $genre; public bool $isDefaultSort = false; public ?array $length; + public ?array $lengthInput; public string $sortField; public string $sortOrder; diff --git a/app/Libraries/Search/ScoreSearchParams.php b/app/Libraries/Search/ScoreSearchParams.php index cb0d3158d83..047c54e87f9 100644 --- a/app/Libraries/Search/ScoreSearchParams.php +++ b/app/Libraries/Search/ScoreSearchParams.php @@ -86,7 +86,11 @@ public static function showLegacyForUser( return null; } - return ($user->userProfileCustomization ?? UserProfileCustomization::DEFAULTS)['legacy_score_only'] + $profileCustomization = $user !== null + ? $user->profileCustomization() + : UserProfileCustomization::DEFAULTS; + + return $profileCustomization['legacy_score_only'] ? true : null; } diff --git a/app/Models/Chat/Channel.php b/app/Models/Chat/Channel.php index 6476f7be9b3..026e1ebf1ea 100644 --- a/app/Models/Chat/Channel.php +++ b/app/Models/Chat/Channel.php @@ -456,14 +456,8 @@ public function receiveMessage(User $sender, ?string $content, bool $isAction = throw new API\ExcessiveChatMessagesException(osu_trans('api.error.chat.limit_exceeded')); } - $chatFilters = app('chat-filters')->all(); - - foreach ($chatFilters as $filter) { - $content = str_replace($filter->match, $filter->replacement, $content); - } - $message = new Message([ - 'content' => $content, + 'content' => app('chat-filters')->filter($content), 'is_action' => $isAction, 'timestamp' => $now, ]); diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index 0f3d8ab4c2e..17197647ada 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -149,6 +149,11 @@ public function user() return $this->belongsTo(User::class, 'user_id'); } + public function processHistory() + { + return $this->hasOne(ScoreProcessHistory::class, 'score_id'); + } + public function scopeDefault(Builder $query): Builder { return $query->whereHas('beatmap.beatmapset'); @@ -243,6 +248,7 @@ public function getAttribute($key) 'beatmap', 'performance', + 'processHistory', 'reportedIn', 'user' => $this->getRelationValue($key), }; diff --git a/app/Models/Solo/ScoreProcessHistory.php b/app/Models/Solo/ScoreProcessHistory.php new file mode 100644 index 00000000000..12c0787f565 --- /dev/null +++ b/app/Models/Solo/ScoreProcessHistory.php @@ -0,0 +1,25 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Models\Solo; + +use App\Models\Model; + +/** + * @property int $score_id + * @property int $processed_version + * @property \Carbon\Carbon $processed_at + */ +class ScoreProcessHistory extends Model +{ + protected $table = 'score_process_history'; + + public function score() + { + return $this->belongsTo(Score::class, 'score_id'); + } +} diff --git a/app/Models/Store/Order.php b/app/Models/Store/Order.php index c91be16e98c..cd753e1a961 100644 --- a/app/Models/Store/Order.php +++ b/app/Models/Store/Order.php @@ -694,7 +694,7 @@ private function newOrderItem(array $params, Product $product) $params['extra_data'] = ExtraDataSupporterTag::fromOrderItemParams($params, $this->user); break; // TODO: look at migrating to extra_data - case 'username-change': + case Product::USERNAME_CHANGE: // ignore received cost $params['cost'] = $this->user->usernameChangeCost(); break; diff --git a/app/Models/Store/OrderItem.php b/app/Models/Store/OrderItem.php index 764e1aa0177..3ed193e79bf 100644 --- a/app/Models/Store/OrderItem.php +++ b/app/Models/Store/OrderItem.php @@ -115,7 +115,7 @@ public function getCustomClassInstance() { // only one for now if ($this->product->custom_class === 'username-change') { - return new ChangeUsername($this->order->user, $this->extra_info); + return new ChangeUsername($this->order->user, $this->extra_info ?? ''); } } diff --git a/app/Models/Store/Product.php b/app/Models/Store/Product.php index 94619c9a3c8..e934c2adad7 100644 --- a/app/Models/Store/Product.php +++ b/app/Models/Store/Product.php @@ -40,8 +40,10 @@ */ class Product extends Model { + const BUTTON_DISABLED = [self::SUPPORTER_TAG_NAME, self::USERNAME_CHANGE]; const REDIRECT_PLACEHOLDER = 'redirect'; const SUPPORTER_TAG_NAME = 'supporter-tag'; + const USERNAME_CHANGE = 'username-change'; protected $primaryKey = 'product_id'; diff --git a/app/Models/UserProfileCustomization.php b/app/Models/UserProfileCustomization.php index 1abf1dbca43..304bce56668 100644 --- a/app/Models/UserProfileCustomization.php +++ b/app/Models/UserProfileCustomization.php @@ -5,6 +5,7 @@ namespace App\Models; +use App\Models\Solo\Score; use Illuminate\Database\Eloquent\Casts\AsArrayObject; /** @@ -28,7 +29,7 @@ class UserProfileCustomization extends Model 'comments_sort' => Comment::DEFAULT_SORT, 'extras_order' => self::SECTIONS, 'forum_posts_show_deleted' => true, - 'legacy_score_only' => true, + 'legacy_score_only' => false, 'profile_cover_expanded' => true, 'user_list_filter' => self::USER_LIST['filters']['default'], 'user_list_sort' => self::USER_LIST['sorts']['default'], @@ -195,7 +196,19 @@ public function setForumPostsShowDeletedAttribute($value) public function getLegacyScoreOnlyAttribute(): bool { - return $this->options['legacy_score_only'] ?? static::DEFAULTS['legacy_score_only']; + $option = $this->options['legacy_score_only'] ?? null; + if ($option === null) { + $lastScore = Score::where('user_id', $this->getKey())->last(); + if ($lastScore === null) { + $option = static::DEFAULTS['legacy_score_only']; + } else { + $option = $lastScore->isLegacy(); + $this->setOption('legacy_score_only', $option); + $this->save(); + } + } + + return $option; } public function setLegacyScoreOnlyAttribute($value): void diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2bf3d0f79d7..ee86fa994e6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -64,7 +64,7 @@ public function boot() $GLOBALS['cfg']['datadog-helper']['prefix_web'].'.queue.run', 1, [ - 'job' => $event->job->resolveName(), + 'job' => $event->job->payload()['data']['commandName'], 'queue' => $event->job->getQueue(), ] ); diff --git a/app/Singletons/ChatFilters.php b/app/Singletons/ChatFilters.php index 24d96c92751..2700379b7c5 100644 --- a/app/Singletons/ChatFilters.php +++ b/app/Singletons/ChatFilters.php @@ -7,19 +7,22 @@ use App\Models\ChatFilter; use App\Traits\Memoizes; -use Illuminate\Database\Eloquent\Collection; class ChatFilters { use Memoizes; - public function all() + public function filter(string $text): string { - return $this->memoize(__FUNCTION__, fn () => $this->fetch()); - } + $replacements = $this->memoize(__FUNCTION__, function () { + $ret = []; + foreach (ChatFilter::all() as $entry) { + $ret[$entry->match] = $entry->replacement; + } - protected function fetch(): Collection - { - return ChatFilter::all(); + return $ret; + }); + + return strtr($text, $replacements); } } diff --git a/app/Transformers/Multiplayer/RoomTransformer.php b/app/Transformers/Multiplayer/RoomTransformer.php index cd687f53e1f..193ff93db59 100644 --- a/app/Transformers/Multiplayer/RoomTransformer.php +++ b/app/Transformers/Multiplayer/RoomTransformer.php @@ -21,7 +21,6 @@ class RoomTransformer extends TransformerAbstract 'playlist', 'playlist_item_stats', 'recent_participants', - 'scores', ]; public function transform(Room $room) @@ -94,12 +93,4 @@ public function includePlaylistItemStats(Room $room) { return $this->primitive($room->playlistItemStats()); } - - public function includeScores(Room $room) - { - return $this->collection( - $room->scores()->completed()->get(), - new ScoreTransformer() - ); - } } diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index 08b1b7a79ae..fba784eec4f 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -24,6 +24,7 @@ class ScoreTransformer extends TransformerAbstract // warning: the preload is actually for PlaylistItemUserHighScore, not for Score const MULTIPLAYER_BASE_PRELOAD = [ 'scoreLink.score', + 'scoreLink.score.processHistory', 'scoreLink.user.country', ]; @@ -35,6 +36,7 @@ class ScoreTransformer extends TransformerAbstract const USER_PROFILE_INCLUDES_PRELOAD = [ 'beatmap', 'beatmap.beatmapset', + 'processHistory', // it's for user profile so the user is already available // 'user', ]; @@ -102,6 +104,7 @@ public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) if ($score instanceof SoloScore) { $extraAttributes['ranked'] = $score->ranked; $extraAttributes['preserve'] = $score->preserve; + $extraAttributes['processed'] = $score->legacy_score_id !== null || $score->processHistory !== null; } $hasReplay = $score->has_replay; diff --git a/config/services.php b/config/services.php index 6c3da69a69d..34283279340 100644 --- a/config/services.php +++ b/config/services.php @@ -42,8 +42,4 @@ 'passport' => [ 'path' => env('PASSPORT_KEY_PATH'), ], - - 'sentry' => [ - 'public_dsn' => env('APP_SENTRY_PUBLIC', ''), - ], ]; diff --git a/package.json b/package.json index a1d89043c01..4ef87710ecf 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,6 @@ "webpack": "^5.88.2", "webpack-cli": "^5.1.4", "webpack-manifest-plugin": "^5.0.0", - "webpack-sentry-plugin": "^2.0.2", "yargs": "^12.0.5", "ziggy-js": "^1.8.1" }, diff --git a/resources/js/components/block-button.tsx b/resources/js/components/block-button.tsx index c78dae12b3d..cb77db04e52 100644 --- a/resources/js/components/block-button.tsx +++ b/resources/js/components/block-button.tsx @@ -120,7 +120,6 @@ export default class BlockButton extends React.Component { if (core.currentUser != null) { core.currentUser.blocks = data.filter((d) => d.relation_type === 'block'); core.currentUser.friends = data.filter((d) => d.relation_type === 'friend'); - $.publish('user:update', core.currentUser); } this.props.onClick?.(); diff --git a/resources/js/components/follow-toggle.tsx b/resources/js/components/follow-toggle.tsx index 72a9f6cfe42..7283a5b14af 100644 --- a/resources/js/components/follow-toggle.tsx +++ b/resources/js/components/follow-toggle.tsx @@ -20,6 +20,7 @@ interface State { toggling: boolean; } +// TODO: mobx and turn to observer export default class FollowToggle extends React.PureComponent { static defaultProps = { following: true, @@ -81,10 +82,7 @@ export default class FollowToggle extends React.PureComponent { this.toggleXhr = $.ajax(route('follows.store'), { data: params, method }) .done(() => { if (this.props.follow.subtype === 'mapping') { - $.publish('user:followUserMapping:update', { - following: !this.state.following, - userId: this.props.follow.notifiable_id, - }); + core.currentUserModel.updateFollowUserMapping(!this.state.following, this.props.follow.notifiable_id); } else { this.setState({ following: !this.state.following }); } @@ -97,7 +95,7 @@ export default class FollowToggle extends React.PureComponent { private readonly refresh = () => { if (this.props.follow.subtype === 'mapping') { this.setState({ - following: core.currentUser != null && core.currentUser.follow_user_mapping.includes(this.props.follow.notifiable_id), + following: core.currentUserModel.following.has(this.props.follow.notifiable_id), }); } }; diff --git a/resources/js/components/follow-user-mapping-button.tsx b/resources/js/components/follow-user-mapping-button.tsx index 50e1a7d9fdd..2c57505baf6 100644 --- a/resources/js/components/follow-user-mapping-button.tsx +++ b/resources/js/components/follow-user-mapping-button.tsx @@ -27,6 +27,7 @@ interface State { const bn = 'user-action-button'; +// TODO: mobx and turn to observer export default class FollowUserMappingButton extends React.Component { private readonly buttonRef = React.createRef(); private readonly eventId = `follow-user-mapping-button-${nextVal()}`; @@ -35,7 +36,7 @@ export default class FollowUserMappingButton extends React.Component { this.setState({ - following: core.currentUser?.follow_user_mapping.includes(this.props.userId) ?? false, + following: core.currentUserModel.following.has(this.props.userId), }); }; @@ -152,6 +153,6 @@ export default class FollowUserMappingButton extends React.Component { - $.publish('user:followUserMapping:update', { following: !this.state.following, userId: this.props.userId }); + core.currentUserModel.updateFollowUserMapping(!this.state.following, this.props.userId); }; } diff --git a/resources/js/components/friend-button.tsx b/resources/js/components/friend-button.tsx index e099db01bc3..701b9fe3af1 100644 --- a/resources/js/components/friend-button.tsx +++ b/resources/js/components/friend-button.tsx @@ -188,7 +188,6 @@ export default class FriendButton extends React.Component { // TODO: move logic to a user object? core.currentUser.friends = data; - $.publish('user:update', core.currentUser); dispatch(new FriendUpdated(this.props.userId)); }; } diff --git a/resources/js/core-legacy/account-edit-blocklist.coffee b/resources/js/core-legacy/account-edit-blocklist.coffee deleted file mode 100644 index 986245271a1..00000000000 --- a/resources/js/core-legacy/account-edit-blocklist.coffee +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -# See the LICENCE file in the repository root for full licence text. - -import { trans } from 'utils/lang' - -export default class AccountEditBlocklist - element: 'block-list__content' - jsClass: '.js-account-edit-blocklist' - - constructor: -> - $(document).on 'click', @jsClass, @toggle - $.subscribe 'user:update', @updateBlockCount - - - updateBlockCount: => - return unless currentUser.id? - - $("#{@jsClass}-count").text trans('users.blocks.blocked_count', count: currentUser.blocks?.length ? 0) - - - toggle: (e) => - e.preventDefault() - - $(".#{@element}").toggleClass('hidden') - - label = - if $(".#{@element}").hasClass('hidden') - trans 'common.buttons.show' - else - trans 'common.buttons.hide' - - $(@jsClass).text label diff --git a/resources/js/core-legacy/current-user-observer.coffee b/resources/js/core-legacy/current-user-observer.coffee deleted file mode 100644 index e8be4f0ed38..00000000000 --- a/resources/js/core-legacy/current-user-observer.coffee +++ /dev/null @@ -1,63 +0,0 @@ -# Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -# See the LICENCE file in the repository root for full licence text. - -import { pull } from 'lodash' -import { reaction } from 'mobx' -import core from 'osu-core-singleton' -import { urlPresence } from 'utils/css' - -export default class CurrentUserObserver - constructor: -> - @covers = document.getElementsByClassName('js-current-user-cover') - @avatars = document.getElementsByClassName('js-current-user-avatar') - - $.subscribe 'user:update', @setData - $(document).on 'turbolinks:load', @reinit - $.subscribe 'user:followUserMapping:update', @updateFollowUserMapping - - # one time setup to monitor cover url variable. No disposer because nothing destroys this object. - $ => - reaction((=> core.currentUser?.cover.url), @setCovers) - - - reinit: => - @setAvatars() - @setSentryUser() - - - setAvatars: (elements) => - elements ?= @avatars - - bgImage = urlPresence(currentUser.avatar_url) if currentUser.id? - for el in elements - el.style.backgroundImage = bgImage - - - setCovers: => - bgImage = urlPresence(core.currentUser.cover.url) if core.currentUser? - for el in @covers - el.style.backgroundImage = bgImage - - - setData: (_e, data) => - window.currentUser = data - - @reinit() - - - setSentryUser: -> - return unless Sentry? - - Sentry.configureScope (scope) -> - scope.setUser id: currentUser.id, username: currentUser.username - - - updateFollowUserMapping: (_e, data) => - if data.following - currentUser.follow_user_mapping.push(data.userId) - else - pull(currentUser.follow_user_mapping, data.userId) - - core.currentUser.follow_user_mapping = currentUser.follow_user_mapping - - $.publish 'user:followUserMapping:refresh' diff --git a/resources/js/core-legacy/sticky-footer.coffee b/resources/js/core-legacy/sticky-footer.coffee deleted file mode 100644 index b0a10a32db7..00000000000 --- a/resources/js/core-legacy/sticky-footer.coffee +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -# See the LICENCE file in the repository root for full licence text. - -# How to use: -# 1. create a marker on when it should be fixed, with class including -# 'js-sticky-footer' and data attribute 'data-sticky-footer-target' -# 2. subscribe to 'stickyFooter' event -# 3. in the function, check if second parameter (first one is unused event -# object) is the correct target -# 4. stick if matches, unstick otherwise -export default class StickyFooter - constructor: -> - @stickMarker = document.getElementsByClassName('js-sticky-footer') - @permanentFixedFooter = document.getElementsByClassName('js-permanent-fixed-footer') - @throttledStickOrUnstick = _.throttle @stickOrUnstick, 100 - - $(window).on 'scroll resize', @stickOrUnstick - $.subscribe 'stickyFooter:check', @throttledStickOrUnstick - $(document).on 'turbolinks:load', @throttledStickOrUnstick - - - stickOrUnstick: => - return if @stickMarker.length == 0 - - bottom = window.innerHeight - @permanentFixedFooter[0].offsetHeight - - for marker in @stickMarker - continue if marker.getAttribute('data-sticky-footer-disabled') == '1' - - if marker.getBoundingClientRect().top >= bottom - $.publish 'stickyFooter', marker.getAttribute('data-sticky-footer-target') - return - - $.publish 'stickyFooter' - - - markerCheckEnabled: (el) -> - el.getAttribute('data-sticky-footer-disabled') == '1' - - - markerDisable: (el) -> - el.setAttribute('data-sticky-footer-disabled', '1') - - - markerEnable: (el) -> - el.setAttribute('data-sticky-footer-disabled', '') diff --git a/resources/js/core/account-edit-blocklist.ts b/resources/js/core/account-edit-blocklist.ts new file mode 100644 index 00000000000..7bc6ee29c8a --- /dev/null +++ b/resources/js/core/account-edit-blocklist.ts @@ -0,0 +1,35 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import { reaction } from 'mobx'; +import OsuCore from 'osu-core'; +import { trans } from 'utils/lang'; + +const contentClass = '.js-account-edit-blocklist-content'; +const countClass = '.js-account-edit-blocklist-count'; +const labelClass = '.js-account-edit-blocklist'; + +export default class AccountEditBlocklist { + constructor(private readonly core: OsuCore) { + $(document).on('click', labelClass, this.toggle); + $(() => reaction(() => this.core.currentUser?.blocks, this.updateBlockCount)); + } + + private readonly toggle = (e: JQuery.ClickEvent) => { + e.preventDefault(); + + $(contentClass).toggleClass('hidden'); + + const label = $(contentClass).hasClass('hidden') + ? trans('common.buttons.show') + : trans('common.buttons.hide'); + + $(labelClass).text(label); + }; + + private readonly updateBlockCount = () => { + if (this.core.currentUser == null) return; + + $(countClass).text(trans('users.blocks.blocked_count', { count: this.core.currentUser.blocks.length ?? 0 })); + }; +} diff --git a/resources/js/core/current-user-observer.ts b/resources/js/core/current-user-observer.ts new file mode 100644 index 00000000000..080308615bb --- /dev/null +++ b/resources/js/core/current-user-observer.ts @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import { reaction } from 'mobx'; +import OsuCore from 'osu-core'; +import { urlPresence } from 'utils/css'; + +export default class CurrentUserObserver { + private readonly avatars = document.getElementsByClassName('js-current-user-avatar'); + private readonly covers = document.getElementsByClassName('js-current-user-cover'); + + constructor(private readonly core: OsuCore) { + $(document).on('turbolinks:load', this.setAvatars); + + // one time setup to monitor user url variables. No disposer because nothing destroys this object. + $(() => reaction(() => this.core.currentUser?.avatar_url, this.setAvatars)); + $(() => reaction(() => this.core.currentUser?.cover.url, this.setCovers)); + } + + private readonly setAvatars = () => { + const bgImage = urlPresence(this.core.currentUser?.avatar_url) ?? ''; + for (const el of this.avatars) { + if (el instanceof HTMLElement) { + el.style.backgroundImage = bgImage; + } + } + }; + + private readonly setCovers = () => { + const bgImage = urlPresence(this.core.currentUser?.cover.url) ?? ''; + for (const el of this.covers) { + if (el instanceof HTMLElement) { + el.style.backgroundImage = bgImage; + } + } + }; +} diff --git a/resources/js/core/sticky-footer.ts b/resources/js/core/sticky-footer.ts new file mode 100644 index 00000000000..d9c7330aa1d --- /dev/null +++ b/resources/js/core/sticky-footer.ts @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import { throttle } from 'lodash'; + +// How to use: +// 1. create a marker on when it should be fixed, with class including +// 'js-sticky-footer' and data attribute 'data-sticky-footer-target' +// 2. subscribe to 'stickyFooter' event +// 3. in the function, check if second parameter (first one is unused event +// object) is the correct target +// 4. stick if matches, unstick otherwise +export default class StickyFooter { + private readonly permanentFixedFooter = document.getElementsByClassName('js-permanent-fixed-footer'); + private readonly stickMarker = document.getElementsByClassName('js-sticky-footer'); + private readonly throttledStickOrUnstick; + + constructor() { + this.throttledStickOrUnstick = throttle(this.stickOrUnstick, 100); + + $(window).on('scroll resize', this.stickOrUnstick); + $.subscribe('stickyFooter:check', this.throttledStickOrUnstick); + $(document).on('turbolinks:load', this.throttledStickOrUnstick); + } + + markerDisable(el: HTMLElement) { + el.dataset.stickyFooterDisabled = '1'; + } + + markerEnable(el: HTMLElement) { + el.dataset.stickyFooterDisabled = ''; + } + + private readonly stickOrUnstick = () => { + if (this.stickMarker.length === 0) return; + + const footer = this.permanentFixedFooter[0]; + if (!(footer instanceof HTMLElement)) return; + + const bottom = window.innerHeight - footer.offsetHeight; + + for (const marker of this.stickMarker) { + if (marker instanceof HTMLElement) { + if (marker.dataset.stickyFooterDisabled === '1') continue; + + if (marker.getBoundingClientRect().top >= bottom) { + $.publish('stickyFooter', marker.dataset.stickyFooterTarget); + return; + } + } + } + + $.publish('stickyFooter'); + }; +} diff --git a/resources/js/core/user/user-model.ts b/resources/js/core/user/user-model.ts index a1a192eacc7..9756568361d 100644 --- a/resources/js/core/user/user-model.ts +++ b/resources/js/core/user/user-model.ts @@ -1,7 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. -import { computed, makeObservable } from 'mobx'; +import { pull } from 'lodash'; +import { action, computed, makeObservable } from 'mobx'; import OsuCore from 'osu-core'; export default class UserModel { @@ -14,6 +15,15 @@ export default class UserModel { return new Set(this.core.currentUser.blocks.map((b) => b.target_id)); } + @computed + get following() { + if (this.core.currentUser == null) { + return new Set(); + } + + return new Set(this.core.currentUser.follow_user_mapping); + } + @computed get friends() { if (this.core.currentUser == null) { @@ -30,4 +40,18 @@ export default class UserModel { isFriendWith(id: number) { return this.friends.get(id) != null; } + + @action + updateFollowUserMapping(following: boolean, userId: number) { + const currentUser = this.core.currentUser; + if (currentUser == null) return; + + if (following) { + currentUser.follow_user_mapping.push(userId); + } else { + pull(currentUser.follow_user_mapping, userId); + } + + $.publish('user:followUserMapping:refresh'); + } } diff --git a/resources/js/interfaces/solo-score-json.ts b/resources/js/interfaces/solo-score-json.ts index 0a1293516cf..b67404e200e 100644 --- a/resources/js/interfaces/solo-score-json.ts +++ b/resources/js/interfaces/solo-score-json.ts @@ -41,6 +41,7 @@ type SoloScoreJsonDefaultAttributes = { passed: boolean; pp: number | null; preserve?: boolean; + processed?: boolean; rank: Rank; ranked?: boolean; ruleset_id: number; diff --git a/resources/js/main.coffee b/resources/js/main.coffee index cfea9eb5c31..648567c5cd0 100644 --- a/resources/js/main.coffee +++ b/resources/js/main.coffee @@ -2,13 +2,11 @@ # See the LICENCE file in the repository root for full licence text. import AccountEditAvatar from 'core-legacy/account-edit-avatar' -import AccountEditBlocklist from 'core-legacy/account-edit-blocklist' import AccountEdit from 'core-legacy/account-edit' import BbcodePreview from 'core-legacy/bbcode-preview' import BeatmapPack from 'core-legacy/beatmap-pack' import ChangelogChartLoader from 'core-legacy/changelog-chart-loader' import CheckboxValidation from 'core-legacy/checkbox-validation' -import CurrentUserObserver from 'core-legacy/current-user-observer' import FancyGraph from 'core-legacy/fancy-graph' import FormClear from 'core-legacy/form-clear' import FormConfirmation from 'core-legacy/form-confirmation' @@ -30,7 +28,6 @@ import NavButton from 'core-legacy/nav-button' import Nav2 from 'core-legacy/nav2' import PostPreview from 'core-legacy/post-preview' import Search from 'core-legacy/search' -import StickyFooter from 'core-legacy/sticky-footer' import { StoreCheckout } from 'core-legacy/store-checkout' import TooltipDefault from 'core-legacy/tooltip-default' import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay' @@ -60,12 +57,8 @@ $(document).on 'turbolinks:load', -> BeatmapPack.initialize() StoreCheckout.initialize() -# ensure currentUser is updated early enough. -window.currentUserObserver ?= new CurrentUserObserver - window.accountEdit ?= new AccountEdit window.accountEditAvatar ?= new AccountEditAvatar -window.accountEditBlocklist ?= new AccountEditBlocklist window.bbcodePreview ?= new BbcodePreview window.changelogChartLoader ?= new ChangelogChartLoader window.checkboxValidation ?= new CheckboxValidation @@ -85,13 +78,12 @@ window.menu ?= new Menu window.navButton ?= new NavButton window.postPreview ?= new PostPreview window.search ?= new Search -window.stickyFooter ?= new StickyFooter window.tooltipDefault ?= new TooltipDefault window.formConfirmation ?= new FormConfirmation(window.formError) window.forumPostsSeek ?= new ForumPostsSeek(window.forum) window.forumTopicPostJump ?= new ForumTopicPostJump(window.forum) -window.forumTopicReply ?= new ForumTopicReply(bbcodePreview: window.bbcodePreview, forum: window.forum, stickyFooter: window.stickyFooter) +window.forumTopicReply ?= new ForumTopicReply(bbcodePreview: window.bbcodePreview, forum: window.forum, stickyFooter: osuCore.stickyFooter) window.nav2 ?= new Nav2(osuCore.clickMenu) diff --git a/resources/js/osu-core.ts b/resources/js/osu-core.ts index cc3ca0cdcaf..0a6a9dc5cd8 100644 --- a/resources/js/osu-core.ts +++ b/resources/js/osu-core.ts @@ -3,9 +3,11 @@ import { BeatmapsetSearchController } from 'beatmaps/beatmapset-search-controller'; import ChatWorker from 'chat/chat-worker'; +import AccountEditBlocklist from 'core/account-edit-blocklist'; import BrowserTitleWithNotificationCount from 'core/browser-title-with-notification-count'; import Captcha from 'core/captcha'; import ClickMenu from 'core/click-menu'; +import CurrentUserObserver from 'core/current-user-observer'; import Enchant from 'core/enchant'; import FixRelativeLink from 'core/fix-relative-link'; import ForumPoll from 'core/forum/forum-poll'; @@ -16,6 +18,7 @@ import Localtime from 'core/localtime'; import MobileToggle from 'core/mobile-toggle'; import OsuAudio from 'core/osu-audio/main'; import ReactTurbolinks from 'core/react-turbolinks'; +import StickyFooter from 'core/sticky-footer'; import StickyHeader from 'core/sticky-header'; import SyncHeight from 'core/sync-height'; import Timeago from 'core/timeago'; @@ -39,6 +42,7 @@ import { parseJsonNullable } from 'utils/json'; // will this replace main.coffee eventually? export default class OsuCore { + readonly accountEditBlocklist; readonly beatmapsetSearchController; readonly browserTitleWithNotificationCount; readonly captcha; @@ -46,6 +50,7 @@ export default class OsuCore { readonly clickMenu; @observable currentUser?: CurrentUserJson; readonly currentUserModel; + readonly currentUserObserver; readonly dataStore; readonly enchant; readonly fixRelativeLink; @@ -61,6 +66,7 @@ export default class OsuCore { readonly referenceLinkTooltip; readonly scorePins; readonly socketWorker; + readonly stickyFooter; readonly stickyHeader; readonly syncHeight; readonly timeago; @@ -95,6 +101,7 @@ export default class OsuCore { this.captcha = new Captcha(); this.chatWorker = new ChatWorker(); this.clickMenu = new ClickMenu(); + this.currentUserObserver = new CurrentUserObserver(this); this.currentUserModel = new UserModel(this); this.fixRelativeLink = new FixRelativeLink(); this.forumPoll = new ForumPoll(); @@ -106,6 +113,7 @@ export default class OsuCore { this.browserTitleWithNotificationCount = new BrowserTitleWithNotificationCount(this); this.referenceLinkTooltip = new ReferenceLinkTooltip(); this.scorePins = new ScorePins(); + this.stickyFooter = new StickyFooter(); this.stickyHeader = new StickyHeader(); this.syncHeight = new SyncHeight(); this.timeago = new Timeago(); @@ -123,6 +131,7 @@ export default class OsuCore { // should probably figure how to conditionally or lazy initialize these so they don't all init when not needed. // TODO: requires dynamic imports to lazy load modules. this.dataStore = new RootDataStore(); + this.accountEditBlocklist = new AccountEditBlocklist(this); this.userLoginObserver = new UserLoginObserver(); this.windowFocusObserver = new WindowFocusObserver(); @@ -143,8 +152,7 @@ export default class OsuCore { const currentUser = parseJsonNullable('json-current-user', true); if (currentUser != null) { - window.currentUser = currentUser; - this.setCurrentUser(window.currentUser); + this.setCurrentUser(currentUser); } }; @@ -161,6 +169,7 @@ export default class OsuCore { } this.socketWorker.setUserId(user?.id ?? null); this.currentUser = user; + window.currentUser = userOrEmpty; this.userPreferences.setUser(this.currentUser); }; } diff --git a/resources/js/scores/pp-value.tsx b/resources/js/scores/pp-value.tsx index 395c203eb72..efa5bef527a 100644 --- a/resources/js/scores/pp-value.tsx +++ b/resources/js/scores/pp-value.tsx @@ -21,12 +21,12 @@ export default function PpValue(props: Props) { if (!isBest && !isSolo) { title = trans('scores.status.non_best'); content = '-'; - } else if (props.score.ranked === false) { + } else if (props.score.ranked === false || props.score.preserve === false) { title = trans('scores.status.no_pp'); content = '-'; } else if (props.score.pp == null) { - if (isSolo && !props.score.passed) { - title = trans('scores.status.non_passing'); + if (isSolo && props.score.processed === true) { + title = trans('scores.status.no_pp'); content = '-'; } else { title = trans('scores.status.processing'); diff --git a/resources/lang/en/accounts.php b/resources/lang/en/accounts.php index 3d70df0bed5..7a62dd5d1c2 100644 --- a/resources/lang/en/accounts.php +++ b/resources/lang/en/accounts.php @@ -10,8 +10,8 @@ 'avatar' => [ 'title' => 'Avatar', - 'rules' => 'Please ensure your avatar adheres to :link.
This means it must be suitable for all ages. i.e. no nudity, profanity or suggestive content.', - 'rules_link' => 'the community rules', + 'rules' => 'Please ensure your avatar adheres to :link.
This means it must be suitable for all ages. i.e. no nudity, offensive or suggestive content.', + 'rules_link' => 'the Visual content considerations', ], 'email' => [ diff --git a/resources/lang/en/models.php b/resources/lang/en/models.php new file mode 100644 index 00000000000..625d0bfc3fb --- /dev/null +++ b/resources/lang/en/models.php @@ -0,0 +1,13 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +return [ + 'not_found' => "Specified :model couldn't be found.", + + 'name' => [ + 'App\Models\Beatmap' => 'beatmap difficulty', + 'App\Models\Beatmapset' => 'beatmap', + ], +]; diff --git a/resources/lang/en/scores.php b/resources/lang/en/scores.php index 8ff75f71411..fb385b3dd30 100644 --- a/resources/lang/en/scores.php +++ b/resources/lang/en/scores.php @@ -25,7 +25,6 @@ 'status' => [ 'non_best' => 'Only personal best scores award pp', - 'non_passing' => 'Only passing scores award pp', 'no_pp' => 'pp is not awarded for this score', 'processing' => 'This score is still being calculated and will be displayed soon', 'no_rank' => 'This score has no rank as it is unranked or marked for deletion', diff --git a/resources/views/accounts/_edit_privacy.blade.php b/resources/views/accounts/_edit_privacy.blade.php index d89ca662ada..f7ef54d5b4c 100644 --- a/resources/views/accounts/_edit_privacy.blade.php +++ b/resources/views/accounts/_edit_privacy.blade.php @@ -63,7 +63,7 @@ class="account-edit-entry account-edit-entry--no-label js-account-edit"
-