the browser-facing portion of osu!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'master' into feature/chat-remove-first-message-id

authored by

Dean Herbert and committed by
GitHub
7e81970a f2dba5d1

+410 -110
+2
.env.example
··· 218 218 # USER_MAX_MULTIPLAYER_ROOMS=1 219 219 # USER_MAX_MULTIPLAYER_ROOMS_SUPPORTER=5 220 220 221 + # MULTIPLAYER_MAX_ATTEMPTS_LIMIT=128 222 + 221 223 # NOTIFICATION_QUEUE=notification 222 224 # NOTIFICATION_REDIS_HOST=127.0.0.1 223 225 # NOTIFICATION_REDIS_PORT=6379
+1
app/Http/Controllers/AccountController.php
··· 210 210 'beatmapset_download:string', 211 211 'beatmapset_show_nsfw:bool', 212 212 'beatmapset_title_show_original:bool', 213 + 'comments_show_deleted:bool', 213 214 'comments_sort:string', 214 215 'extras_order:string[]', 215 216 'forum_posts_show_deleted:bool',
-2
app/Http/Controllers/BeatmapsetsController.php
··· 60 60 return $set; 61 61 } else { 62 62 $commentBundle = CommentBundle::forEmbed($beatmapset); 63 - $hasDiscussion = $beatmapset->discussion_enabled; 64 63 65 64 if (priv_check('BeatmapsetMetadataEdit', $beatmapset)->can()) { 66 65 $genres = Genre::listing(); ··· 76 75 'beatmapset', 77 76 'commentBundle', 78 77 'genres', 79 - 'hasDiscussion', 80 78 'languages', 81 79 'noindex', 82 80 'set'
+3 -2
app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php
··· 170 170 $clientHash = md5($clientHash); 171 171 } 172 172 173 - Build::where([ 173 + $buildExists = Build::where([ 174 174 'hash' => hex2bin($clientHash), 175 175 'allow_ranking' => true, 176 - ])->firstOrFail(); 176 + ])->exists(); 177 + abort_if(!$buildExists, 422, 'invalid client hash'); 177 178 } 178 179 179 180 $score = $room->startPlay($user, $playlistItem);
+20 -12
app/Http/Controllers/NewsController.php
··· 34 34 35 35 $postsJson = [ 36 36 'news_posts' => json_collection($posts, 'NewsPost', ['preview']), 37 + 'news_sidebar' => $this->sidebarMeta($posts[0] ?? null), 37 38 'search' => $search['params'], 38 39 'cursor' => $search['cursorHelper']->next($posts), 39 40 ]; ··· 47 48 'title' => 'osu!news Feed', 48 49 ], 49 50 'postsJson' => $postsJson, 50 - 'sidebarMeta' => $this->sidebarMeta($posts[0] ?? null), 51 51 ]); 52 52 } 53 53 } ··· 99 99 } 100 100 101 101 $currentYear = $currentYear ?? date('Y'); 102 + $latestPost = NewsPost::select('updated_at')->default()->first(); 103 + $lastUpdate = $latestPost === null ? 0 : $latestPost->updated_at->timestamp; 102 104 103 - $years = NewsPost::selectRaw('DISTINCT YEAR(published_at) year') 104 - ->whereNotNull('published_at') 105 - ->orderBy('year', 'DESC') 106 - ->pluck('year') 107 - ->toArray(); 105 + return cache_remember_with_fallback( 106 + "news_sidebar_meta_{$currentYear}_{$lastUpdate}", 107 + 3600, 108 + function () use ($currentYear) { 109 + $years = NewsPost::selectRaw('DISTINCT YEAR(published_at) year') 110 + ->whereNotNull('published_at') 111 + ->orderBy('year', 'DESC') 112 + ->pluck('year') 113 + ->toArray(); 108 114 109 - $posts = NewsPost::default()->year($currentYear)->get(); 115 + $posts = NewsPost::default()->year($currentYear)->get(); 110 116 111 - return [ 112 - 'current_year' => $currentYear, 113 - 'news_posts' => json_collection($posts, 'NewsPost'), 114 - 'years' => $years, 115 - ]; 117 + return [ 118 + 'current_year' => $currentYear, 119 + 'news_posts' => json_collection($posts, 'NewsPost'), 120 + 'years' => $years, 121 + ]; 122 + } 123 + ); 116 124 } 117 125 }
+1 -1
app/Jobs/Notifications/UserBeatmapsetNew.php
··· 12 12 13 13 class UserBeatmapsetNew extends BroadcastNotificationBase 14 14 { 15 - const NOTIFICATION_OPTION_NAME = UserNotificationOption::BEATMAPSET_MODDING; 15 + const NOTIFICATION_OPTION_NAME = UserNotificationOption::MAPPING; 16 16 17 17 protected $beatmapset; 18 18
+1 -1
app/Libraries/Markdown/OsuMarkdownProcessor.php
··· 292 292 return; 293 293 } 294 294 295 - $this->firstImage = $this->node->getUrl(); 295 + $this->firstImage = proxy_media($this->node->getUrl()); 296 296 } 297 297 298 298 public function setTitle()
+1
app/Libraries/Multiplayer/Mod.php
··· 142 142 'overall_difficulty' => 'float', 143 143 'circle_size' => 'float', 144 144 'approach_rate' => 'float', 145 + 'scroll_speed' => 'float', 145 146 ], 146 147 self::DOUBLETIME => [ 147 148 'speed_change' => 'float',
+1 -3
app/Libraries/UserRegistration.php
··· 6 6 namespace App\Libraries; 7 7 8 8 use App\Exceptions\ValidationException; 9 - use App\Jobs\EsIndexDocument; 10 9 use App\Models\User; 11 10 use Carbon\Carbon; 12 11 use Datadog; ··· 56 55 try { 57 56 $this->user->getConnection()->transaction(function () { 58 57 User::findAndRenameUserForInactive($this->user->username); 58 + $this->user->shouldReindex = true; 59 59 if (!$this->user->save()) { 60 60 // probably failed because of validation 61 61 throw new ValidationException($this->user->validationErrors()); ··· 65 65 66 66 Datadog::increment('osu.new_account_registrations', 1, ['source' => 'osu-web']); 67 67 }); 68 - 69 - dispatch(new EsIndexDocument($this->user)); 70 68 } catch (Exception $e) { 71 69 if (is_sql_unique_exception($e)) { 72 70 $this->user->validationErrors()->add('username', '.unknown_duplicate');
+20 -4
app/Models/Multiplayer/PlaylistItem.php
··· 52 52 { 53 53 $obj = new self(); 54 54 foreach (['beatmap_id', 'ruleset_id'] as $field) { 55 - $obj->$field = array_get($json, $field); 56 - if (!present($obj->$field)) { 55 + $value = get_int($json[$field] ?? null); 56 + if ($value === null) { 57 57 throw new InvariantException("{$field} is required."); 58 58 } 59 + $obj->$field = $value; 59 60 } 60 61 62 + $obj->max_attempts = get_int($json['max_attempts'] ?? null); 63 + 61 64 $obj->allowed_mods = Mod::parseInputArray( 62 - array_get($json, 'allowed_mods') ?? [], 65 + $json['allowed_mods'] ?? [], 63 66 $obj->ruleset_id 64 67 ); 65 68 66 69 $obj->required_mods = Mod::parseInputArray( 67 - array_get($json, 'required_mods') ?? [], 70 + $json['required_mods'] ?? [], 68 71 $obj->ruleset_id 69 72 ); 70 73 ··· 99 102 ->orderBy('score_id', 'asc'); 100 103 } 101 104 105 + private function assertValidMaxAttempts() 106 + { 107 + if ($this->max_attempts === null) { 108 + return; 109 + } 110 + 111 + $maxAttemptsLimit = config('osu.multiplayer.max_attempts_limit'); 112 + if ($this->max_attempts < 1 || $this->max_attempts > $maxAttemptsLimit) { 113 + throw new InvariantException("field 'max_attempts' must be between 1 and {$maxAttemptsLimit}"); 114 + } 115 + } 116 + 102 117 private function validateRuleset() 103 118 { 104 119 // osu beatmaps can be played in any mode, but non-osu maps can only be played in their specific modes ··· 121 136 122 137 public function save(array $options = []) 123 138 { 139 + $this->assertValidMaxAttempts(); 124 140 $this->validateRuleset(); 125 141 $this->validateModOverlaps(); 126 142 Mod::validateSelection(array_column($this->allowed_mods, 'acronym'), $this->ruleset_id, true);
+24 -12
app/Models/Multiplayer/Room.php
··· 205 205 throw new InvariantException('number of simultaneously active rooms reached'); 206 206 } 207 207 208 - $this->name = $params['name'] ?? null; 208 + $this->name = get_string($params['name'] ?? null); 209 209 $this->user_id = $owner->getKey(); 210 210 $this->max_attempts = get_int($params['max_attempts'] ?? null); 211 211 $this->starts_at = now(); ··· 215 215 $this->category = $category; 216 216 $this->ends_at = now()->addSeconds(30); 217 217 } else { 218 - if ($params['ends_at'] ?? null !== null) { 219 - $this->ends_at = Carbon::parse($params['ends_at']); 220 - } elseif ($params['duration'] ?? null !== null) { 221 - $this->ends_at = $this->starts_at->copy()->addMinutes(get_int($params['duration'])); 218 + $endsAt = parse_time_to_carbon($params['ends_at'] ?? null); 219 + if ($endsAt !== null) { 220 + $this->ends_at = $endsAt; 221 + } else { 222 + $duration = get_int($params['duration'] ?? null); 223 + if ($duration !== null) { 224 + $this->ends_at = $this->starts_at->copy()->addMinutes($duration); 225 + } 222 226 } 223 227 } 224 228 ··· 323 327 } 324 328 325 329 if ($this->max_attempts !== null) { 326 - if ($this->max_attempts < 1 || $this->max_attempts > 32) { 327 - throw new InvariantException("field 'max_attempts' must be between 1 and 32"); 330 + $maxAttemptsLimit = config('osu.multiplayer.max_attempts_limit'); 331 + if ($this->max_attempts < 1 || $this->max_attempts > $maxAttemptsLimit) { 332 + throw new InvariantException("field 'max_attempts' must be between 1 and {$maxAttemptsLimit}"); 328 333 } 329 334 } 330 335 } ··· 337 342 throw new InvariantException('Room has already ended.'); 338 343 } 339 344 340 - if ( 341 - $this->max_attempts !== null 342 - && $playlistItem->scores()->where('user_id', $user->getKey())->count() >= $this->max_attempts 343 - ) { 344 - throw new InvariantException('You have reached the maximum number of tries allowed.'); 345 + if ($this->max_attempts !== null) { 346 + $roomStats = $this->userHighScores()->where('user_id', $user->getKey())->first(); 347 + if ($roomStats !== null && $roomStats->attempts >= $this->max_attempts) { 348 + throw new InvariantException('You have reached the maximum number of tries allowed.'); 349 + } 350 + } 351 + 352 + if ($playlistItem->max_attempts !== null) { 353 + $playlistAttempts = $playlistItem->scores()->where('user_id', $user->getKey())->count(); 354 + if ($playlistAttempts >= $playlistItem->max_attempts) { 355 + throw new InvariantException('You have reached the maximum number of tries allowed.'); 356 + } 345 357 } 346 358 } 347 359 }
+45 -7
app/Models/User.php
··· 11 11 use App\Libraries\BBCodeForDB; 12 12 use App\Libraries\ChangeUsername; 13 13 use App\Libraries\Elasticsearch\Indexable; 14 + use App\Libraries\Transactions\AfterCommit; 14 15 use App\Libraries\User\DatadogLoginAttempt; 15 16 use App\Libraries\UsernameValidation; 16 17 use App\Models\Forum\TopicWatch; ··· 28 29 use Illuminate\Auth\Authenticatable; 29 30 use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; 30 31 use Illuminate\Contracts\Translation\HasLocalePreference; 31 - use Illuminate\Database\QueryException as QueryException; 32 + use Illuminate\Database\QueryException; 32 33 use Laravel\Passport\HasApiTokens; 33 34 use Request; 34 35 ··· 167 168 * @property string|null $username_previous 168 169 * @property int|null $userpage_post_id 169 170 */ 170 - class User extends Model implements AuthenticatableContract, HasLocalePreference, Indexable 171 + class User extends Model implements AfterCommit, AuthenticatableContract, HasLocalePreference, Indexable 171 172 { 172 173 use Elasticsearch\UserTrait, Store\UserTrait; 173 174 use Authenticatable, HasApiTokens, Memoizes, Reportable, UserAvatar, UserScoreable, Validatable; ··· 220 221 'user_website' => 200, 221 222 ]; 222 223 224 + public $shouldReindex = false; 225 + 223 226 private $validateCurrentPassword = false; 224 227 private $validatePasswordConfirmation = false; 225 228 public $password = null; ··· 338 341 339 342 $skipValidations = in_array($type, ['inactive', 'revert'], true); 340 343 $this->saveOrExplode(['skipValidations' => $skipValidations]); 341 - dispatch(new EsIndexDocument($this)); 344 + $this->shouldReindex = true; 342 345 343 346 return $history; 344 347 }); ··· 1695 1698 public function recommendedStarDifficulty(string $mode) 1696 1699 { 1697 1700 $stats = $this->statistics($mode); 1698 - if ($stats) { 1699 - return pow($stats->rank_score, 0.4) * 0.195; 1700 - } 1701 + 1702 + return UserStatistics\Model::calculateRecommendedStarDifficulty($stats); 1703 + } 1704 + 1705 + /** 1706 + * Recommended star difficulty for all modes. 1707 + * 1708 + * @return float 1709 + */ 1710 + public function recommendedStarDifficultyAll() 1711 + { 1712 + return $this->memoize(__FUNCTION__, function () { 1713 + $unionQuery = null; 1714 + 1715 + foreach (Beatmap::MODES as $key => $_value) { 1716 + $query = $this->statistics($key, true)->selectRaw("'{$key}' AS game_mode, rank_score"); 1717 + 1718 + if ($unionQuery === null) { 1719 + $unionQuery = $query; 1720 + } else { 1721 + $unionQuery->unionAll($query); 1722 + } 1723 + } 1724 + 1725 + $stats = $unionQuery->get()->keyBy('game_mode'); 1726 + 1727 + foreach (Beatmap::MODES as $key => $_value) { 1728 + $recs[$key] = UserStatistics\Model::calculateRecommendedStarDifficulty($stats[$key] ?? null); 1729 + } 1701 1730 1702 - return 1.0; 1731 + return $recs; 1732 + }); 1703 1733 } 1704 1734 1705 1735 public function refreshForumCache($forum = null, $postsChangeCount = 0) ··· 2132 2162 } 2133 2163 2134 2164 return $this->isValid() && parent::save($options); 2165 + } 2166 + 2167 + public function afterCommit() 2168 + { 2169 + if ($this->shouldReindex) { 2170 + $this->shouldReindex = false; 2171 + dispatch(new EsIndexDocument($this)); 2172 + } 2135 2173 } 2136 2174 2137 2175 protected function newReportableExtraParams(): array
+2
app/Models/UserNotificationOption.php
··· 28 28 const COMMENT_REPLY = 'comment_reply'; 29 29 const DELIVERY_MODES = ['mail', 'push']; 30 30 const FORUM_TOPIC_REPLY = Notification::FORUM_TOPIC_REPLY; 31 + const MAPPING = 'mapping'; 31 32 32 33 const HAS_DELIVERY_MODES = [ 34 + self::MAPPING, 33 35 self::BEATMAPSET_MODDING, 34 36 Notification::CHANNEL_MESSAGE, 35 37 Notification::COMMENT_NEW,
+14
app/Models/UserProfileCustomization.php
··· 164 164 $this->setOption('beatmapset_title_show_original', $value); 165 165 } 166 166 167 + public function setCommentsShowDeletedAttribute($value) 168 + { 169 + if (!is_bool($value)) { 170 + $value = null; 171 + } 172 + 173 + $this->setOption('comments_show_deleted', $value); 174 + } 175 + 176 + public function getCommentsShowDeletedAttribute() 177 + { 178 + return $this->getOptions()['comments_show_deleted'] ?? false; 179 + } 180 + 167 181 public function getCommentsSortAttribute() 168 182 { 169 183 return $this->getOptions()['comments_sort'] ?? Comment::DEFAULT_SORT;
+9
app/Models/UserStatistics/Model.php
··· 69 69 return $this->count300 + $this->count100 + $this->count50; 70 70 } 71 71 72 + public static function calculateRecommendedStarDifficulty(?self $stats) 73 + { 74 + if ($stats !== null && $stats->rank_score > 0) { 75 + return pow($stats->rank_score, 0.4) * 0.195; 76 + } 77 + 78 + return 1.0; 79 + } 80 + 72 81 public static function getClass($modeStr, $variant = null) 73 82 { 74 83 if (!Beatmap::isModeValid($modeStr)) {
+1
app/Transformers/UserCompactTransformer.php
··· 373 373 'beatmapset_download', 374 374 'beatmapset_show_nsfw', 375 375 'beatmapset_title_show_original', 376 + 'comments_show_deleted', 376 377 'forum_posts_show_deleted', 377 378 'ranking_expanded', 378 379 'user_list_filter',
+19 -13
app/helpers.php
··· 941 941 942 942 function proxy_media($url) 943 943 { 944 + if (!present($url)) { 945 + return ''; 946 + } 947 + 948 + $url = html_entity_decode_better($url); 949 + 950 + if (config('osu.camo.key') === null) { 951 + return $url; 952 + } 953 + 954 + $isProxied = starts_with($url, config('osu.camo.prefix')); 955 + 956 + if ($isProxied) { 957 + return $url; 958 + } 959 + 944 960 // turn relative urls into absolute urls 945 961 if (!preg_match('/^https?\:\/\//', $url)) { 946 962 // ensure url is relative to the site root ··· 950 966 $url = config('app.url').$url; 951 967 } 952 968 953 - $decoded = urldecode(html_entity_decode_better($url)); 954 969 955 - if (config('osu.camo.key') === null) { 956 - return $decoded; 957 - } 958 - 959 - $isProxied = starts_with($decoded, config('osu.camo.prefix')); 960 - if ($isProxied) { 961 - return $decoded; 962 - } 963 - 964 - $url = bin2hex($decoded); 965 - $secret = hash_hmac('sha1', $decoded, config('osu.camo.key')); 970 + $hexUrl = bin2hex($url); 971 + $secret = hash_hmac('sha1', $url, config('osu.camo.key')); 966 972 967 - return config('osu.camo.prefix')."{$secret}/{$url}"; 973 + return config('osu.camo.prefix')."{$secret}/{$hexUrl}"; 968 974 } 969 975 970 976 function lazy_load_image($url, $class = '', $alt = '')
+3
config/osu.php
··· 116 116 'legacy' => [ 117 117 'shared_interop_secret' => env('SHARED_INTEROP_SECRET', ''), 118 118 ], 119 + 'multiplayer' => [ 120 + 'max_attempts_limit' => get_int(env('MULTIPLAYER_MAX_ATTEMPTS_LIMIT')) ?? 128, 121 + ], 119 122 'notification' => [ 120 123 'endpoint' => presence(env('NOTIFICATION_ENDPOINT'), '/home/notifications/feed'), 121 124 'queue_name' => presence(env('NOTIFICATION_QUEUE'), 'notification'),
+35
database/migrations/2021_02_03_120003_add_max_attempts_to_playlist_items.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + use Illuminate\Database\Migrations\Migration; 7 + use Illuminate\Database\Schema\Blueprint; 8 + use Illuminate\Support\Facades\Schema; 9 + 10 + class AddMaxAttemptsToPlaylistItems extends Migration 11 + { 12 + /** 13 + * Run the migrations. 14 + * 15 + * @return void 16 + */ 17 + public function up() 18 + { 19 + Schema::table('multiplayer_playlist_items', function (Blueprint $table) { 20 + $table->tinyInteger('max_attempts')->unsigned()->nullable()->after('required_mods'); 21 + }); 22 + } 23 + 24 + /** 25 + * Reverse the migrations. 26 + * 27 + * @return void 28 + */ 29 + public function down() 30 + { 31 + Schema::table('multiplayer_playlist_items', function (Blueprint $table) { 32 + $table->dropColumn('max_attempts'); 33 + }); 34 + } 35 + }
+33 -24
resources/assets/coffee/react/_components/comment.coffee
··· 13 13 import { ShowMoreLink } from 'show-more-link' 14 14 import { Spinner } from 'spinner' 15 15 import { UserAvatar } from 'user-avatar' 16 + import { classWithModifiers } from 'utils/css' 16 17 import { estimateMinLines } from 'utils/estimate-min-lines' 17 18 18 19 el = React.createElement ··· 105 106 106 107 @renderRepliesToggle() 107 108 @renderCommentableMeta(meta) 109 + @renderToolbar() 108 110 109 111 div 110 112 className: osu.classWithModifiers('comment__main', mainModifiers) ··· 158 160 if @props.comment.canHaveVote 159 161 div 160 162 className: 'comment__row-item visible-xs' 161 - @renderVoteText() 163 + @renderVoteButton(true) 162 164 163 165 div 164 166 className: 'comment__row-item comment__row-item--info' ··· 373 375 user.username 374 376 375 377 376 - # mobile vote button 377 - renderVoteButton: => 378 - className = osu.classWithModifiers('comment-vote', @props.modifiers) 379 - className += ' comment-vote--posting' if @state.postingVote 380 - className += ' comment-vote--disabled' if !@props.comment.canVote 378 + renderVoteButton: (inline = false) => 379 + hasVoted = @hasVoted() 380 + 381 + className = classWithModifiers 'comment-vote', @props.modifiers 382 + className += classWithModifiers 'comment-vote', 383 + disabled: !@props.comment.canVote 384 + inline: inline 385 + on: hasVoted 386 + posting: @state.postingVote 387 + true 381 388 382 - if @hasVoted() 383 - className += ' comment-vote--on' 384 - hover = null 385 - else 386 - className += ' comment-vote--off' 387 - hover = div className: 'comment-vote__hover', '+1' 389 + hover = div className: 'comment-vote__hover', '+1' if !inline && !hasVoted 388 390 389 391 button 390 392 className: className ··· 398 400 hover 399 401 400 402 401 - renderVoteText: => 402 - className = 'comment__action' 403 - className += ' comment__action--active' if @hasVoted() 404 - 405 - button 406 - className: className 407 - type: 'button' 408 - onClick: @voteToggle 409 - disabled: @state.postingVote 410 - "+#{osu.formatNumberSuffixed(@props.comment.votesCount, null, maximumFractionDigits: 1)}" 411 - 412 - 413 403 renderCommentableMeta: (meta) => 414 404 return unless @props.showCommentableMeta 415 405 ··· 430 420 osu.trans("comments.commentable_name.#{@props.comment.commentableType}") 431 421 component params, 432 422 meta.title 423 + 424 + 425 + renderToolbar: => 426 + return unless @props.showToolbar 427 + 428 + div className: 'comment__toolbar', 429 + div className: 'sort', 430 + div className: 'sort__items', 431 + button 432 + type: 'button' 433 + className: 'sort__item sort__item--button' 434 + onClick: @onShowDeletedToggleClick 435 + span className: 'sort__item-icon', 436 + span className: if uiState.comments.isShowDeleted then 'fas fa-check-square' else 'far fa-square' 437 + osu.trans('common.buttons.show_deleted') 433 438 434 439 435 440 hasVoted: => ··· 479 484 loadReplies: => 480 485 @loadMoreRef.current?.load() 481 486 @toggleReplies() 487 + 488 + 489 + onShowDeletedToggleClick: -> 490 + $.publish 'comments:toggle-show-deleted' 482 491 483 492 484 493 parentLink: (parent) =>
+1 -2
resources/assets/coffee/react/_components/comments-manager.coffee
··· 81 81 82 82 83 83 toggleShowDeleted: => 84 - runInAction () -> 85 - uiState.comments.isShowDeleted = !uiState.comments.isShowDeleted 84 + uiState.toggleShowDeletedComments() 86 85 87 86 88 87 toggleFollow: =>
+1
resources/assets/coffee/react/comments-show/main.coffee
··· 27 27 el Comment, 28 28 comment: @comment 29 29 showCommentableMeta: true 30 + showToolbar: true 30 31 depth: 0 31 32 linkParent: true 32 33 modifiers: ['dark', 'single']
+2
resources/assets/coffee/react/contest-voting/art-entry-list.coffee
··· 20 20 key: index, 21 21 displayIndex: index, 22 22 entry: entry, 23 + hideIfNotVoted: @state.showVotedOnly 23 24 waitingForResponse: @state.waitingForResponse, 24 25 options: @state.options, 25 26 contest: @state.contest, ··· 32 33 33 34 div className: 'contest__art-list', 34 35 div className: 'contest__vote-summary--art', 36 + @renderToggleShowVotedOnly() 35 37 span className: 'contest__vote-summary-text contest__vote-summary-text--art', 'votes' 36 38 el VoteSummary, voteCount: @state.selected.length, maxVotes: @state.contest.max_votes 37 39
+4 -1
resources/assets/coffee/react/contest-voting/art-entry.coffee
··· 8 8 9 9 export class ArtEntry extends React.Component 10 10 render: -> 11 + isSelected = _.includes @props.selected, @props.entry.id 12 + 13 + return null if @props.hideIfNotVoted && !isSelected 14 + 11 15 votingOver = moment(@props.contest.voting_ends_at).diff() <= 0 12 - isSelected = _.includes @props.selected, @props.entry.id 13 16 showVotes = @props.contest.show_votes 14 17 shape = @props.contest.shape 15 18 galleryId = "contest-#{@props.contest.id}"
+17
resources/assets/coffee/react/contest-voting/base-entry-list.coffee
··· 1 1 # Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 # See the LICENCE file in the repository root for full licence text. 3 3 4 + import { button, span } from 'react-dom-factories' 5 + 4 6 export class BaseEntryList extends React.Component 5 7 constructor: (props) -> 6 8 super props ··· 9 11 waitingForResponse: false 10 12 contest: @props.contest 11 13 selected: @props.selected 14 + showVotedOnly: false 12 15 options: 13 16 showPreview: @props.options.showPreview ? false 14 17 showLink: @props.options.showLink ? false ··· 44 47 45 48 componentWillUnmount: -> 46 49 $.unsubscribe '.contest' 50 + 51 + 52 + renderToggleShowVotedOnly: => 53 + button 54 + type: 'button' 55 + className: 'btn-osu-big btn-osu-big--contest-entries-toolbar' 56 + onClick: @onToggleShowVotedOnlyClick 57 + span className: 'btn-osu-big__icon-inline btn-osu-big__icon-inline--left', 58 + span className: if @state.showVotedOnly then 'fas fa-check-square' else 'far fa-square' 59 + osu.trans('contest.voting.show_voted_only') 60 + 61 + 62 + onToggleShowVotedOnlyClick: => 63 + @setState showVotedOnly: !@state.showVotedOnly
+7 -1
resources/assets/coffee/react/contest-voting/entry-list.coffee
··· 6 6 import { VoteSummary } from './vote-summary' 7 7 import * as React from 'react' 8 8 import { div,a,i,span } from 'react-dom-factories' 9 + import { classWithModifiers } from 'utils/css' 9 10 el = React.createElement 10 11 11 12 export class EntryList extends BaseEntryList ··· 27 28 key: entry.id, 28 29 rank: index + 1, 29 30 entry: entry, 31 + hideIfNotVoted: @state.showVotedOnly 30 32 waitingForResponse: @state.waitingForResponse, 31 33 options: @state.options, 32 34 contest: @state.contest, ··· 36 38 37 39 div className: 'contest-voting-list__table', 38 40 div className: 'contest-voting-list__header', 41 + if @state.contest.show_votes 42 + div className: 'contest-voting-list__rank contest-voting-list__rank--blank' 39 43 if @state.options.showPreview 40 44 div className: 'contest-voting-list__icon' 41 45 if @state.options.showLink 42 - div className: 'contest-voting-list__icon' 46 + div className: classWithModifiers('contest-voting-list__icon', 'best-of': @state.contest.best_of) 43 47 div className: 'contest-voting-list__header-wrapper', 44 48 div className: 'contest-voting-list__header-title', osu.trans('contest.entry._') 49 + div className: 'contest-voting-list__header-voted-toggle-button', 50 + @renderToggleShowVotedOnly() 45 51 div className: 'contest-voting-list__header-votesummary', 46 52 div className: 'contest__vote-summary-text', osu.trans('contest.vote.list') 47 53 el VoteSummary, voteCount: @state.selected.length, maxVotes: @state.contest.max_votes
+4 -2
resources/assets/coffee/react/contest-voting/entry.coffee
··· 9 9 10 10 export class Entry extends React.Component 11 11 render: -> 12 + selected = _.includes @props.selected, @props.entry.id 13 + 14 + return null if @props.hideIfNotVoted && !selected 15 + 12 16 if @props.contest.show_votes 13 17 votePercentage = _.round((@props.entry.results.votes / @props.totalVotes)*100, 2) 14 18 relativeVotePercentage = _.round((@props.entry.results.votes / @props.winnerVotes)*100, 2) 15 - 16 - selected = _.includes @props.selected, @props.entry.id 17 19 18 20 div className: "contest-voting-list__row#{if selected && !@props.contest.show_votes then ' contest-voting-list__row--selected' else ''}", 19 21 if @props.contest.show_votes
+12
resources/assets/less/bem/btn-osu-big.less
··· 109 109 } 110 110 } 111 111 112 + &--contest-entries-toolbar { 113 + font-size: inherit; 114 + line-height: normal; 115 + text-transform: uppercase; 116 + padding: 5px 10px; 117 + margin: 0 10px; 118 + transition: none; 119 + --colour: hsl(var(--hsl-f1)); 120 + --bg: transparent; 121 + --hover-bg: hsl(var(--hsl-b3)); 122 + } 123 + 112 124 &--danger { 113 125 background-color: @osu-colour-red-3; 114 126
+6
resources/assets/less/bem/comment-vote.less
··· 24 24 background-color: transparent; 25 25 } 26 26 27 + &--inline { 28 + display: inline-flex; 29 + padding: 0 8px; 30 + font-size: inherit; 31 + } 32 + 27 33 &--on { 28 34 .default-text-shadow(); 29 35 color: #fff;
+7 -1
resources/assets/less/bem/comment.less
··· 67 67 68 68 .@{_top}--single & { 69 69 margin-left: 0; 70 - margin-bottom: 40px; 70 + margin-bottom: 30px; 71 71 flex-direction: column; 72 72 font-size: @font-size--title; 73 73 } ··· 243 243 .link-hover({ 244 244 color: @osu-colour-l1; 245 245 }); 246 + } 247 + 248 + &__toolbar { 249 + margin: 10px 0; 250 + display: flex; 251 + justify-content: flex-end; 246 252 } 247 253 248 254 &__top-show-replies {
+9 -1
resources/assets/less/bem/contest-voting-list.less
··· 17 17 font-weight: 100; 18 18 justify-content: center; 19 19 background: @osu-colour-b6; 20 + 21 + &--blank { 22 + background-color: transparent; 23 + } 20 24 } 21 25 22 26 &__trophy { ··· 69 73 70 74 &__header-wrapper { 71 75 display: flex; 72 - justify-content: space-between; 73 76 width: 100%; 74 77 } 75 78 ··· 88 91 89 92 &__header-title { 90 93 padding-left: 10px; 94 + margin-right: auto; 95 + } 96 + 97 + &__header-voted-toggle-button { 98 + align-self: center; 91 99 } 92 100 93 101 &__header-votesummary {
+3 -1
resources/assets/less/bem/tournament.less
··· 54 54 &__page { 55 55 background-color: @osu-colour-b4; 56 56 .default-box-shadow(); 57 - padding: 20px; 57 + .default-gutter-v2(); 58 + padding-top: 20px; 59 + padding-bottom: $padding-top; 58 60 } 59 61 }
-1
resources/assets/lib/news-index.ts
··· 7 7 return { 8 8 container, 9 9 data: osu.parseJson('json-index'), 10 - sidebarMeta: osu.parseJson('json-sidebar'), 11 10 }; 12 11 });
+3 -3
resources/assets/lib/news-index/main.tsx
··· 15 15 interface Props { 16 16 container: HTMLElement; 17 17 data: PostsJson; 18 - sidebarMeta: NewsSidebarMetaJson; 19 18 } 20 19 21 20 interface PostsJson { 22 21 news_posts: PostJson[]; 22 + news_sidebar: NewsSidebarMetaJson; 23 23 search: Search; 24 24 } 25 25 ··· 71 71 <div className='osu-page osu-page--wiki'> 72 72 <div className='wiki-page'> 73 73 <div className='wiki-page__toc'> 74 - <NewsSidebar data={this.props.sidebarMeta} /> 74 + <NewsSidebar data={this.props.data.news_sidebar} /> 75 75 </div> 76 76 77 77 <div className='wiki-page__content'> ··· 159 159 const search: Search = { 160 160 cursor: {}, 161 161 limit: 21, 162 - year: this.props.sidebarMeta.current_year, 162 + year: this.props.data.news_sidebar.current_year, 163 163 }; 164 164 165 165 const lastPost = _.last(this.state.posts);
+1 -1
resources/assets/lib/play-detail.coffee
··· 91 91 92 92 div 93 93 className: "#{bn}__pp" 94 - if shouldShowPp(score.beatmapset) 94 + if shouldShowPp(score.beatmap) 95 95 el React.Fragment, null, 96 96 el PpValue, 97 97 score: score
+18
resources/assets/lib/stores/ui-state-store.ts
··· 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 4 import { CommentBundleJson } from 'interfaces/comment-json'; 5 + import { route } from 'laroute'; 5 6 import { Dictionary, orderBy } from 'lodash'; 6 7 import { action, observable } from 'mobx'; 7 8 import { Comment, CommentSort } from 'models/comment'; ··· 91 92 this.updatePinnedCommentIds(commentBundle); 92 93 93 94 this.orderedCommentsByParentId = {}; 95 + this.comments.isShowDeleted = currentUser?.user_preferences?.comments_show_deleted ?? false; 96 + } 97 + 98 + @action 99 + toggleShowDeletedComments() { 100 + this.comments.isShowDeleted = !this.comments.isShowDeleted; 101 + 102 + if (currentUser.id != null) { 103 + $.ajax(route('account.options'), { 104 + data: { 105 + user_profile_customization: { 106 + comments_show_deleted: this.comments.isShowDeleted, 107 + }, 108 + }, 109 + method: 'PUT', 110 + }); 111 + } 94 112 } 95 113 96 114 @action
+28 -4
resources/assets/lib/utils/beatmap-helper.ts
··· 25 25 26 26 export function findDefault<T extends BeatmapJson>(params: FindDefaultParams<T>): T | null { 27 27 if (params.items != null) { 28 - return _.findLast<T>(params.items, isVisibleBeatmap) 29 - ?? _.last(params.items) 30 - ?? null; 28 + let currentDiffDelta: number; 29 + let currentItem: T | null = null; 30 + const targetDiff = userRecommendedDifficulty(params.mode ?? modes[0]); 31 + 32 + params.items.forEach((item) => { 33 + const diffDelta = Math.abs(item.difficulty_rating - targetDiff); 34 + 35 + if (isVisibleBeatmap(item) && (currentDiffDelta == null || diffDelta < currentDiffDelta)) { 36 + currentDiffDelta = diffDelta; 37 + currentItem = item; 38 + } 39 + }); 40 + 41 + return currentItem ?? _.last(params.items) ?? null; 31 42 } 32 43 33 44 if (params.group == null) return null; ··· 35 46 const findModes = params.mode == null ? userModes() : [params.mode]; 36 47 37 48 for (const m of findModes) { 38 - const beatmap = findDefault({ items: params.group[m] }); 49 + const beatmap = findDefault({ items: params.group[m], mode: m }); 39 50 40 51 if (beatmap != null) return beatmap; 41 52 } ··· 130 141 131 142 return ret; 132 143 } 144 + 145 + let userRecommendedDifficultyCache: Partial<Record<GameMode, number>> | null = null; 146 + 147 + function userRecommendedDifficulty(mode: GameMode) { 148 + if (userRecommendedDifficultyCache == null) { 149 + userRecommendedDifficultyCache = osu.parseJson<Record<GameMode, number> | null>('json-recommended-star-difficulty-all') ?? {}; 150 + $(document).one('turbolinks:before-cache', () => { 151 + userRecommendedDifficultyCache = null; 152 + }); 153 + } 154 + 155 + return userRecommendedDifficultyCache[mode] ?? 1.0; 156 + }
+1
resources/lang/en/accounts.php
··· 62 62 'comment_new' => 'new comments', 63 63 'forum_topic_reply' => 'topic reply', 64 64 'mail' => 'mail', 65 + 'mapping' => 'beatmap mapper', 65 66 'push' => 'push', 66 67 'user_achievement_unlock' => 'user medal unlocked', 67 68 ],
+2 -1
resources/lang/en/contest.php
··· 14 14 ], 15 15 16 16 'voting' => [ 17 + 'login_required' => 'Please sign in to vote.', 17 18 'over' => 'Voting for this contest has ended', 18 - 'login_required' => 'Please sign in to vote.', 19 + 'show_voted_only' => 'Show voted', 19 20 20 21 'best_of' => [ 21 22 'none_played' => "It doesn't look like you played any beatmaps that qualify for this contest!",
+1
resources/views/beatmapsets/discussion.blade.php
··· 20 20 {!! json_encode($initialData) !!} 21 21 </script> 22 22 23 + @include('beatmapsets._recommended_star_difficulty_all') 23 24 @include('layout._extra_js', ['src' => 'js/react/beatmap-discussions.js']) 24 25 @endsection
+1
resources/views/beatmapsets/show.blade.php
··· 38 38 {!! json_encode($languages) !!} 39 39 </script> 40 40 41 + @include('beatmapsets._recommended_star_difficulty_all') 41 42 @include('layout._extra_js', ['src' => 'js/react/beatmapset-page.js']) 42 43 @endsection
-4
resources/views/news/index.blade.php
··· 7 7 @section('content') 8 8 <div class="js-react--news-index osu-layout osu-layout--full"></div> 9 9 10 - <script id="json-sidebar" type="application/json"> 11 - {!! json_encode($sidebarMeta) !!} 12 - </script> 13 - 14 10 <script id="json-index" type="application/json"> 15 11 {!! json_encode($postsJson) !!} 16 12 </script>
+2 -2
tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php
··· 60 60 'playlist' => $playlistItem->getKey(), 61 61 ]), [ 62 62 'version_hash' => md5('testversion'), 63 - ])->assertStatus(404); 63 + ])->assertStatus(422); 64 64 65 65 $this->assertSame($initialScoresCount, Score::count()); 66 66 } ··· 96 96 'playlist' => $playlistItem->getKey(), 97 97 ]), [ 98 98 'version_hash' => $hash, 99 - ])->assertStatus(404); 99 + ])->assertStatus(422); 100 100 101 101 $this->assertSame($initialScoresCount, Score::count()); 102 102 }
+38 -4
tests/Models/Multiplayer/RoomTest.php
··· 10 10 use App\Models\Multiplayer\PlaylistItem; 11 11 use App\Models\Multiplayer\Room; 12 12 use App\Models\User; 13 + use Exception; 13 14 use Tests\TestCase; 14 15 15 16 class RoomTest extends TestCase ··· 70 71 { 71 72 $user = factory(User::class)->create(); 72 73 $room = factory(Room::class)->create(['max_attempts' => 2]); 73 - $playlistItem = factory(PlaylistItem::class)->create([ 74 + $playlistItem1 = factory(PlaylistItem::class)->create([ 75 + 'room_id' => $room->getKey(), 76 + ]); 77 + $playlistItem2 = factory(PlaylistItem::class)->create([ 74 78 'room_id' => $room->getKey(), 75 79 ]); 76 80 77 - $room->startPlay($user, $playlistItem); 81 + $room->startPlay($user, $playlistItem1); 78 82 $this->assertTrue(true); 79 83 80 - $room->startPlay($user, $playlistItem); 84 + $room->startPlay($user, $playlistItem2); 81 85 $this->assertTrue(true); 82 86 83 87 $this->expectException(InvariantException::class); 84 - $room->startPlay($user, $playlistItem); 88 + $room->startPlay($user, $playlistItem1); 89 + } 90 + 91 + public function testMaxAttemptsForItemReached() 92 + { 93 + $user = factory(User::class)->create(); 94 + $room = factory(Room::class)->create(); 95 + $playlistItem1 = factory(PlaylistItem::class)->create([ 96 + 'room_id' => $room->getKey(), 97 + 'max_attempts' => 1, 98 + ]); 99 + $playlistItem2 = factory(PlaylistItem::class)->create([ 100 + 'room_id' => $room->getKey(), 101 + 'max_attempts' => 1, 102 + ]); 103 + 104 + $initialCount = $room->scores()->count(); 105 + $room->startPlay($user, $playlistItem1); 106 + $this->assertSame($initialCount + 1, $room->scores()->count()); 107 + 108 + $initialCount = $room->scores()->count(); 109 + try { 110 + $room->startPlay($user, $playlistItem1); 111 + } catch (Exception $ex) { 112 + $this->assertTrue($ex instanceof InvariantException); 113 + } 114 + $this->assertSame($initialCount, $room->scores()->count()); 115 + 116 + $initialCount = $room->scores()->count(); 117 + $room->startPlay($user, $playlistItem2); 118 + $this->assertSame($initialCount + 1, $room->scores()->count()); 85 119 } 86 120 }