the browser-facing portion of osu!

Merge branch 'master' into old-link

authored by

bakaneko and committed by
GitHub
ef5bc754 c61eebe3

+3089 -1602
+1
.eslintrc.js
··· 198 198 rules: { 199 199 'arrow-body-style': 'error', 200 200 'arrow-parens': 'error', 201 + 'arrow-spacing': 'error', 201 202 'brace-style': 'error', 202 203 'comma-dangle': ['error', 'always-multiline'], 203 204 complexity: 'off',
+1 -1
.github/workflows/lint.yml
··· 48 48 - name: Install js dependencies 49 49 run: yarn --frozen-lockfile 50 50 51 - - run: 'yarn lint --max-warnings 130 > /dev/null' 51 + - run: 'yarn lint --max-warnings 123 > /dev/null' 52 52 53 53 - run: ./bin/update_licence.sh -nf 54 54
+1 -1
SETUP.md
··· 238 238 $ php artisan migrate:fresh --seed 239 239 ``` 240 240 241 - Run the above command to rebuild the database and seed with sample data. In order for the seeder to seed beatmaps, you must enter a valid osu! API key as the value of the `OSU_API_KEY` property in the `.env` configuration file, as the seeder obtains beatmap data from the osu! API. The key can be obtained at [the "osu! API Access" page](https://old.ppy.sh/p/api), which is currently only available on the old site. 241 + Run the above command to rebuild the database and seed with sample data. In order for the seeder to seed beatmaps, you must enter a valid osu! API key as the value of the `OSU_API_KEY` property in the `.env` configuration file, as the seeder obtains beatmap data from the osu! API. The key can be obtained from [the "Legacy API" section of your account settings page](https://osu.ppy.sh/home/account/edit#legacy-api). 242 242 243 243 ## Continuous asset generation while developing 244 244
+8 -1
app/Console/Commands/NotificationsSendMail.php
··· 61 61 foreach ($userIds->chunk($chunkSize) as $chunk) { 62 62 $users = User::whereIn('user_id', $chunk)->get(); 63 63 foreach ($users as $user) { 64 - dispatch(new UserNotificationDigest($user, $fromId, $toId)); 64 + $job = new UserNotificationDigest($user, $fromId, $toId); 65 + try { 66 + $job->handle(); 67 + } catch (\Exception $e) { 68 + // catch exception and queue job to be rerun to avoid job exploding and preventing other notifications from being processed. 69 + log_error($e); 70 + dispatch($job); 71 + } 65 72 } 66 73 } 67 74
+15 -7
app/Http/Controllers/AccountController.php
··· 15 15 use App\Models\UserAccountHistory; 16 16 use App\Models\UserNotificationOption; 17 17 use App\Transformers\CurrentUserTransformer; 18 + use App\Transformers\LegacyApiKeyTransformer; 19 + use App\Transformers\LegacyIrcKeyTransformer; 18 20 use Auth; 19 21 use DB; 20 22 use Mail; ··· 111 113 $authorizedClients = json_collection(Client::forUser($user), 'OAuth\Client', 'user'); 112 114 $ownClients = json_collection($user->oauthClients()->where('revoked', false)->get(), 'OAuth\Client', ['redirect', 'secret']); 113 115 116 + $legacyApiKey = $user->apiKeys()->available()->first(); 117 + $legacyApiKeyJson = $legacyApiKey === null ? null : json_item($legacyApiKey, new LegacyApiKeyTransformer()); 118 + 119 + $legacyIrcKey = $user->legacyIrcKey; 120 + $legacyIrcKeyJson = $legacyIrcKey === null ? null : json_item($legacyIrcKey, new LegacyIrcKeyTransformer()); 121 + 114 122 $notificationOptions = $user->notificationOptions->keyBy('name'); 115 123 116 124 return ext_view('accounts.edit', compact( 117 125 'authorizedClients', 118 126 'blocks', 119 127 'currentSessionId', 128 + 'legacyApiKeyJson', 129 + 'legacyIrcKeyJson', 120 130 'notificationOptions', 121 131 'ownClients', 122 132 'sessions' ··· 153 163 $previousEmail = $user->user_email; 154 164 155 165 if ($user->update($params) === true) { 156 - $addresses = [$user->user_email]; 157 - if (present($previousEmail)) { 158 - $addresses[] = $previousEmail; 159 - } 160 - foreach ($addresses as $address) { 161 - Mail::to($address)->locale($user->preferredLocale())->send(new UserEmailUpdated($user)); 166 + foreach ([$previousEmail, $user->user_email] as $address) { 167 + if (is_valid_email_format($address)) { 168 + Mail::to($address)->locale($user->preferredLocale())->send(new UserEmailUpdated($user)); 169 + } 162 170 } 163 171 164 172 UserAccountHistory::logUserUpdateEmail($user, $previousEmail); ··· 255 263 $user = Auth::user()->validateCurrentPassword()->validatePasswordConfirmation(); 256 264 257 265 if ($user->update($params) === true) { 258 - if (present($user->user_email)) { 266 + if (is_valid_email_format($user->user_email)) { 259 267 Mail::to($user)->send(new UserPasswordUpdated($user)); 260 268 } 261 269
+13 -8
app/Http/Controllers/BeatmapsetsController.php
··· 277 277 'genre_id:int', 278 278 'language_id:int', 279 279 'nsfw:bool', 280 - 'tags:string', 281 280 ]); 282 281 283 - $offsetParams = get_params($params, 'beatmapset', [ 284 - 'offset:int', 285 - ]); 286 - 287 - $updateParams = array_merge($metadataParams, $offsetParams); 288 - 289 282 if (count($metadataParams) > 0) { 290 283 priv_check('BeatmapsetMetadataEdit', $beatmapset)->ensureCan(); 291 284 } 292 285 293 - if (count($offsetParams) > 0) { 286 + $updateParams = [ 287 + ...$metadataParams, 288 + ...get_params($params, 'beatmapset', [ 289 + 'offset:int', 290 + 'tags:string', 291 + ]), 292 + ]; 293 + 294 + if (array_key_exists('offset', $updateParams)) { 294 295 priv_check('BeatmapsetOffsetEdit')->ensureCan(); 296 + } 297 + 298 + if (array_key_exists('tags', $updateParams)) { 299 + priv_check('BeatmapsetTagsEdit')->ensureCan(); 295 300 } 296 301 297 302 if (count($updateParams) > 0) {
+4
app/Http/Controllers/CommentsController.php
··· 194 194 $comment = new Comment($params); 195 195 $comment->setCommentable(); 196 196 197 + if ($comment->commentable === null) { 198 + abort(422, 'invalid commentable specified'); 199 + } 200 + 197 201 priv_check('CommentStore', $comment->commentable)->ensureCan(); 198 202 199 203 try {
+1 -1
app/Http/Controllers/PasswordResetController.php
··· 127 127 return osu_trans('password_reset.error.user_not_found'); 128 128 } 129 129 130 - if (!present($user->user_email)) { 130 + if (!is_valid_email_format($user->user_email)) { 131 131 return osu_trans('password_reset.error.contact_support'); 132 132 } 133 133
+1 -1
app/Http/Controllers/ReportsController.php
··· 25 25 'reason', 26 26 'reportable_id:int', 27 27 'reportable_type', 28 - ]); 28 + ], ['null_missing' => true]); 29 29 30 30 $class = MorphMap::getClass($params['reportable_type']); 31 31 if ($class === null) {
+1 -1
app/Http/Controllers/UsersController.php
··· 685 685 abort(404); 686 686 } 687 687 688 - $this->offset = get_int(Request::input('offset')) ?? 0; 688 + $this->offset = max(0, get_int(Request::input('offset')) ?? 0); 689 689 690 690 if ($this->offset >= $this->maxResults) { 691 691 $this->perPage = 0;
-78
app/Jobs/NotifyForumUpdateMail.php
··· 1 - <?php 2 - 3 - // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 - // See the LICENCE file in the repository root for full licence text. 5 - 6 - namespace App\Jobs; 7 - 8 - use App\Mail\ForumNewReply; 9 - use App\Models\UserNotificationOption; 10 - use Illuminate\Bus\Queueable; 11 - use Illuminate\Contracts\Queue\ShouldQueue; 12 - use Illuminate\Queue\SerializesModels; 13 - use Mail; 14 - 15 - class NotifyForumUpdateMail implements ShouldQueue 16 - { 17 - use Queueable, SerializesModels; 18 - 19 - public $topic; 20 - public $user; 21 - 22 - public function __construct($data) 23 - { 24 - $this->topic = $data['topic']; 25 - $this->user = $data['user']; 26 - } 27 - 28 - public function handle() 29 - { 30 - if ($this->topic === null) { 31 - return; 32 - } 33 - 34 - $watches = $this->topic->watches() 35 - ->where('mail', '=', true) 36 - ->where('notify_status', '=', false) 37 - ->has('user') 38 - ->with('user', 'topic') 39 - ->get(); 40 - 41 - $options = UserNotificationOption 42 - ::whereIn('user_id', $watches->pluck('user_id')) 43 - ->where(['name' => UserNotificationOption::FORUM_TOPIC_REPLY]) 44 - ->get() 45 - ->keyBy('user_id'); 46 - 47 - foreach ($watches as $watch) { 48 - $user = $watch->user; 49 - 50 - if (!present($user->user_email)) { 51 - continue; 52 - } 53 - 54 - if (($options[$user->getKey()]->details['mail'] ?? true) !== true) { 55 - continue; 56 - } 57 - 58 - if ($this->user !== null && $this->user->getKey() === $user->getKey()) { 59 - continue; 60 - } 61 - 62 - if ($user->getKey() === $this->topic->topic_last_poster_id) { 63 - continue; 64 - } 65 - 66 - if (!priv_check_user($user, 'ForumTopicWatch', $this->topic)->can()) { 67 - continue; 68 - } 69 - 70 - Mail::to($user)->queue(new ForumNewReply([ 71 - 'topic' => $this->topic, 72 - 'user' => $user, 73 - ])); 74 - 75 - $watch->update(['notify_status' => true]); 76 - } 77 - } 78 - }
+4 -1
app/Jobs/UserNotificationDigest.php
··· 11 11 use App\Models\Notification; 12 12 use App\Models\User; 13 13 use App\Models\UserNotification; 14 + use Datadog; 14 15 use DB; 15 16 use Illuminate\Bus\Queueable; 16 17 use Illuminate\Contracts\Queue\ShouldQueue; ··· 38 39 39 40 public function handle() 40 41 { 41 - if (!present($this->user->email)) { 42 + if (!is_valid_email_format($this->user->user_email)) { 42 43 return; 43 44 } 44 45 ··· 50 51 51 52 // TODO: catch and log errors? 52 53 Mail::to($this->user)->send(new UserNotificationDigestMail($notifications, $this->user)); 54 + 55 + Datadog::increment(config('datadog-helper.prefix_web').'.user_notification_digest.mail', 1); 53 56 } 54 57 55 58 private function filterNotifications(Collection $notifications)
+2 -2
app/Libraries/Fulfillments/SupporterTagFulfillment.php
··· 73 73 ); 74 74 } 75 75 76 - if (present($donor->user_email)) { 76 + if (is_valid_email_format($donor->user_email)) { 77 77 $donationTotal = $items->sum('cost'); 78 78 $totalDuration = $isGift ? null : $items->sum('extra_data.duration'); // duration is not relevant for gift. 79 79 ··· 90 90 91 91 Event::generate('userSupportGift', ['user' => $giftee, 'date' => $this->order->paid_at]); 92 92 93 - if (present($giftee->user_email)) { 93 + if (is_valid_email_format($giftee->user_email)) { 94 94 $duration = 0; 95 95 $messages = []; 96 96
+25 -12
app/Libraries/OsuAuthorize.php
··· 887 887 return 'unauthorized'; 888 888 } 889 889 890 + public function checkBeatmapsetTagsEdit(?User $user): string 891 + { 892 + $this->ensureLoggedIn($user); 893 + 894 + if ($user->isModerator()) { 895 + return 'ok'; 896 + } 897 + 898 + return 'unauthorized'; 899 + } 900 + 890 901 /** 891 902 * @param User|null $user 892 903 * @return string ··· 1470 1481 $this->ensureLoggedIn($user); 1471 1482 $this->ensureCleanRecord($user); 1472 1483 1473 - $plays = $user->playCount(); 1474 - $posts = $user->user_posts; 1475 - $forInitialHelpForum = in_array($forum->forum_id, config('osu.forum.initial_help_forum_ids'), true); 1484 + if (!$user->isBot()) { 1485 + $plays = $user->playCount(); 1486 + $posts = $user->user_posts; 1487 + $forInitialHelpForum = in_array($forum->forum_id, config('osu.forum.initial_help_forum_ids'), true); 1476 1488 1477 - if ($forInitialHelpForum) { 1478 - if ($plays < 10 && $posts > 10) { 1479 - return $prefix.'too_many_help_posts'; 1480 - } 1481 - } else { 1482 - if ($plays < config('osu.forum.minimum_plays') && $plays < $posts + 1) { 1483 - return $prefix.'play_more'; 1484 - } 1489 + if ($forInitialHelpForum) { 1490 + if ($plays < 10 && $posts > 10) { 1491 + return $prefix.'too_many_help_posts'; 1492 + } 1493 + } else { 1494 + if ($plays < config('osu.forum.minimum_plays') && $plays < $posts + 1) { 1495 + return $prefix.'play_more'; 1496 + } 1485 1497 1486 - $this->ensureHasPlayed($user); 1498 + $this->ensureHasPlayed($user); 1499 + } 1487 1500 } 1488 1501 1489 1502 return 'ok';
+1 -1
app/Libraries/User/ForceReactivation.php
··· 49 49 LegacySession::where('session_user_id', $userId)->delete(); 50 50 UserClient::where('user_id', $userId)->update(['verified' => false]); 51 51 52 - if (!$waitingActivation && present($this->user->user_email)) { 52 + if (!$waitingActivation && is_valid_email_format($this->user->user_email)) { 53 53 Mail::to($this->user)->send(new UserForceReactivation([ 54 54 'user' => $this->user, 55 55 'reason' => $this->reason,
+1 -1
app/Libraries/UserVerification.php
··· 96 96 { 97 97 $user = $this->user; 98 98 99 - if (!present($user->user_email)) { 99 + if (!is_valid_email_format($user->user_email)) { 100 100 return; 101 101 } 102 102
+4 -1
app/Listeners/Fulfillments/PaymentSubscribers.php
··· 126 126 return; 127 127 } 128 128 129 - Mail::to($order->user)->queue(new StorePaymentCompleted($order)); 129 + $user = $order->user; 130 + if (is_valid_email_format($user->user_email)) { 131 + Mail::to($user)->queue(new StorePaymentCompleted($order)); 132 + } 130 133 } 131 134 }
+1 -1
app/Models/ApiKey.php
··· 64 64 } 65 65 66 66 if (!filter_var($this->app_url ?? '', FILTER_VALIDATE_URL)) { 67 - $this->validationErrors()->add($field, 'url'); 67 + $this->validationErrors()->add('app_url', 'url'); 68 68 } 69 69 70 70 if (!$this->exists && static::where(['user_id' => $this->user_id])->available()->exists()) {
+1
app/Models/BeatmapPack.php
··· 27 27 'standard' => 'S', 28 28 'featured' => 'F', 29 29 'tournament' => 'P', // since 'T' is taken and 'P' goes for 'pool' 30 + 'loved' => 'L', 30 31 'chart' => 'R', 31 32 'theme' => 'T', 32 33 'artist' => 'A',
+9 -7
app/Models/Beatmapset.php
··· 469 469 return false; 470 470 } 471 471 472 - $contents = file_get_contents($url); 473 - if ($contents === false) { 474 - throw new BeatmapProcessorException('Error retrieving beatmap'); 475 - } 472 + $curl = curl_init($url); 473 + curl_setopt($curl, CURLOPT_FILE, $oszFile); 474 + curl_exec($curl); 476 475 477 - $bytesWritten = fwrite($oszFile, $contents); 476 + if (curl_errno($curl) > 0) { 477 + throw new BeatmapProcessorException('Failed downloading osz: '.curl_error($curl)); 478 + } 478 479 479 - if ($bytesWritten === false) { 480 - throw new BeatmapProcessorException('Failed writing stream'); 480 + $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); 481 + if ($statusCode !== 200) { 482 + throw new BeatmapProcessorException('Failed downloading osz: HTTP Error '.$statusCode); 481 483 } 482 484 483 485 return new BeatmapsetArchive(get_stream_filename($oszFile));
+20
app/Models/Chat/Message.php
··· 66 66 }; 67 67 } 68 68 69 + public function reportableAdditionalInfo(): ?string 70 + { 71 + $history = static 72 + ::where('message_id', '<=', $this->getKey()) 73 + ->whereHas('channel', fn ($ch) => $ch->where('type', '<>', Channel::TYPES['pm'])) 74 + ->where('user_id', $this->user_id) 75 + ->orderBy('timestamp', 'DESC') 76 + ->with('channel') 77 + ->limit(5) 78 + ->get() 79 + ->map(fn ($m) => "**{$m->timestamp_json} {$m->channel->name}:**\n{$m->content}\n") 80 + ->reverse() 81 + ->join("\n"); 82 + 83 + $channel = $this->channel; 84 + $header = 'Reported in: '.($channel->isPM() ? 'pm' : '**'.$channel->name.'** ('.strtolower($channel->type).')'); 85 + 86 + return "{$header}\n\n{$history}"; 87 + } 88 + 69 89 public function trashed(): bool 70 90 { 71 91 return false;
+4 -4
app/Models/Forum/Post.php
··· 377 377 378 378 // record edit history 379 379 if ($this->exists && $this->isDirty('post_text')) { 380 - $this->fill([ 381 - 'post_edit_time' => Carbon::now(), 382 - 'post_edit_count' => DB::raw('post_edit_count + 1'), 383 - ]); 380 + $this->post_edit_time = Carbon::now(); 381 + if ($this->post_edit_count < 64000) { 382 + $this->post_edit_count = DB::raw('post_edit_count + 1'); 383 + } 384 384 } 385 385 386 386 return parent::save($options);
+13 -8
app/Models/Forum/TopicPoll.php
··· 15 15 16 16 private $topic; 17 17 private $validated = false; 18 - private $params; 18 + private $params = [ 19 + 'hide_results' => false, 20 + 'length_days' => 0, 21 + 'max_options' => 1, 22 + 'options' => [], 23 + 'title' => null, 24 + 'vote_change' => false, 25 + ]; 19 26 private $votedBy = []; 20 27 21 28 public function __get(string $field) ··· 35 42 36 43 public function fill($params) 37 44 { 38 - $this->params = array_merge([ 39 - 'hide_results' => false, 40 - 'length_days' => 0, 41 - 'max_options' => 1, 42 - 'vote_change' => false, 43 - ], $params); 45 + $this->params = [ 46 + ...$this->params, 47 + ...$params, 48 + ]; 44 49 $this->validated = false; 45 50 46 51 return $this; ··· 61 66 $this->validated = true; 62 67 $this->validationErrors()->reset(); 63 68 64 - if (!isset($this->params['title']) || !present($this->params['title'])) { 69 + if (!present($this->params['title'])) { 65 70 $this->validationErrors()->add('title', 'required'); 66 71 } 67 72
+29 -4
app/Models/OAuth/Client.php
··· 8 8 use App\Exceptions\InvariantException; 9 9 use App\Models\User; 10 10 use App\Traits\Validatable; 11 + use Carbon\Carbon; 11 12 use DB; 12 13 use Laravel\Passport\Client as PassportClient; 13 14 use Laravel\Passport\RefreshToken; 14 15 16 + /** 17 + * @property Carbon|null $created_at 18 + * @property int $id 19 + * @property string $name 20 + * @property bool $password_client 21 + * @property bool $personal_access_client 22 + * @property string $provider 23 + * @property string $redirect 24 + * @property-read Collection<RefreshToken> refreshTokens 25 + * @property bool $revoked 26 + * @property string $secret 27 + * @property-read Collection<Token> tokens 28 + * @property Carbon|null $updated_at 29 + * @property-read User|null $user 30 + * @property int|null $user_id 31 + */ 15 32 class Client extends PassportClient 16 33 { 17 34 use Validatable; ··· 56 73 ); 57 74 } 58 75 76 + public function setRedirectAttribute(string $value) 77 + { 78 + $this->attributes['redirect'] = implode(',', array_unique(preg_split('/[\s,]+/', $value, 0, PREG_SPLIT_NO_EMPTY))); 79 + } 80 + 59 81 public function isValid() 60 82 { 61 83 $this->validationErrors()->reset(); ··· 71 93 $this->validationErrors()->add('name', 'required'); 72 94 } 73 95 74 - $redirect = trim($this->redirect); 75 - // TODO: this url validation is not very good. 76 - if (present($redirect) && !filter_var($redirect, FILTER_VALIDATE_URL)) { 77 - $this->validationErrors()->add('redirect', '.url'); 96 + $redirects = explode(',', $this->redirect ?? ''); 97 + foreach ($redirects as $redirect) { 98 + // TODO: this url validation is not very good. 99 + if (present($redirect) && !filter_var($redirect, FILTER_VALIDATE_URL)) { 100 + $this->validationErrors()->add('redirect', '.url'); 101 + break; 102 + } 78 103 } 79 104 80 105 return $this->validationErrors()->isEmpty();
+6 -1
app/Models/Traits/Reportable.php
··· 17 17 { 18 18 abstract protected function newReportableExtraParams(): array; 19 19 20 + public function reportableAdditionalInfo(): ?string 21 + { 22 + return null; 23 + } 24 + 20 25 public function reportedIn() 21 26 { 22 27 return $this->morphMany(UserReport::class, 'reportable'); ··· 36 41 $attributes['comments'] = $params['comments'] ?? ''; 37 42 $attributes['reporter_id'] = $reporter->getKey(); 38 43 39 - if (array_key_exists('reason', $params)) { 44 + if (present($params['reason'] ?? null)) { 40 45 $attributes['reason'] = $params['reason']; 41 46 } 42 47
+1
app/Models/Traits/ReportableInterface.php
··· 10 10 11 11 interface ReportableInterface 12 12 { 13 + public function reportableAdditionalInfo(): ?string; 13 14 public function reportBy(User $reporter, array $params): ?UserReport; 14 15 public function trashed(); 15 16 }
+8 -4
app/Models/User.php
··· 25 25 use Carbon\Carbon; 26 26 use DB; 27 27 use Ds\Set; 28 - use Egulias\EmailValidator\EmailValidator; 29 - use Egulias\EmailValidator\Validation\NoRFCWarningsValidation; 30 28 use Exception; 31 29 use Hash; 32 30 use Illuminate\Auth\Authenticatable; ··· 2000 1998 $this->currentPassword = $value; 2001 1999 } 2002 2000 2001 + /** 2002 + * Enables email presence and confirmation field equality check. 2003 + */ 2003 2004 public function validateEmailConfirmation() 2004 2005 { 2005 2006 $this->validateEmailConfirmation = true; ··· 2246 2247 } 2247 2248 2248 2249 if ($this->validateEmailConfirmation) { 2250 + if ($this->user_email === null) { 2251 + $this->validationErrors()->add('user_email', '.required'); 2252 + } 2253 + 2249 2254 if ($this->user_email !== $this->emailConfirmation) { 2250 2255 $this->validationErrors()->add('user_email_confirmation', '.wrong_email_confirmation'); 2251 2256 } ··· 2296 2301 2297 2302 public function isValidEmail() 2298 2303 { 2299 - $emailValidator = new EmailValidator(); 2300 - if (!$emailValidator->isValid($this->user_email, new NoRFCWarningsValidation())) { 2304 + if (!is_valid_email_format($this->user_email)) { 2301 2305 $this->validationErrors()->add('user_email', '.invalid_email'); 2302 2306 2303 2307 // no point validating further if address isn't valid.
+12
app/Models/UserReport.php
··· 7 7 8 8 use App\Exceptions\ValidationException; 9 9 use App\Libraries\MorphMap; 10 + use App\Models\Chat\Message; 10 11 use App\Models\Score\Best; 11 12 use App\Models\Score\Best\Model as BestModel; 12 13 use App\Traits\Validatable; ··· 135 136 $this->validationErrors()->add( 136 137 'reason', 137 138 '.no_ranked_beatmapset' 139 + ); 140 + } 141 + 142 + if ( 143 + $this->reportable instanceof Message 144 + && $this->reportable->channel->isHideable() 145 + && !$this->reportable->channel->hasUser($this->reporter) 146 + ) { 147 + $this->validationErrors()->add( 148 + 'reportable', 149 + '.not_in_channel' 138 150 ); 139 151 } 140 152
+13 -6
app/Notifications/UserReportNotification.php
··· 48 48 ? "<{$reportableUrl}|{$notifiable->reportable_type} {$notifiable->reportable_id}>" 49 49 : "{$notifiable->reportable_type} {$notifiable->reportable_id}"; 50 50 51 + $fields = [ 52 + 'Reporter' => $this->discordMarkdownLink($this->reporter->url(), $this->reporter->username), 53 + 'Reported' => $reportedText, 54 + 'User' => $this->discordMarkdownLink($userUrl, $user), 55 + 'Reason' => $notifiable->reason, 56 + ]; 57 + 58 + $additionalInfo = $reportable->reportableAdditionalInfo(); 59 + if ($additionalInfo !== null) { 60 + $fields['Additional Info'] = $additionalInfo; 61 + } 62 + 51 63 $attachment 52 64 ->color('warning') 53 65 ->content($notifiable->comments) 54 - ->fields([ 55 - 'Reporter' => $this->discordMarkdownLink($this->reporter->url(), $this->reporter->username), 56 - 'Reported' => $reportedText, 57 - 'User' => $this->discordMarkdownLink($userUrl, $user), 58 - 'Reason' => $notifiable->reason, 59 - ]); 66 + ->fields($fields); 60 67 }); 61 68 } 62 69
+4 -7
app/Transformers/BeatmapsetCompactTransformer.php
··· 127 127 { 128 128 $currentUser = Auth::user(); 129 129 130 - if ($currentUser === null) { 131 - return; 132 - } 133 - 134 130 $hypeValidation = $beatmapset->validateHypeBy($currentUser); 135 131 136 132 return $this->primitive([ ··· 138 134 'can_delete' => !$beatmapset->isScoreable() && priv_check('BeatmapsetDelete', $beatmapset)->can(), 139 135 'can_edit_metadata' => priv_check('BeatmapsetMetadataEdit', $beatmapset)->can(), 140 136 'can_edit_offset' => priv_check('BeatmapsetOffsetEdit')->can(), 137 + 'can_edit_tags' => priv_check('BeatmapsetTagsEdit')->can(), 141 138 'can_hype' => $hypeValidation['result'], 142 139 'can_hype_reason' => $hypeValidation['message'] ?? null, 143 140 'can_love' => $beatmapset->isLoveable() && priv_check('BeatmapsetLove')->can(), 144 141 'can_remove_from_loved' => $beatmapset->isLoved() && priv_check('BeatmapsetRemoveFromLoved')->can(), 145 142 'is_watching' => BeatmapsetWatch::check($beatmapset, Auth::user()), 146 - 'new_hype_time' => json_time($currentUser->newHypeTime()), 147 - 'nomination_modes' => $currentUser->nominationModes(), 148 - 'remaining_hype' => $currentUser->remainingHype(), 143 + 'new_hype_time' => json_time($currentUser?->newHypeTime()), 144 + 'nomination_modes' => $currentUser?->nominationModes(), 145 + 'remaining_hype' => $currentUser?->remainingHype() ?? 0, 149 146 ]); 150 147 } 151 148
+17 -5
app/helpers.php
··· 5 5 6 6 use App\Libraries\LocaleMeta; 7 7 use App\Models\LoginAttempt; 8 + use Egulias\EmailValidator\EmailValidator; 9 + use Egulias\EmailValidator\Validation\NoRFCWarningsValidation; 8 10 use Illuminate\Support\Arr; 9 11 use Illuminate\Support\HtmlString; 10 12 ··· 806 808 return is_api_request() || request()->expectsJson(); 807 809 } 808 810 811 + function is_valid_email_format(?string $email): bool 812 + { 813 + if ($email === null) { 814 + return false; 815 + } 816 + 817 + static $validator; 818 + $validator ??= new EmailValidator(); 819 + static $lexer; 820 + $lexer ??= new NoRFCWarningsValidation(); 821 + 822 + return $validator->isValid($email, $lexer); 823 + } 824 + 809 825 function is_sql_unique_exception($ex) 810 826 { 811 827 return starts_with( ··· 1317 1333 CURLOPT_MAXREDIRS => 5, 1318 1334 ]); 1319 1335 $data = curl_exec($curl); 1320 - curl_close($curl); 1321 1336 1322 1337 $result = read_image_properties_from_string($data); 1323 1338 ··· 1789 1804 ]); 1790 1805 curl_exec($ch); 1791 1806 1792 - $errored = curl_errno($ch) > 0 || curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 200; 1793 - curl_close($ch); 1794 - 1795 - return !$errored; 1807 + return curl_errno($ch) === 0 && curl_getinfo($ch, CURLINFO_HTTP_CODE) === 200; 1796 1808 } 1797 1809 1798 1810 function mini_asset(string $url): string
+23 -3
database/mods.json
··· 805 805 "Type": "string" 806 806 } 807 807 ], 808 - "IncompatibleMods": [], 808 + "IncompatibleMods": [ 809 + "BU" 810 + ], 809 811 "RequiresConfiguration": false, 810 812 "UserPlayable": true, 811 813 "ValidForMultiplayer": true, ··· 903 905 "AP", 904 906 "TR", 905 907 "WG", 906 - "RP" 908 + "RP", 909 + "BU" 907 910 ], 908 911 "RequiresConfiguration": false, 909 912 "UserPlayable": true, ··· 927 930 "AP", 928 931 "TR", 929 932 "WG", 930 - "MG" 933 + "MG", 934 + "BU" 931 935 ], 932 936 "RequiresConfiguration": false, 933 937 "UserPlayable": true, ··· 972 976 "Settings": [], 973 977 "IncompatibleMods": [ 974 978 "AD" 979 + ], 980 + "RequiresConfiguration": false, 981 + "UserPlayable": true, 982 + "ValidForMultiplayer": true, 983 + "ValidForMultiplayerAsFreeMod": true 984 + }, 985 + { 986 + "Acronym": "BU", 987 + "Name": "Bubbles", 988 + "Description": "Don't let their popping distract you!", 989 + "Type": "Fun", 990 + "Settings": [], 991 + "IncompatibleMods": [ 992 + "BR", 993 + "MG", 994 + "RP" 975 995 ], 976 996 "RequiresConfiguration": false, 977 997 "UserPlayable": true,
+1
resources/css/bem-index.less
··· 202 202 @import "bem/landing-news"; 203 203 @import "bem/landing-sitemap"; 204 204 @import "bem/lazy-load"; 205 + @import "bem/legacy-api-details"; 205 206 @import "bem/line-chart"; 206 207 @import "bem/link"; 207 208 @import "bem/livestream-featured";
-4
resources/css/bem/comments.less
··· 75 75 padding-bottom: @_header-spacing; 76 76 } 77 77 78 - &__text { 79 - .default-gutter-v2(); 80 - } 81 - 82 78 &__title { 83 79 .default-gutter-v2(); 84 80 padding-top: @_header-spacing;
+39
resources/css/bem/legacy-api-details.less
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + .legacy-api-details { 5 + display: grid; 6 + grid-template-columns: 1fr auto; 7 + gap: 10px; 8 + 9 + &__actions { 10 + display: flex; 11 + gap: 10px; 12 + flex-direction: column; 13 + 14 + @media @desktop { 15 + flex-direction: row; 16 + align-items: baseline; 17 + } 18 + } 19 + 20 + &__content { 21 + overflow-wrap: anywhere; 22 + display: flex; 23 + gap: 10px; 24 + flex-direction: column; 25 + } 26 + 27 + &__entry { 28 + overflow-wrap: anywhere; 29 + } 30 + 31 + &__label { 32 + color: hsl(var(--hsl-c2)); 33 + font-size: @font-size--normal; 34 + } 35 + 36 + &__value { 37 + font-size: @font-size--title-small; 38 + } 39 + }
+5 -1
resources/css/bem/love-beatmap-modal.less
··· 36 36 } 37 37 38 38 &__diff-mode-title { 39 - font-size: @font-size--title-small; 40 39 padding-bottom: 5px; 41 40 margin-bottom: 5px; 42 41 border-bottom: 1px solid #fff; 43 42 position: sticky; 44 43 top: 0; 45 44 background-color: hsl(var(--hsl-b5)); 45 + } 46 + 47 + &__diff-mode-title-label { 48 + font-size: @font-size--title-small; 46 49 } 47 50 48 51 &__row { ··· 82 85 font-size: inherit; 83 86 font-weight: normal; 84 87 cursor: pointer; 88 + margin: 0; 85 89 } 86 90 }
+7 -2
resources/css/bem/oauth-client-details.less
··· 36 36 } 37 37 38 38 &__group { 39 + .fancy-scrollbar(); 39 40 display: inline-flex; 40 41 flex-direction: column; 41 42 margin: 10px 0; ··· 50 51 .reset-input(); 51 52 .default-border-radius(); 52 53 53 - flex: 1; 54 - align-self: stretch; 55 54 background-color: @osu-colour-b3; 56 55 border: 2px solid transparent; 57 56 padding: 5px; ··· 69 68 70 69 &--has-error { 71 70 border-color: @osu-colour-red-1; 71 + } 72 + 73 + &--textarea { 74 + min-height: 5em; 75 + max-height: 50vh; 76 + resize: none; 72 77 } 73 78 } 74 79
+5 -1
resources/css/bem/play-detail.less
··· 65 65 } 66 66 67 67 &__beatmap { 68 + .u-ellipsis-overflow(); 68 69 color: @yellow-dark; 69 70 } 70 71 71 72 &__beatmap-and-time { 72 73 margin-top: 2px; 74 + white-space: nowrap; 75 + display: flex; 76 + gap: 15px; 73 77 } 74 78 75 79 &__detail { ··· 217 221 218 222 &__time { 219 223 color: @osu-colour-f1; 220 - margin-left: 15px; 224 + flex: none; 221 225 } 222 226 223 227 &__title {
+3 -3
resources/js/beatmap-discussions/beatmap-owner-editor.tsx
··· 20 20 type BeatmapsetWithDiscussionJson = BeatmapsetExtendedJson; 21 21 22 22 interface XhrCollection { 23 - updateOwner?: JQuery.jqXHR<BeatmapsetWithDiscussionJson>; 24 - userLookup?: JQuery.jqXHR<UserJson>; 23 + updateOwner: JQuery.jqXHR<BeatmapsetWithDiscussionJson>; 24 + userLookup: JQuery.jqXHR<UserJson>; 25 25 } 26 26 27 27 interface Props { ··· 40 40 private shouldFocusInputOnNextRender = false; 41 41 @observable private updatingOwner = false; 42 42 private userLookupTimeout?: number; 43 - private xhr: XhrCollection = {}; 43 + private xhr: Partial<XhrCollection> = {}; 44 44 45 45 @computed 46 46 private get inputUser() {
+1 -1
resources/js/beatmap-discussions/discussions.tsx
··· 5 5 import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; 6 6 import BeatmapsetDiscussionJson, { BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; 7 7 import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 8 - import { BeatmapsetWithDiscussionsJson } from 'interfaces/beatmapset-json'; 8 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 9 9 import UserJson from 'interfaces/user-json'; 10 10 import { size } from 'lodash'; 11 11 import { action, computed, makeObservable, observable } from 'mobx';
+37 -25
resources/js/beatmap-discussions/love-beatmap-modal.tsx
··· 3 3 4 4 import BeatmapJson from 'interfaces/beatmap-json'; 5 5 import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 6 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 6 7 import GameMode from 'interfaces/game-mode'; 7 8 import { route } from 'laroute'; 8 - import { computed, makeObservable, observable } from 'mobx'; 9 + import { action, computed, makeObservable, observable } from 'mobx'; 9 10 import { observer } from 'mobx-react'; 10 11 import * as React from 'react'; 11 12 import { onError } from 'utils/ajax'; 12 13 import { group as groupBeatmaps } from 'utils/beatmap-helper'; 13 14 import { trans } from 'utils/lang'; 14 - import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay'; 15 + import { hideLoadingOverlay, showImmediateLoadingOverlay } from 'utils/loading-overlay'; 15 16 16 17 interface Props { 17 - beatmapset: BeatmapsetExtendedJson; 18 + beatmapset: BeatmapsetExtendedJson & Required<Pick<BeatmapsetExtendedJson, 'beatmaps'>>; 18 19 onClose: () => void; 19 20 } 20 21 21 22 @observer 22 23 export default class LoveConfirmation extends React.Component<Props> { 23 24 @observable private selectedBeatmapIds: Set<number>; 25 + @observable private xhr: JQuery.jqXHR<BeatmapsetWithDiscussionsJson> | null = null; 26 + 27 + @computed 28 + private get beatmaps() { 29 + return this.props.beatmapset.beatmaps.filter((beatmap) => beatmap.deleted_at == null); 30 + } 31 + 32 + @computed 33 + private get groupedBeatmaps() { 34 + return groupBeatmaps(this.beatmaps); 35 + } 24 36 25 37 constructor(props: Props) { 26 38 super(props); ··· 30 42 makeObservable(this); 31 43 } 32 44 33 - @computed 34 - private get beatmaps() { 35 - return this.props.beatmapset.beatmaps?.filter((beatmap) => beatmap.deleted_at === null) ?? []; 36 - } 37 - 38 - @computed 39 - private get groupedBeatmaps() { 40 - return groupBeatmaps(this.beatmaps); 45 + componentWillUnmount() { 46 + this.xhr?.abort(); 41 47 } 42 48 43 49 render() { ··· 62 68 63 69 <button 64 70 className='btn-osu-big btn-osu-big--rounded-thin' 65 - disabled={this.selectedBeatmapIds.size === 0} 71 + disabled={this.xhr != null || this.selectedBeatmapIds.size === 0} 66 72 onClick={this.handleSubmit} 67 73 type='button' 68 74 > ··· 85 91 return isAllSelected; 86 92 }; 87 93 94 + @action 88 95 private handleCheckboxDifficulty = (e: React.ChangeEvent<HTMLInputElement>) => { 89 96 const beatmapId = parseInt(e.target.value, 10); 90 97 ··· 99 106 const mode = e.target.value as GameMode; 100 107 const modeBeatmaps = this.groupedBeatmaps.get(mode) ?? []; 101 108 102 - const action = this.checkIsModeSelected(mode) ? 'delete' : 'add'; 103 - modeBeatmaps.forEach((beatmap) => this.selectedBeatmapIds[action](beatmap.id)); 109 + const op = this.checkIsModeSelected(mode) ? 'delete' : 'add'; 110 + modeBeatmaps.forEach((beatmap) => this.selectedBeatmapIds[op](beatmap.id)); 104 111 }; 105 112 113 + @action 106 114 private handleSubmit = () => { 107 - if (!confirm(trans('beatmaps.nominations.love_confirm'))) { 108 - return; 109 - } 110 - 111 - if (this.selectedBeatmapIds.size === 0) { 115 + if (this.xhr != null 116 + || this.selectedBeatmapIds.size === 0 117 + || !confirm(trans('beatmaps.nominations.love_confirm'))) { 112 118 return; 113 119 } 114 120 115 - showLoadingOverlay(); 121 + showImmediateLoadingOverlay(); 116 122 117 123 const url = route('beatmapsets.love', { beatmapset: this.props.beatmapset.id }); 118 124 const params = { ··· 120 126 method: 'PUT', 121 127 }; 122 128 123 - $.ajax(url, params).done((response) => { 124 - $.publish('beatmapsetDiscussions:update', { beatmapset: response }); 129 + this.xhr = $.ajax(url, params); 130 + this.xhr.done((beatmapset) => { 131 + $.publish('beatmapsetDiscussions:update', { beatmapset }); 125 132 this.props.onClose(); 126 133 }).fail(onError) 127 - .always(hideLoadingOverlay); 134 + .always(action(() => { 135 + this.xhr = null; 136 + hideLoadingOverlay(); 137 + })); 128 138 }; 129 139 130 140 private renderDiffMode(mode: GameMode, beatmaps: BeatmapJson[]) { ··· 142 152 <input 143 153 checked={isModeSelected !== false} 144 154 className='osu-switch-v2__input' 145 - data-indeterminate={isModeSelected === null} 155 + data-indeterminate={isModeSelected == null} 146 156 onChange={this.handleCheckboxMode} 147 157 type='checkbox' 148 158 value={mode} 149 159 /> 150 160 <span className='osu-switch-v2__content' /> 151 161 </div> 152 - {trans(`beatmaps.mode.${mode}`)} 162 + <span className='love-beatmap-modal__diff-mode-title-label'> 163 + {trans(`beatmaps.mode.${mode}`)} 164 + </span> 153 165 </label> 154 166 </div> 155 167 <ul className='love-beatmap-modal__diff-list'>
+1 -1
resources/js/beatmap-discussions/new-discussion.tsx
··· 10 10 import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 11 11 import { BeatmapsetDiscussionPostStoreResponseJson } from 'interfaces/beatmapset-discussion-post-responses'; 12 12 import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 13 - import { BeatmapsetWithDiscussionsJson } from 'interfaces/beatmapset-json'; 13 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 14 14 import { route } from 'laroute'; 15 15 import { action, computed, makeObservable, observable, runInAction } from 'mobx'; 16 16 import { observer } from 'mobx-react';
+210 -232
resources/js/beatmap-discussions/nominator.tsx
··· 4 4 import BigButton from 'components/big-button'; 5 5 import Modal from 'components/modal'; 6 6 import BeatmapsetEventJson from 'interfaces/beatmapset-event-json'; 7 - import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 7 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 8 8 import GameMode from 'interfaces/game-mode'; 9 - import UserExtendedJson from 'interfaces/user-extended-json'; 10 9 import UserJson from 'interfaces/user-json'; 11 10 import { route } from 'laroute'; 12 - import * as _ from 'lodash'; 11 + import { forEachRight, map, uniq } from 'lodash'; 12 + import { action, computed, makeObservable, observable } from 'mobx'; 13 + import { observer } from 'mobx-react'; 14 + import core from 'osu-core-singleton'; 13 15 import * as React from 'react'; 14 16 import { onError } from 'utils/ajax'; 17 + import { isUserFullNominator } from 'utils/beatmapset-discussion-helper'; 15 18 import { classWithModifiers } from 'utils/css'; 16 19 import { trans } from 'utils/lang'; 17 20 18 21 interface Props { 19 - beatmapset: BeatmapsetExtendedJson; 22 + beatmapset: BeatmapsetWithDiscussionsJson; 20 23 currentHype: number; 21 - currentUser: UserExtendedJson; 22 24 unresolvedIssues: number; 23 - users: UserJson[]; 25 + users: Partial<Record<number, UserJson>>; 24 26 } 25 27 26 - interface State { 27 - loading: boolean; 28 - selectedModes: GameMode[]; 29 - visible: boolean; 30 - } 28 + const bn = 'nomination-dialog'; 31 29 32 - export class Nominator extends React.PureComponent<Props, State> { 33 - private bn = 'nomination-dialog'; 30 + @observer 31 + export class Nominator extends React.Component<Props> { 34 32 private checkboxContainerRef = React.createRef<HTMLDivElement>(); 35 - private xhr?: JQuery.jqXHR; 33 + @observable private loading = false; 34 + @observable private selectedModes: GameMode[] = []; 35 + @observable private visible = false; 36 + private xhr?: JQuery.jqXHR<BeatmapsetWithDiscussionsJson>; 36 37 37 - constructor(props: Props) { 38 - super(props); 38 + private get mapCanBeNominated() { 39 + if (this.props.beatmapset.hype == null) { 40 + return false; 41 + } 39 42 40 - this.state = { 41 - loading: false, 42 - selectedModes: this.hybridMode() ? [] : [_.keys(this.props.beatmapset.nominations?.required)[0] as GameMode], 43 - visible: false, 44 - }; 43 + return this.props.beatmapset.status === 'pending' && this.props.currentHype >= this.props.beatmapset.hype.required; 45 44 } 46 45 47 - componentWillUnmount() { 48 - this.xhr?.abort(); 49 - } 46 + private get nominationEvents() { 47 + const nominations: BeatmapsetEventJson[] = []; 50 48 51 - hasFullNomination = (mode: GameMode) => { 52 - const eventUserIsFullNominator = (event: BeatmapsetEventJson, gameMode?: GameMode) => { 53 - if (!event.user_id) { 49 + forEachRight(this.props.beatmapset.events, (event) => { 50 + if (event.type === 'nomination_reset') { 54 51 return false; 55 52 } 56 53 57 - return _.some(this.props.users[event.user_id].groups, (group) => { 58 - if (gameMode !== undefined) { 59 - return (group.identifier === 'bng' || group.identifier === 'nat') && group.playmodes?.includes(gameMode); 60 - } else { 61 - return (group.identifier === 'bng' || group.identifier === 'nat'); 62 - } 63 - }); 64 - }; 65 - 66 - return _.some(this.nominationEvents(), (event) => { 67 - if (event.type === 'nominate' && event.comment != null) { 68 - return event.comment.modes.includes(mode) && eventUserIsFullNominator(event, mode); 69 - } else { 70 - return eventUserIsFullNominator(event); 54 + if (event.type === 'nominate') { 55 + nominations.push(event); 71 56 } 72 57 }); 73 - }; 74 58 75 - hideNominationModal = () => { 76 - this.setState({ 77 - loading: false, 78 - selectedModes: this.hybridMode() ? [] : this.state.selectedModes, 79 - visible: false, 80 - }); 81 - }; 59 + return nominations; 60 + } 82 61 83 - hybridMode = () => _.keys(this.props.beatmapset.nominations?.required).length > 1; 62 + @computed 63 + private get playmodes() { 64 + return this.props.beatmapset.nominations.legacy_mode 65 + ? null 66 + : Object.keys(this.props.beatmapset.nominations.required) as GameMode[]; 67 + } 84 68 85 - legacyMode = () => this.props.beatmapset.nominations?.legacy_mode; 86 - 87 - mapCanBeNominated = () => { 88 - if (this.props.beatmapset.hype == null) { 69 + private get userCanNominate() { 70 + if (!this.userHasNominatePermission) { 89 71 return false; 90 72 } 91 73 92 - return this.props.beatmapset.status === 'pending' && this.props.currentHype >= this.props.beatmapset.hype.required; 93 - }; 74 + const nominationModes = this.playmodes ?? uniq(this.props.beatmapset.beatmaps.map((bm) => bm.mode)); 94 75 95 - nominate = () => { 96 - this.xhr?.abort(); 76 + return nominationModes.some((mode) => this.userCanNominateMode(mode)); 77 + } 97 78 98 - this.setState({ loading: true }, () => { 99 - const url = route('beatmapsets.nominate', { beatmapset: this.props.beatmapset.id }); 100 - const params = { 101 - data: { 102 - playmodes: this.state.selectedModes, 103 - }, 104 - method: 'PUT', 105 - }; 79 + private get userHasNominatePermission() { 80 + const currentUser = core.currentUserOrFail; 81 + return currentUser.is_admin || (!this.userIsOwner && (currentUser.is_bng || currentUser.is_nat)); 82 + } 106 83 107 - this.xhr = $.ajax(url, params) 108 - .done((response) => { 109 - $.publish('beatmapsetDiscussions:update', { beatmapset: response }); 110 - }) 111 - .fail(onError) 112 - .always(this.hideNominationModal); 113 - }); 114 - }; 84 + private get userIsOwner() { 85 + const userId = core.currentUserOrFail.id; 115 86 116 - nominationCountMet = (mode: GameMode) => { 117 - if ( 118 - this.props.beatmapset.nominations?.legacy_mode || 119 - !this.props.beatmapset.nominations?.required[mode] 120 - ) { 121 - return false; 122 - } 123 - 124 - const req = this.props.beatmapset.nominations.required[mode]; 125 - const curr = this.props.beatmapset.nominations.current[mode] || 0; 87 + return userId === this.props.beatmapset.user_id 88 + || this.props.beatmapset.beatmaps.some((beatmap) => beatmap.deleted_at == null && userId === beatmap.user_id); 89 + } 126 90 127 - if (!req) { 128 - return false; 91 + private get userNominatableModes() { 92 + if (!this.mapCanBeNominated || !this.userHasNominatePermission) { 93 + return {}; 129 94 } 130 95 131 - return curr >= req; 132 - }; 96 + return this.props.beatmapset.current_user_attributes.nomination_modes ?? {}; 97 + } 133 98 134 - nominationEvents = () => { 135 - const nominations: BeatmapsetEventJson[] = []; 99 + constructor(props: Props) { 100 + super(props); 136 101 137 - _.forEachRight(this.props.beatmapset.events ?? [], (event) => { 138 - if (event.type === 'nomination_reset') { 139 - return false; 140 - } 102 + makeObservable(this); 103 + } 141 104 142 - if (event.type === 'nominate') { 143 - nominations.push(event); 144 - } 145 - }); 105 + componentWillUnmount() { 106 + this.xhr?.abort(); 107 + } 146 108 147 - return nominations; 148 - }; 109 + render() { 110 + if (core.currentUser == null) return null; 149 111 150 - render(): React.ReactNode { 151 112 return ( 152 113 <> 153 114 {this.renderButton()} 154 - {this.state.visible && this.renderModal()} 115 + {this.visible && this.renderModal()} 155 116 </> 156 117 ); 157 118 } 158 119 159 - renderButton = () => { 160 - if (!this.mapCanBeNominated() || !this.userHasNominatePermission()) { 161 - return; 120 + private hasFullNomination(mode: GameMode) { 121 + return this.nominationEvents.some((event) => { 122 + const user = event.user_id != null ? this.props.users[event.user_id] : null; 123 + 124 + return event.type === 'nominate' && event.comment != null 125 + ? event.comment.modes.includes(mode) && isUserFullNominator(user, mode) 126 + : isUserFullNominator(user); 127 + }); 128 + } 129 + 130 + @action 131 + private readonly hideNominationModal = () => { 132 + this.visible = false; 133 + }; 134 + 135 + @action 136 + private readonly nominate = () => { 137 + if (this.loading) return; 138 + 139 + this.loading = true; 140 + 141 + const url = route('beatmapsets.nominate', { beatmapset: this.props.beatmapset.id }); 142 + const params = { 143 + data: { 144 + playmodes: this.playmodes != null && this.playmodes.length === 1 ? this.playmodes : this.selectedModes, 145 + }, 146 + method: 'PUT', 147 + }; 148 + 149 + this.xhr = $.ajax(url, params); 150 + this.xhr.done((response) => { 151 + $.publish('beatmapsetDiscussions:update', { beatmapset: response }); 152 + this.hideNominationModal(); 153 + }) 154 + .fail(onError) 155 + .always(action(() => this.loading = false)); 156 + }; 157 + 158 + private nominationCountMet(mode: GameMode) { 159 + if (this.props.beatmapset.nominations.legacy_mode || this.props.beatmapset.nominations.required[mode] === 0) { 160 + return false; 162 161 } 163 162 164 - const button = (disabled = false) => ( 165 - <BigButton 166 - disabled={disabled} 167 - icon='fas fa-thumbs-up' 168 - props={{ 169 - onClick: this.showNominationModal, 170 - }} 171 - text={trans('beatmaps.nominations.nominate')} 172 - /> 173 - ); 163 + const req = this.props.beatmapset.nominations.required[mode]; 164 + const curr = this.props.beatmapset.nominations.current[mode] ?? 0; 174 165 166 + if (!req) { 167 + return false; 168 + } 169 + 170 + return curr >= req; 171 + } 172 + 173 + private renderButton() { 174 + if (!this.mapCanBeNominated || !this.userHasNominatePermission) { 175 + return; 176 + } 177 + 178 + let tooltipText: string | undefined; 175 179 if (this.props.unresolvedIssues > 0) { 176 - // add a wrapper for the tooltip (because titles on a disabled button don't show) 177 - return ( 178 - <div title={trans('beatmaps.nominations.unresolved_issues')}> 179 - {button(true)} 180 - </div> 181 - ); 182 - } else { 183 - return button(this.props.beatmapset.nominations?.nominated || !this.userCanNominate()); 180 + tooltipText = trans('beatmaps.nominations.unresolved_issues'); 181 + } else if (this.props.beatmapset.nominations.nominated) { 182 + tooltipText = trans('beatmaps.nominations.already_nominated'); 183 + } else if (!this.userCanNominate) { 184 + tooltipText = trans('beatmaps.nominations.cannot_nominate'); 184 185 } 185 - }; 186 186 187 - renderModal = () => { 188 - const content = this.hybridMode() ? this.modalContentHybrid() : this.modalContentNormal(); 187 + return ( 188 + <div title={tooltipText}> 189 + <BigButton 190 + disabled={tooltipText != null} 191 + icon='fas fa-thumbs-up' 192 + props={{ 193 + onClick: this.showNominationModal, 194 + }} 195 + text={trans('beatmaps.nominations.nominate')} 196 + /> 197 + </div> 198 + ); 199 + } 200 + 201 + private renderModal() { 202 + const isHybrid = this.playmodes != null && this.playmodes.length > 1; 189 203 190 204 return ( 191 205 <Modal onClose={this.hideNominationModal}> 192 - <div className={this.bn}> 193 - <div className={`${this.bn}__header`}>{trans('beatmapsets.nominate.dialog.header')}</div> 194 - {content} 195 - <div className={`${this.bn}__buttons`}> 206 + <div className={bn}> 207 + <div className={`${bn}__header`}>{trans('beatmapsets.nominate.dialog.header')}</div> 208 + {isHybrid ? this.renderModalContentHybrid() : this.renderModalContentNormal()} 209 + <div className={`${bn}__buttons`}> 196 210 <BigButton 197 - disabled={(this.hybridMode() && this.state.selectedModes.length < 1) || this.state.loading} 211 + disabled={isHybrid && this.selectedModes.length < 1} 198 212 icon='fas fa-thumbs-up' 199 - isBusy={this.state.loading} 213 + isBusy={this.loading} 200 214 props={{ 201 215 onClick: this.nominate, 202 216 }} 203 217 text={trans('beatmaps.nominations.nominate')} 204 218 /> 205 219 <BigButton 206 - disabled={this.state.loading} 220 + disabled={this.loading} 207 221 icon='fas fa-times' 208 222 props={{ 209 223 onClick: this.hideNominationModal, ··· 214 228 </div> 215 229 </Modal> 216 230 ); 217 - }; 231 + } 218 232 219 - requiresFullNomination = (mode: GameMode) => { 220 - let req; 221 - let curr; 233 + private renderModalContentHybrid() { 234 + return ( 235 + <> 236 + {trans('beatmapsets.nominate.dialog.which_modes')} 237 + <div ref={this.checkboxContainerRef} className={`${bn}__checkboxes`}> 238 + {this.playmodes?.map((mode: GameMode) => { 239 + const disabled = !this.userCanNominateMode(mode); 240 + return ( 241 + <label 242 + key={mode} 243 + className={classWithModifiers('osu-switch-v2', { disabled })} 244 + > 245 + <input 246 + checked={this.selectedModes.includes(mode)} 247 + className='osu-switch-v2__input' 248 + disabled={disabled} 249 + name='nomination_modes' 250 + onChange={this.updateCheckboxes} 251 + type='checkbox' 252 + value={mode} 253 + /> 254 + <span className='osu-switch-v2__content' /> 255 + <div 256 + className={classWithModifiers(`${bn}__label`, { disabled })} 257 + > 258 + <i className={`fal fa-extra-mode-${mode}`} /> {trans(`beatmaps.mode.${mode}`)} 259 + </div> 260 + </label> 261 + ); 262 + })} 263 + </div> 264 + <div className={`${bn}__warn`}> 265 + {trans('beatmapsets.nominate.dialog.hybrid_warning')} 266 + </div> 267 + </> 268 + ); 269 + } 222 270 223 - if (this.props.beatmapset.nominations?.legacy_mode) { 224 - req = this.props.beatmapset.nominations?.required; 225 - curr = this.props.beatmapset.nominations?.current; 226 - } else { 227 - req = this.props.beatmapset.nominations?.required[mode]; 228 - curr = this.props.beatmapset.nominations?.current[mode]; 229 - } 271 + private renderModalContentNormal() { 272 + return trans('beatmapsets.nominate.dialog.confirmation'); 273 + } 230 274 231 - return (curr === (req ?? 0) - 1) && !this.hasFullNomination(mode); 232 - }; 233 - 234 - showNominationModal = () => this.setState({ visible: true }); 235 - 236 - updateCheckboxes = () => { 237 - const checkedBoxes = _.map(this.checkboxContainerRef.current?.querySelectorAll<HTMLInputElement>('input[type=checkbox]:checked'), (node) => node.value); 238 - this.setState({ selectedModes: checkedBoxes as GameMode[] }); 239 - }; 240 - 241 - userCanNominate = () => { 242 - if (!this.userHasNominatePermission()) { 243 - return false; 244 - } 275 + private requiresFullNomination(mode: GameMode) { 276 + let req: number; 277 + let curr: number; 245 278 246 - let nominationModes; 247 - if (this.legacyMode()) { 248 - nominationModes = _.uniq(this.props.beatmapset.beatmaps?.map((bm) => bm.mode)); 279 + if (this.props.beatmapset.nominations.legacy_mode) { 280 + req = this.props.beatmapset.nominations.required; 281 + curr = this.props.beatmapset.nominations.current; 249 282 } else { 250 - nominationModes = Object.keys(this.props.beatmapset.nominations!.required) as GameMode[]; 283 + req = this.props.beatmapset.nominations.required[mode] ?? 0; 284 + curr = this.props.beatmapset.nominations.current[mode] ?? 0; 251 285 } 252 286 253 - return _.some(nominationModes, (mode) => this.userCanNominateMode(mode)); 254 - }; 255 - 256 - userCanNominateMode = (mode: GameMode) => { 257 - if (!this.userHasNominatePermission() || this.nominationCountMet(mode)) { 258 - return false; 259 - } 287 + return (curr === req - 1) && !this.hasFullNomination(mode); 288 + } 260 289 261 - const userNominatable = this.userNominatableModes(); 290 + @action 291 + private readonly showNominationModal = () => this.visible = true; 262 292 263 - return userNominatable[mode] === 'full' || 264 - (userNominatable[mode] === 'limited' && !this.requiresFullNomination(mode)); 265 - }; 266 - 267 - userHasNominatePermission = () => this.props.currentUser.is_admin || (!this.userIsOwner() && (this.props.currentUser.is_bng || this.props.currentUser.is_nat)); 268 - 269 - userIsOwner = () => { 270 - const userId = this.props.currentUser?.id; 271 - 272 - return (userId != null && ( 273 - userId === this.props.beatmapset.user_id 274 - || (this.props.beatmapset.beatmaps ?? []).some((beatmap) => beatmap.deleted_at == null && userId === beatmap.user_id) 275 - )); 293 + @action 294 + private readonly updateCheckboxes = () => { 295 + const checkedBoxes = map(this.checkboxContainerRef.current?.querySelectorAll<HTMLInputElement>('input[type=checkbox]:checked'), (node) => node.value); 296 + this.selectedModes = checkedBoxes as GameMode[]; 276 297 }; 277 298 278 - userNominatableModes = () => { 279 - if (!this.mapCanBeNominated() || !this.userHasNominatePermission()) { 280 - return {}; 299 + private userCanNominateMode(mode: GameMode) { 300 + if (!this.userHasNominatePermission || this.nominationCountMet(mode)) { 301 + return false; 281 302 } 282 303 283 - return this.props.beatmapset.current_user_attributes?.nomination_modes ?? {}; 284 - }; 304 + const userNominatable = this.userNominatableModes; 285 305 286 - private modalContentHybrid = () => { 287 - const playmodes = _.keys(this.props.beatmapset.nominations?.required); 288 - 289 - const renderPlaymodes = _.map(playmodes, (mode: GameMode) => { 290 - const disabled = !this.userCanNominateMode(mode); 291 - return ( 292 - <label 293 - key={mode} 294 - className={classWithModifiers('osu-switch-v2', { disabled })} 295 - > 296 - <input 297 - checked={this.state.selectedModes.includes(mode)} 298 - className='osu-switch-v2__input' 299 - disabled={disabled} 300 - name='nomination_modes' 301 - onChange={this.updateCheckboxes} 302 - type='checkbox' 303 - value={mode} 304 - /> 305 - <span className='osu-switch-v2__content' /> 306 - <div 307 - className={classWithModifiers(`${this.bn}__label`, { disabled })} 308 - > 309 - <i className={`fal fa-extra-mode-${mode}`} /> {trans(`beatmaps.mode.${mode}`)} 310 - </div> 311 - </label> 312 - ); 313 - }); 314 - 315 - return ( 316 - <> 317 - {trans('beatmapsets.nominate.dialog.which_modes')} 318 - <div ref={this.checkboxContainerRef} className={`${this.bn}__checkboxes`}> 319 - {renderPlaymodes} 320 - </div> 321 - <div className={`${this.bn}__warn`}> 322 - {trans('beatmapsets.nominate.dialog.hybrid_warning')} 323 - </div> 324 - </> 325 - ); 326 - }; 327 - 328 - private modalContentNormal() { 329 - return trans('beatmapsets.nominate.dialog.confirmation'); 306 + return userNominatable[mode] === 'full' 307 + || (userNominatable[mode] === 'limited' && !this.requiresFullNomination(mode)); 330 308 } 331 309 }
+1 -1
resources/js/beatmap-discussions/post.tsx
··· 15 15 import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 16 16 import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json'; 17 17 import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; 18 - import { BeatmapsetWithDiscussionsJson } from 'interfaces/beatmapset-json'; 18 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 19 19 import UserJson from 'interfaces/user-json'; 20 20 import { route } from 'laroute'; 21 21 import { isEqual } from 'lodash';
+3 -2
resources/js/beatmap-discussions/renderers.tsx
··· 4 4 import * as React from 'react'; 5 5 import { uriTransformer } from 'react-markdown'; 6 6 import { propsFromHref, timestampRegexGlobal } from 'utils/beatmapset-discussion-helper'; 7 - import { openBeatmapEditor } from 'utils/url'; 7 + import { openBeatmapEditor, safeReactMarkdownUrl } from 'utils/url'; 8 8 9 9 export const LinkContext = React.createContext({ inLink: false }); 10 10 ··· 16 16 17 17 export function linkRenderer(astProps: JSX.IntrinsicElements['a']) { 18 18 const props = propsFromHref(astProps.href); 19 + const href = safeReactMarkdownUrl(props.href); 19 20 20 21 return ( 21 22 <> 22 23 <LinkContext.Provider value={{ inLink: true }}> 23 - <a {...props}>{props.children ?? astProps.children}</a> 24 + <a {...props} href={href}>{props.children ?? astProps.children}</a> 24 25 </LinkContext.Provider> 25 26 </> 26 27 );
-1
resources/js/beatmap-discussions/subscribe.tsx
··· 39 39 render() { 40 40 return ( 41 41 <BigButton 42 - disabled={this.busy} 43 42 icon={this.isWatching ? 'fas fa-eye-slash' : 'fas fa-eye'} 44 43 isBusy={this.busy} 45 44 modifiers='full'
+1 -1
resources/js/beatmapsets-show/main.tsx
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import { Comments } from 'components/comments'; 4 + import Comments from 'components/comments'; 5 5 import { CommentsManager } from 'components/comments-manager'; 6 6 import HeaderV4 from 'components/header-v4'; 7 7 import NotificationBanner from 'components/notification-banner';
+19 -13
resources/js/beatmapsets-show/metadata-editor.tsx
··· 46 46 return this.controller.beatmapset.current_user_attributes.can_edit_offset; 47 47 } 48 48 49 + private get canEditTags() { 50 + return this.controller.beatmapset.current_user_attributes.can_edit_tags; 51 + } 52 + 49 53 constructor(props: Props) { 50 54 super(props); 51 55 ··· 117 121 </div> 118 122 </label> 119 123 120 - <label className='simple-form__row'> 121 - <div className='simple-form__label'> 122 - {trans('beatmapsets.show.info.tags')} 123 - </div> 124 + {this.canEditTags && 125 + <label className='simple-form__row'> 126 + <div className='simple-form__label'> 127 + {trans('beatmapsets.show.info.tags')} 128 + </div> 124 129 125 - <textarea 126 - className='simple-form__input' 127 - maxLength={1000} 128 - name='beatmapset[tags]' 129 - onChange={this.setTags} 130 - value={this.tags} 131 - /> 132 - </label> 130 + <textarea 131 + className='simple-form__input' 132 + maxLength={1000} 133 + name='beatmapset[tags]' 134 + onChange={this.setTags} 135 + value={this.tags} 136 + /> 137 + </label> 138 + } 133 139 134 140 {this.canEditOffset && 135 141 <label className='simple-form__row'> ··· 202 208 language_id: this.languageId, 203 209 nsfw: this.nsfw, 204 210 offset: this.canEditOffset ? getInt(this.offset) : undefined, 205 - tags: this.tags, 211 + tags: this.canEditTags ? this.tags : undefined, 206 212 } }, 207 213 method: 'PATCH', 208 214 });
+1 -1
resources/js/changelog-build/main.coffee
··· 3 3 4 4 import { Build } from 'components/build' 5 5 import { ChangelogHeaderStreams } from 'components/changelog-header-streams' 6 - import { Comments } from 'components/comments' 6 + import Comments from 'components/comments' 7 7 import { CommentsManager } from 'components/comments-manager' 8 8 import HeaderV4 from 'components/header-v4' 9 9 import { route } from 'laroute'
+2 -2
resources/js/chat/message-item.tsx
··· 12 12 import oldLink from 'remark-plugins/old-link'; 13 13 import wikiLink, { RemarkWikiLinkPlugin } from 'remark-wiki-link'; 14 14 import { classWithModifiers } from 'utils/css'; 15 - import { wikiUrl } from 'utils/url'; 15 + import { safeReactMarkdownUrl, wikiUrl } from 'utils/url'; 16 16 17 17 interface Props { 18 18 message: Message; ··· 20 20 21 21 function linkRenderer(astProps: JSX.IntrinsicElements['a']) { 22 22 return ( 23 - <a href={astProps.href} rel='nofollow noreferrer' target='_blank'> 23 + <a href={safeReactMarkdownUrl(astProps.href)} rel='nofollow noreferrer' target='_blank'> 24 24 {astProps.children} 25 25 </a> 26 26 );
-4
resources/js/coffee-modules.d.ts
··· 15 15 } 16 16 } 17 17 18 - declare module 'components/comments' { 19 - class Comments extends React.PureComponent<any> {} 20 - } 21 - 22 18 declare module 'components/comments-manager' { 23 19 interface Props { 24 20 commentableId?: number;
+59
resources/js/comments-index/index.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import Comment from 'components/comment'; 5 + import { computed, makeObservable } from 'mobx'; 6 + import { observer } from 'mobx-react'; 7 + import core from 'osu-core-singleton'; 8 + import * as React from 'react'; 9 + import { trans } from 'utils/lang'; 10 + 11 + const store = core.dataStore.commentStore; 12 + const uiState = core.dataStore.uiState; 13 + 14 + @observer 15 + export default class CommentsIndex extends React.Component { 16 + @computed 17 + private get comments() { 18 + const ret = []; 19 + 20 + for (const id of uiState.comments.topLevelCommentIds) { 21 + const comment = store.comments.get(id); 22 + 23 + if (comment != null) { 24 + ret.push(comment); 25 + } 26 + } 27 + 28 + return ret; 29 + } 30 + 31 + constructor(props: Record<string, never>) { 32 + super(props); 33 + 34 + makeObservable(this); 35 + } 36 + 37 + render() { 38 + const comments = this.comments; 39 + 40 + return comments.length === 0 41 + ? ( 42 + <div className='comments'> 43 + <div className='comments__items comments__items--empty'> 44 + {trans('comments.index.no_comments')} 45 + </div> 46 + </div> 47 + ) : comments.map((comment) => ( 48 + <Comment 49 + key={comment.id} 50 + comment={comment} 51 + depth={0} 52 + expandReplies={false} 53 + linkParent 54 + modifiers='dark' 55 + showCommentableMeta 56 + /> 57 + )); 58 + } 59 + }
-79
resources/js/comments-index/main.coffee
··· 1 - # Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - # See the LICENCE file in the repository root for full licence text. 3 - 4 - import { Comment } from 'components/comment' 5 - import HeaderV4 from 'components/header-v4' 6 - import { route } from 'laroute' 7 - import { Observer } from 'mobx-react' 8 - import core from 'osu-core-singleton' 9 - import * as React from 'react' 10 - import { a, button, div, h1, p, span } from 'react-dom-factories' 11 - import { trans } from 'utils/lang' 12 - 13 - el = React.createElement 14 - 15 - store = core.dataStore.commentStore 16 - uiState = core.dataStore.uiState 17 - 18 - export class Main extends React.Component 19 - constructor: (props) -> 20 - super props 21 - 22 - @pagination = React.createRef() 23 - 24 - 25 - componentDidMount: => 26 - pagination = newBody.querySelector('.js-comments-pagination').cloneNode(true) 27 - @pagination.current.innerHTML = '' 28 - @pagination.current.appendChild pagination 29 - 30 - 31 - render: => 32 - el React.Fragment, null, 33 - el HeaderV4, 34 - links: @headerLinks() 35 - linksBreadcrumb: true 36 - theme: 'comments' 37 - 38 - el Observer, null, () => 39 - comments = uiState.comments.topLevelCommentIds.map (id) -> store.comments.get(id) 40 - div className: 'osu-page osu-page--comments', 41 - 42 - if comments.length < 1 43 - div className: 'comments__text', 44 - trans 'comments.index.no_comments' 45 - else 46 - for comment in comments 47 - el Comment, 48 - key: comment.id 49 - comment: comment 50 - expandReplies: false 51 - showCommentableMeta: true 52 - linkParent: true 53 - depth: 0 54 - modifiers: ['dark'] 55 - 56 - div ref: @pagination 57 - 58 - 59 - headerLinks: => 60 - links = [ 61 - { 62 - title: trans 'comments.index.nav_title' 63 - url: route('comments.index') 64 - } 65 - ] 66 - 67 - if @props.user? 68 - links.push( 69 - { 70 - title: @props.user.username 71 - url: route('users.show', user: @props.user.id) 72 - }, 73 - { 74 - title: trans 'comments.index.nav_comments' 75 - url: route('comments.index', user_id: @props.user.id) 76 - } 77 - ) 78 - 79 - return links
+32
resources/js/comments-show/index.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import Comment from 'components/comment'; 5 + import { observer } from 'mobx-react'; 6 + import core from 'osu-core-singleton'; 7 + import * as React from 'react'; 8 + 9 + const store = core.dataStore.commentStore; 10 + const uiState = core.dataStore.uiState; 11 + 12 + @observer 13 + export default class CommentsShow extends React.Component { 14 + render() { 15 + const comment = store.comments.get(uiState.comments.topLevelCommentIds[0]); 16 + 17 + if (comment == null) { 18 + throw new Error('missing comment'); 19 + } 20 + 21 + return ( 22 + <Comment 23 + comment={comment} 24 + depth={0} 25 + linkParent 26 + modifiers={['dark', 'single']} 27 + showCommentableMeta 28 + showToolbar 29 + /> 30 + ); 31 + } 32 + }
-49
resources/js/comments-show/main.coffee
··· 1 - # Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - # See the LICENCE file in the repository root for full licence text. 3 - 4 - import { Comment } from 'components/comment' 5 - import HeaderV4 from 'components/header-v4' 6 - import { route } from 'laroute' 7 - import { Observer } from 'mobx-react' 8 - import core from 'osu-core-singleton' 9 - import * as React from 'react' 10 - import { a, button, div, h1, li, ol, p, span } from 'react-dom-factories' 11 - import { trans } from 'utils/lang' 12 - 13 - el = React.createElement 14 - 15 - store = core.dataStore.commentStore 16 - uiState = core.dataStore.uiState 17 - 18 - export class Main extends React.PureComponent 19 - render: => 20 - el Observer, null, () => 21 - @comment = store.comments.get(uiState.comments.topLevelCommentIds[0]) 22 - 23 - el React.Fragment, null, 24 - el HeaderV4, 25 - links: @headerLinks() 26 - linksBreadcrumb: true 27 - theme: 'comments' 28 - 29 - div className: 'osu-page osu-page--comment', 30 - el Comment, 31 - comment: @comment 32 - showCommentableMeta: true 33 - showToolbar: true 34 - depth: 0 35 - linkParent: true 36 - modifiers: ['dark', 'single'] 37 - 38 - 39 - headerLinks: => 40 - [ 41 - { 42 - title: trans 'comments.index.nav_title' 43 - url: route('comments.index') 44 - } 45 - { 46 - title: trans 'comments.show.nav_title' 47 - url: route('comments.show', @comment) 48 - } 49 - ]
+10 -3
resources/js/components/big-button.tsx
··· 11 11 extraClasses: string[]; 12 12 href?: string; 13 13 icon?: string; 14 + /** 15 + * Changes icon to spinner and disables the button (implies `disabled`). 16 + */ 14 17 isBusy: boolean; 15 18 isSubmit: boolean; 16 19 modifiers?: Modifiers; ··· 29 32 isSubmit: false, 30 33 props: {}, 31 34 }; 35 + 36 + get disabled() { 37 + return this.props.disabled || this.props.isBusy; 38 + } 32 39 33 40 get text() { 34 41 if (this.props.text == null) { ··· 45 52 } 46 53 47 54 render() { 48 - let blockClass = classWithModifiers('btn-osu-big', this.props.modifiers, { disabled: this.props.disabled }); 55 + let blockClass = classWithModifiers('btn-osu-big', this.props.modifiers, { disabled: this.disabled }); 49 56 if (this.props.extraClasses != null) { 50 57 blockClass += ` ${this.props.extraClasses.join(' ')}`; 51 58 } 52 59 53 60 if (present(this.props.href)) { 54 - if (this.props.disabled) { 61 + if (this.disabled) { 55 62 return ( 56 63 <span className={blockClass} {...this.props.props}> 57 64 {this.renderChildren()} ··· 73 80 return ( 74 81 <button 75 82 className={blockClass} 76 - disabled={this.props.disabled} 83 + disabled={this.disabled} 77 84 type={this.props.isSubmit ? 'submit' : 'button'} 78 85 {...this.props.props} 79 86 >
+5 -4
resources/js/components/comment-editor.tsx
··· 18 18 import { Spinner } from './spinner'; 19 19 import UserAvatar from './user-avatar'; 20 20 21 - type Mode = 'edit' | 'new' | 'reply'; 21 + export type CommentEditMode = 'edit' | 'new' | 'reply'; 22 22 23 23 interface CommentPostParams { 24 24 comment: { ··· 36 36 id?: number; 37 37 message?: string; 38 38 modifiers?: Modifiers; 39 - onPosted?: (mode: Mode) => void; 39 + onPosted?: (mode: CommentEditMode) => void; 40 40 parent?: Comment; 41 41 } 42 42 43 43 const bn = 'comment-editor'; 44 44 45 - const buttonTextKey: Record<Mode, string> = { 45 + const buttonTextKey: Record<CommentEditMode, string> = { 46 46 edit: 'save', 47 47 new: 'post', 48 48 reply: 'reply', ··· 157 157 ? ( 158 158 <div className={`${bn}__footer-item`}> 159 159 <BigButton 160 - disabled={this.posting || !this.isValid} 160 + disabled={!this.isValid} 161 161 isBusy={this.posting} 162 162 modifiers='comment-editor' 163 163 props={{ onClick: this.post }} ··· 204 204 this.message = e.currentTarget.value; 205 205 }; 206 206 207 + @action 207 208 private readonly post = () => { 208 209 if (this.posting) return; 209 210
+31 -31
resources/js/components/comment-show-more.tsx
··· 45 45 this.xhr?.abort(); 46 46 } 47 47 48 - render() { 49 - if (this.props.comments.length >= this.props.total) { 50 - return null; 51 - } 52 - if (!this.hasMoreComments) { 53 - return null; 54 - } 55 - 56 - return this.props.top ?? false 57 - ? ( 58 - <ShowMoreLink 59 - callback={this.load} 60 - hasMore 61 - loading={this.loading} 62 - modifiers='comments' 63 - remaining={this.props.total - this.props.comments.length} 64 - /> 65 - ) : ( 66 - <div className={classWithModifiers(bn, this.props.modifiers)}> 67 - {this.loading ? 68 - <Spinner /> 69 - : 70 - <button className={`${bn}__link`} onClick={this.load}> 71 - {this.props.label ?? trans('common.buttons.show_more')} 72 - </button> 73 - } 74 - </div> 75 - ); 76 - } 77 - 78 48 @action 79 - private readonly load = () => { 49 + readonly load = () => { 80 50 if (this.loading) return; 81 51 82 52 this.loading = true; ··· 112 82 this.loading = false; 113 83 })); 114 84 }; 85 + 86 + render() { 87 + if (this.props.comments.length >= this.props.total) { 88 + return null; 89 + } 90 + if (!this.hasMoreComments) { 91 + return null; 92 + } 93 + 94 + return this.props.top ?? false 95 + ? ( 96 + <ShowMoreLink 97 + callback={this.load} 98 + hasMore 99 + loading={this.loading} 100 + modifiers='comments' 101 + remaining={this.props.total - this.props.comments.length} 102 + /> 103 + ) : ( 104 + <div className={classWithModifiers(bn, this.props.modifiers)}> 105 + {this.loading ? 106 + <Spinner /> 107 + : 108 + <button className={`${bn}__link`} onClick={this.load}> 109 + {this.props.label ?? trans('common.buttons.show_more')} 110 + </button> 111 + } 112 + </div> 113 + ); 114 + } 115 115 }
-643
resources/js/components/comment.coffee
··· 1 - # Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - # See the LICENCE file in the repository root for full licence text. 3 - 4 - import { route } from 'laroute' 5 - import { Observer } from 'mobx-react' 6 - import core from 'osu-core-singleton' 7 - import * as React from 'react' 8 - import { a, button, div, span, textarea, p } from 'react-dom-factories' 9 - import { onError } from 'utils/ajax' 10 - import { classWithModifiers } from 'utils/css' 11 - import { estimateMinLines } from 'utils/estimate-min-lines' 12 - import { createClickCallback, formatNumberSuffixed } from 'utils/html' 13 - import { trans, transChoice } from 'utils/lang' 14 - import ClickToCopy from './click-to-copy' 15 - import CommentEditor from './comment-editor' 16 - import CommentShowMore from './comment-show-more' 17 - import DeletedCommentsCount from './deleted-comments-count' 18 - import { ReportReportable } from './report-reportable' 19 - import ShowMoreLink from './show-more-link' 20 - import { Spinner } from './spinner' 21 - import StringWithComponent from './string-with-component' 22 - import TimeWithTooltip from './time-with-tooltip' 23 - import UserAvatar from './user-avatar' 24 - import UserLink from './user-link' 25 - 26 - el = React.createElement 27 - 28 - deletedUser = username: trans('users.deleted') 29 - commentableMetaStore = core.dataStore.commentableMetaStore 30 - store = core.dataStore.commentStore 31 - userStore = core.dataStore.userStore 32 - 33 - uiState = core.dataStore.uiState 34 - 35 - export class Comment extends React.PureComponent 36 - CLIP_LINES = 7 37 - MAX_DEPTH = 6 38 - 39 - makePreviewElement = document.createElement('div') 40 - 41 - makePreview = (comment, user) -> 42 - if comment.isDeleted 43 - trans('comments.deleted') 44 - else if isBlocked(user) 45 - trans('users.blocks.comment_text') 46 - else 47 - makePreviewElement.innerHTML = comment.messageHtml 48 - _.truncate makePreviewElement.textContent, length: 100 49 - 50 - 51 - isBlocked = (user) -> 52 - return core.currentUserModel.blocks.has(user.id) 53 - 54 - 55 - constructor: (props) -> 56 - super props 57 - 58 - @xhr = {} 59 - @loadMoreRef = React.createRef() 60 - 61 - if osuCore.windowSize.isMobile 62 - # There's no indentation on mobile so don't expand by default otherwise it will be confusing. 63 - expandReplies = false 64 - else if @props.comment.isDeleted 65 - expandReplies = false 66 - else if @props.expandReplies? 67 - expandReplies = @props.expandReplies 68 - else 69 - children = uiState.getOrderedCommentsByParentId(@props.comment.id) 70 - # Collapse if either no children is loaded, current level doesn't add indentation, or this comment is blocked. 71 - expandReplies = children?.length > 0 && @props.depth < MAX_DEPTH && !isBlocked(@userFor(@props.comment)) 72 - 73 - @state = 74 - clipped: true 75 - postingVote: false 76 - editing: false 77 - showNewReply: false 78 - expandReplies: expandReplies 79 - lines: null 80 - forceShow: false 81 - 82 - 83 - componentWillUnmount: => 84 - xhr?.abort() for own _name, xhr of @xhr 85 - 86 - 87 - componentDidMount: => 88 - @setState lines: estimateMinLines(@props.comment.messageHtml ? '') 89 - 90 - 91 - componentDidUpdate: (prevProps) => 92 - if prevProps.comment.messageHtml != @props.comment.messageHtml 93 - @setState lines: estimateMinLines(@props.comment.messageHtml ? '') 94 - 95 - 96 - render: => 97 - el Observer, null, () => 98 - @children = uiState.getOrderedCommentsByParentId(@props.comment.id) ? [] 99 - parent = store.comments.get(@props.comment.parentId) 100 - user = @userFor(@props.comment) 101 - meta = commentableMetaStore.get(@props.comment.commentableType, @props.comment.commentableId) 102 - @isBlocked = isBlocked(user) 103 - 104 - # Only clip if there are at least CLIP_LINES + 2 lines to ensure there are enough contents 105 - # being clipped instead of just single lone line (or worse no more lines because of rounding up). 106 - longContent = @state.lines? && @state.lines.count >= CLIP_LINES + 2 107 - 108 - blockClass = classWithModifiers 'comment', @props.modifiers, top: @props.depth == 0 109 - 110 - mainClass = classWithModifiers 'comment__main', 111 - deleted: @props.comment.isDeleted || @isBlocked 112 - clip: @state.clipped && longContent 113 - 114 - repliesClass = classWithModifiers 'comment__replies', 115 - indented: @props.depth < MAX_DEPTH 116 - hidden: !@state.expandReplies 117 - 118 - if !@props.comment.isDeleted && @isBlocked && !@state.forceShow 119 - return @renderBlocked(blockClass, mainClass) 120 - 121 - div 122 - className: blockClass 123 - 124 - @renderRepliesToggle() 125 - @renderCommentableMeta(meta) 126 - @renderToolbar() 127 - 128 - div 129 - className: mainClass 130 - style: 131 - '--line-height': if @state.lines? then "#{@state.lines.lineHeight}px" else undefined 132 - '--clip-lines': CLIP_LINES 133 - if @props.comment.canHaveVote 134 - div className: 'comment__float-container comment__float-container--left hidden-xs', 135 - @renderVoteButton() 136 - 137 - @renderUserAvatar user 138 - 139 - div className: 'comment__container', 140 - div className: 'comment__row comment__row--header', 141 - @renderUsername user 142 - @renderOwnerBadge(meta) 143 - 144 - if @props.comment.pinned 145 - span 146 - className: 'comment__row-item comment__row-item--pinned' 147 - span className: 'fa fa-thumbtack' 148 - ' ' 149 - trans 'comments.pinned' 150 - 151 - if parent? 152 - span 153 - className: 'comment__row-item comment__row-item--parent' 154 - @parentLink(parent) 155 - 156 - if @props.comment.isDeleted 157 - span 158 - className: 'comment__row-item comment__row-item--deleted' 159 - trans('comments.deleted') 160 - 161 - if @state.editing 162 - div className: 'comment__editor', 163 - el CommentEditor, 164 - id: @props.comment.id 165 - message: @props.comment.message 166 - modifiers: @props.modifiers 167 - close: @closeEdit 168 - else if @props.comment.messageHtml? 169 - el React.Fragment, null, 170 - div 171 - className: 'comment__message', 172 - dangerouslySetInnerHTML: 173 - __html: @props.comment.messageHtml 174 - @renderToggleClipButton() if longContent 175 - 176 - div className: 'comment__row comment__row--footer', 177 - if @props.comment.canHaveVote 178 - div 179 - className: 'comment__row-item visible-xs' 180 - @renderVoteButton(true) 181 - 182 - div 183 - className: 'comment__row-item comment__row-item--info' 184 - el TimeWithTooltip, dateTime: @props.comment.createdAt, relative: true 185 - 186 - @renderPermalink() 187 - @renderReplyButton() 188 - @renderEdit() 189 - @renderRestore() 190 - @renderDelete() 191 - @renderPin() 192 - @renderReport() 193 - @renderEditedBy() 194 - @renderDeletedBy() 195 - @renderForceShow() 196 - @renderRepliesText() 197 - 198 - @renderReplyBox(meta) 199 - 200 - if @props.comment.repliesCount > 0 201 - div 202 - className: repliesClass 203 - @children.map @renderComment 204 - 205 - el DeletedCommentsCount, { comments: @children } 206 - 207 - el CommentShowMore, 208 - parent: @props.comment 209 - comments: @children 210 - total: @props.comment.repliesCount 211 - modifiers: @props.modifiers 212 - label: trans('comments.load_replies') if @children.length == 0 213 - ref: @loadMoreRef 214 - 215 - 216 - renderBlocked: (blockClass, mainClass) => 217 - div className: blockClass, 218 - div className: mainClass, 219 - span 220 - className: if @props.depth > 0 then 'comment__avatar' else '' 221 - style: 222 - height: 'auto' 223 - 224 - div className: 'comment__container', 225 - div className: 'comment__message', 226 - p className: 'osu-md osu-md--comment osu-md__paragraph', 227 - trans('users.blocks.comment_text') 228 - ' ' 229 - @renderForceShowButton() 230 - 231 - 232 - renderComment: (comment) => 233 - comment = store.comments.get(comment.id) 234 - return null if comment.isDeleted && !core.userPreferences.get('comments_show_deleted') 235 - 236 - el Comment, 237 - key: comment.id 238 - comment: comment 239 - depth: @props.depth + 1 240 - parent: @props.comment 241 - modifiers: @props.modifiers 242 - expandReplies: @props.expandReplies 243 - 244 - 245 - renderDelete: => 246 - if !@props.comment.isDeleted && @props.comment.canDelete 247 - div className: 'comment__row-item', 248 - button 249 - type: 'button' 250 - className: 'comment__action' 251 - onClick: @delete 252 - trans('common.buttons.delete') 253 - 254 - 255 - renderDeletedBy: => 256 - if @props.comment.isDeleted && @props.comment.canModerate 257 - div className: 'comment__row-item comment__row-item--info', 258 - el StringWithComponent, 259 - mappings: 260 - timeago: 261 - el TimeWithTooltip, 262 - dateTime: @props.comment.deletedAt 263 - relative: true 264 - user: 265 - if @props.comment.deletedById? 266 - el UserLink, user: (userStore.get(@props.comment.deletedById) ? deletedUser) 267 - else 268 - trans('comments.deleted_by_system') 269 - pattern: trans('comments.deleted_by') 270 - 271 - 272 - renderPin: => 273 - if @props.comment.canPin 274 - div className: 'comment__row-item', 275 - button 276 - type: 'button' 277 - className: 'comment__action' 278 - onClick: @togglePinned 279 - trans 'common.buttons.' + if @props.comment.pinned then 'unpin' else 'pin' 280 - 281 - 282 - renderEdit: => 283 - if @props.comment.canEdit 284 - div className: 'comment__row-item', 285 - button 286 - type: 'button' 287 - className: "comment__action #{if @state.editing then 'comment__action--active' else ''}" 288 - onClick: @toggleEdit 289 - trans('common.buttons.edit') 290 - 291 - 292 - renderEditedBy: => 293 - if !@props.comment.isDeleted && @props.comment.isEdited 294 - editor = userStore.get(@props.comment.editedById) 295 - div 296 - className: 'comment__row-item comment__row-item--info' 297 - el StringWithComponent, 298 - mappings: 299 - timeago: el(TimeWithTooltip, dateTime: @props.comment.editedAt, relative: true) 300 - user: el UserLink, user: editor 301 - pattern: trans('comments.edited') 302 - 303 - 304 - renderOwnerBadge: (meta) => 305 - return null unless meta.owner_id? && @props.comment.userId == meta.owner_id 306 - 307 - div className: 'comment__row-item', 308 - div className: 'comment__owner-badge', meta.owner_title 309 - 310 - 311 - renderPermalink: => 312 - div className: 'comment__row-item', 313 - span 314 - className: 'comment__action comment__action--permalink' 315 - el ClickToCopy, 316 - value: route('comments.show', comment: @props.comment.id) 317 - label: trans 'common.buttons.permalink' 318 - valueAsUrl: true 319 - 320 - 321 - renderRepliesText: => 322 - return if @props.comment.repliesCount == 0 323 - 324 - if !@state.expandReplies && @children.length == 0 325 - callback = @loadReplies 326 - label = trans('comments.load_replies') 327 - else 328 - callback = @toggleReplies 329 - label = transChoice('comments.replies_count', @props.comment.repliesCount) 330 - 331 - div className: 'comment__row-item comment__row-item--replies', 332 - el ShowMoreLink, 333 - direction: if @state.expandReplies then 'up' else 'down' 334 - hasMore: true 335 - label: label 336 - callback: callback 337 - modifiers: ['comment-replies'] 338 - 339 - 340 - renderRepliesToggle: => 341 - if @props.depth == 0 && @children.length > 0 342 - div className: 'comment__float-container comment__float-container--right', 343 - button 344 - className: 'comment__top-show-replies' 345 - type: 'button' 346 - onClick: @toggleReplies 347 - span className: "fas #{if @state.expandReplies then 'fa-angle-up' else 'fa-angle-down'}" 348 - 349 - 350 - renderReplyBox: (commentableMeta) => 351 - if @state.showNewReply 352 - div className: 'comment__reply-box', 353 - el CommentEditor, 354 - close: @closeNewReply 355 - commentableMeta: commentableMeta 356 - modifiers: @props.modifiers 357 - onPosted: @handleReplyPosted 358 - parent: @props.comment 359 - 360 - 361 - renderReplyButton: => 362 - if !@props.comment.isDeleted 363 - div className: 'comment__row-item', 364 - button 365 - type: 'button' 366 - className: "comment__action #{if @state.showNewReply then 'comment__action--active' else ''}" 367 - onClick: @toggleNewReply 368 - trans('common.buttons.reply') 369 - 370 - 371 - renderReport: => 372 - if @props.comment.canReport 373 - div className: 'comment__row-item', 374 - el ReportReportable, 375 - className: 'comment__action' 376 - reportableId: @props.comment.id 377 - reportableType: 'comment' 378 - user: @userFor(@props.comment) 379 - 380 - 381 - renderRestore: => 382 - if @props.comment.isDeleted && @props.comment.canRestore 383 - div className: 'comment__row-item', 384 - button 385 - type: 'button' 386 - className: 'comment__action' 387 - onClick: @restore 388 - trans('common.buttons.restore') 389 - 390 - 391 - renderToggleClipButton: => 392 - button 393 - type: 'button' 394 - className: 'comment__toggle-clip' 395 - onClick: @toggleClip 396 - if @state.clipped 397 - trans('common.buttons.read_more') 398 - else 399 - trans('common.buttons.show_less') 400 - 401 - 402 - renderUserAvatar: (user) => 403 - if user.id? 404 - a 405 - className: 'comment__avatar js-usercard' 406 - 'data-user-id': user.id 407 - href: route('users.show', user: user.id) 408 - el UserAvatar, user: user, modifiers: ['full-circle'] 409 - else 410 - span 411 - className: 'comment__avatar' 412 - el UserAvatar, user: user, modifiers: ['full-circle'] 413 - 414 - 415 - renderUsername: (user) => 416 - if user.id? 417 - a 418 - 'data-user-id': user.id 419 - href: route('users.show', user: user.id) 420 - className: 'js-usercard comment__row-item' 421 - user.username 422 - else 423 - span 424 - className: 'comment__row-item' 425 - user.username 426 - 427 - 428 - renderVoteButton: (inline = false) => 429 - hasVoted = @hasVoted() 430 - 431 - className = classWithModifiers 'comment-vote', 432 - @props.modifiers 433 - disabled: !@props.comment.canVote 434 - inline: inline 435 - on: hasVoted 436 - posting: @state.postingVote 437 - 438 - hover = div className: 'comment-vote__hover', '+1' if !inline && !hasVoted 439 - 440 - button 441 - className: className 442 - type: 'button' 443 - onClick: @voteToggle 444 - disabled: @state.postingVote || !@props.comment.canVote 445 - span className: 'comment-vote__text', 446 - "+#{formatNumberSuffixed(@props.comment.votesCount)}" 447 - if @state.postingVote 448 - span className: 'comment-vote__spinner', el Spinner 449 - hover 450 - 451 - 452 - renderCommentableMeta: (meta) => 453 - return unless @props.showCommentableMeta 454 - 455 - if meta.url 456 - component = a 457 - params = 458 - href: meta.url 459 - className: 'comment__link' 460 - else 461 - component = span 462 - params = null 463 - 464 - div className: 'comment__commentable-meta', 465 - if @props.comment.commentableType? 466 - span className: 'comment__commentable-meta-type', 467 - span className: 'comment__commentable-meta-icon fas fa-comment' 468 - ' ' 469 - trans("comments.commentable_name.#{@props.comment.commentableType}") 470 - component params, 471 - meta.title 472 - 473 - 474 - renderToolbar: => 475 - return unless @props.showToolbar 476 - 477 - div className: 'comment__toolbar', 478 - div className: 'sort', 479 - div className: 'sort__items', 480 - button 481 - type: 'button' 482 - className: 'sort__item sort__item--button' 483 - onClick: @onShowDeletedToggleClick 484 - span className: 'sort__item-icon', 485 - span className: if core.userPreferences.get('comments_show_deleted') then 'fas fa-check-square' else 'far fa-square' 486 - trans('common.buttons.show_deleted') 487 - 488 - 489 - renderForceShowButton: => 490 - button 491 - type: 'button' 492 - className: 'comment__action' 493 - onClick: @toggleForceShow 494 - if !@state.forceShow then trans('users.blocks.show_comment') else trans('users.blocks.hide_comment') 495 - 496 - 497 - renderForceShow: => 498 - if !@props.comment.isDeleted && @isBlocked 499 - div className: 'comment__row-item', 500 - @renderForceShowButton() 501 - 502 - 503 - hasVoted: => 504 - store.userVotes.has(@props.comment.id) 505 - 506 - 507 - delete: => 508 - return unless confirm(trans('common.confirmation')) 509 - 510 - @xhr.delete?.abort() 511 - @xhr.delete = $.ajax route('comments.destroy', comment: @props.comment.id), 512 - method: 'DELETE' 513 - .done (data) => 514 - $.publish 'comment:updated', data 515 - .fail (xhr, status) => 516 - return if status == 'abort' 517 - 518 - onError xhr 519 - 520 - 521 - togglePinned: => 522 - return unless @props.comment.canPin 523 - 524 - @xhr.pin?.abort() 525 - @xhr.pin = $.ajax route('comments.pin', comment: @props.comment.id), 526 - method: if @props.comment.pinned then 'DELETE' else 'POST' 527 - .done (data) => 528 - $.publish 'comment:updated', data 529 - .fail (xhr, status) => 530 - return if status == 'abort' 531 - 532 - onError xhr 533 - 534 - 535 - handleReplyPosted: (type) => 536 - @setState expandReplies: true if type == 'reply' 537 - 538 - 539 - toggleEdit: => 540 - @setState editing: !@state.editing 541 - 542 - 543 - closeEdit: => 544 - @setState editing: false 545 - 546 - 547 - loadReplies: => 548 - @loadMoreRef.current?.load() 549 - @toggleReplies() 550 - 551 - 552 - onShowDeletedToggleClick: -> 553 - core.userPreferences.set('comments_show_deleted', !core.userPreferences.get('comments_show_deleted')) 554 - 555 - 556 - parentLink: (parent) => 557 - parentUser = @userFor(parent) 558 - props = title: makePreview(parent, parentUser) 559 - 560 - if @props.linkParent 561 - component = a 562 - props.href = route('comments.show', comment: parent.id) 563 - props.className = 'comment__link' 564 - else 565 - component = span 566 - 567 - component props, 568 - span className: 'fas fa-reply' 569 - ' ' 570 - parentUser.username 571 - 572 - 573 - userFor: (comment) => 574 - user = userStore.get(comment.userId)?.toJson() 575 - 576 - if user? 577 - user 578 - else if comment.legacyName? 579 - username: comment.legacyName 580 - else 581 - deletedUser 582 - 583 - 584 - restore: => 585 - @xhr.restore?.abort() 586 - @xhr.restore = $.ajax route('comments.restore', comment: @props.comment.id), 587 - method: 'POST' 588 - .done (data) => 589 - $.publish 'comment:updated', data 590 - .fail (xhr, status) => 591 - return if status == 'abort' 592 - 593 - onError xhr 594 - 595 - 596 - toggleNewReply: => 597 - @setState showNewReply: !@state.showNewReply 598 - 599 - 600 - voteToggle: (e) => 601 - target = e.target 602 - 603 - return if core.userLogin.showIfGuest(createClickCallback(target)) 604 - 605 - @setState postingVote: true 606 - 607 - if @hasVoted() 608 - method = 'DELETE' 609 - storeMethod = 'removeUserVote' 610 - else 611 - method = 'POST' 612 - storeMethod = 'addUserVote' 613 - 614 - @xhr.vote?.abort() 615 - @xhr.vote = $.ajax route('comments.vote', comment: @props.comment.id), 616 - method: method 617 - .always => 618 - @setState postingVote: false 619 - .done (data) => 620 - $.publish 'comment:updated', data 621 - store[storeMethod](@props.comment) 622 - 623 - .fail (xhr, status) => 624 - return if status == 'abort' 625 - return $(target).trigger('ajax:error', [xhr, status]) if xhr.status == 401 626 - 627 - onError xhr 628 - 629 - 630 - closeNewReply: => 631 - @setState showNewReply: false 632 - 633 - 634 - toggleReplies: => 635 - @setState expandReplies: !@state.expandReplies 636 - 637 - 638 - toggleClip: => 639 - @setState clipped: !@state.clipped 640 - 641 - 642 - toggleForceShow: => 643 - @setState forceShow: !@state.forceShow
+873
resources/js/components/comment.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import UserJson from 'interfaces/user-json'; 5 + import { route } from 'laroute'; 6 + import { truncate } from 'lodash'; 7 + import { action, computed, makeObservable, observable } from 'mobx'; 8 + import { observer } from 'mobx-react'; 9 + import { Comment as CommentModel } from 'models/comment'; 10 + import core from 'osu-core-singleton'; 11 + import * as React from 'react'; 12 + import { onError } from 'utils/ajax'; 13 + import { classWithModifiers, Modifiers } from 'utils/css'; 14 + import { estimateMinLines } from 'utils/estimate-min-lines'; 15 + import { createClickCallback, formatNumberSuffixed, stripTags } from 'utils/html'; 16 + import { trans, transChoice } from 'utils/lang'; 17 + import ClickToCopy from './click-to-copy'; 18 + import CommentEditor, { CommentEditMode } from './comment-editor'; 19 + import CommentShowMore from './comment-show-more'; 20 + import DeletedCommentsCount from './deleted-comments-count'; 21 + import { ReportReportable } from './report-reportable'; 22 + import ShowMoreLink from './show-more-link'; 23 + import { Spinner } from './spinner'; 24 + import StringWithComponent from './string-with-component'; 25 + import TimeWithTooltip from './time-with-tooltip'; 26 + import UserAvatar from './user-avatar'; 27 + import UserLink from './user-link'; 28 + 29 + const deletedUser = { username: trans('users.deleted') }; 30 + const commentableMetaStore = core.dataStore.commentableMetaStore; 31 + const store = core.dataStore.commentStore; 32 + const userStore = core.dataStore.userStore; 33 + 34 + const uiState = core.dataStore.uiState; 35 + 36 + const clipLines = 7; 37 + const maxDepth = 6; 38 + 39 + interface Props { 40 + comment: CommentModel; 41 + depth: number; 42 + expandReplies?: boolean; 43 + linkParent: boolean; 44 + modifiers: Modifiers; 45 + showCommentableMeta: boolean; 46 + showToolbar: boolean; 47 + } 48 + 49 + interface XhrCollection { 50 + delete: JQuery.jqXHR<unknown>; 51 + pin: JQuery.jqXHR<unknown>; 52 + restore: JQuery.jqXHR<unknown>; 53 + vote: JQuery.jqXHR<unknown>; 54 + } 55 + 56 + @observer 57 + export default class Comment extends React.Component<Props> { 58 + static readonly defaultProps = { 59 + linkParent: false, 60 + showCommentableMeta: false, 61 + showToolbar: false, 62 + }; 63 + 64 + @observable private clipped = true; 65 + @observable private editing = false; 66 + @observable private expandReplies: boolean; 67 + @observable private forceShow = false; 68 + private readonly showMoreRef = React.createRef<CommentShowMore>(); 69 + @observable private showNewReply = false; 70 + @observable private xhr: Partial<XhrCollection> = {}; 71 + 72 + private get hasVoted() { 73 + return store.userVotes.has(this.props.comment.id); 74 + } 75 + 76 + @computed 77 + private get isBlocked() { 78 + return this.props.comment.userId != null && core.currentUserModel.blocks.has(this.props.comment.userId); 79 + } 80 + 81 + private get isLongContent() { 82 + // Only clip if there are at least clipLines + 2 lines to ensure there are enough contents 83 + // being clipped instead of just single lone line (or worse no more lines because of rounding up). 84 + return this.lines != null && this.lines.count >= clipLines + 2; 85 + } 86 + 87 + @computed 88 + private get lines() { 89 + return estimateMinLines(this.props.comment.messageHtml ?? ''); 90 + } 91 + 92 + @computed 93 + private get meta() { 94 + return commentableMetaStore.get(this.props.comment.commentableType, this.props.comment.commentableId); 95 + } 96 + 97 + @computed 98 + private get parentComment() { 99 + return this.props.comment.parentId == null 100 + ? undefined 101 + : store.comments.get(this.props.comment.parentId); 102 + } 103 + 104 + @computed 105 + private get parentPreview() { 106 + const comment = this.parentComment; 107 + 108 + if (comment == null) { 109 + throw new Error('trying to render parent preview without parent'); 110 + } 111 + 112 + return comment.isDeleted 113 + ? trans('comments.deleted') 114 + : comment.userId != null && core.currentUserModel.blocks.has(comment.userId) 115 + ? trans('users.blocks.comment_text') 116 + : truncate(stripTags(comment.messageHtml ?? ''), { length: 100 }); 117 + } 118 + 119 + private get postingVote() { 120 + return this.xhr.vote != null; 121 + } 122 + 123 + @computed 124 + private get replies() { 125 + return uiState.getOrderedCommentsByParentId(this.props.comment.id) ?? []; 126 + } 127 + 128 + @computed 129 + private get user() { 130 + return this.getUser(this.props.comment.userId); 131 + } 132 + 133 + constructor(props: Props) { 134 + super(props); 135 + 136 + makeObservable(this); 137 + 138 + if (core.windowSize.isMobile) { 139 + // There's no indentation on mobile so don't expand by default otherwise it will be confusing. 140 + this.expandReplies = false; 141 + } else if (this.props.comment.isDeleted) { 142 + this.expandReplies = false; 143 + } else if (this.props.expandReplies != null) { 144 + this.expandReplies = this.props.expandReplies; 145 + } else { 146 + const children = this.replies; 147 + // Collapse if either no children is loaded, current level doesn't add indentation, or this comment is blocked. 148 + this.expandReplies = children?.length > 0 && this.props.depth < maxDepth && !this.isBlocked; 149 + } 150 + } 151 + 152 + componentWillUnmount() { 153 + Object.values(this.xhr).forEach((xhr) => xhr?.abort()); 154 + } 155 + 156 + render() { 157 + return ( 158 + <div className={classWithModifiers( 159 + 'comment', 160 + this.props.modifiers, 161 + { top: this.props.depth === 0 }, 162 + )}> 163 + {!this.props.comment.isDeleted && this.isBlocked && !this.forceShow 164 + ? this.renderBlocked() 165 + : this.renderMain() 166 + } 167 + </div> 168 + ); 169 + } 170 + 171 + private getUser(id: number | null | undefined): UserJson | { username: string } { 172 + const user = id == null ? null : userStore.get(id)?.toJson(); 173 + 174 + return user == null 175 + ? this.props.comment.legacyName == null 176 + ? deletedUser 177 + : { username: this.props.comment.legacyName } 178 + : user; 179 + } 180 + 181 + @action 182 + private readonly onCloseEdit = () => { 183 + this.editing = false; 184 + }; 185 + 186 + @action 187 + private readonly onCloseReplyBox = () => { 188 + this.showNewReply = false; 189 + }; 190 + 191 + @action 192 + private readonly onDelete = () => { 193 + if (this.xhr.delete != null || !confirm(trans('common.confirmation'))) { 194 + return; 195 + } 196 + 197 + this.xhr.delete = $.ajax(route('comments.destroy', { comment: this.props.comment.id }), { method: 'DELETE' }); 198 + this.xhr.delete.done((data) => { 199 + $.publish('comment:updated', data); 200 + }) 201 + .fail(onError) 202 + .always(action(() => { 203 + this.xhr.delete = undefined; 204 + })); 205 + }; 206 + 207 + private readonly onLoadReplies = () => { 208 + this.showMoreRef.current?.load(); 209 + this.onToggleReplies(); 210 + }; 211 + 212 + @action 213 + private readonly onReplyPosted = (type: CommentEditMode) => { 214 + this.expandReplies = type === 'reply'; 215 + }; 216 + 217 + private readonly onRestore = () => { 218 + if (this.xhr.restore != null) return; 219 + 220 + this.xhr.restore = $.ajax(route('comments.restore', { comment: this.props.comment.id }), { 221 + method: 'POST', 222 + }); 223 + this.xhr.restore.done((data) => { 224 + $.publish('comment:updated', data); 225 + }) 226 + .fail(onError) 227 + .always(action(() => { 228 + this.xhr.restore = undefined; 229 + })); 230 + }; 231 + 232 + private readonly onShowDeletedToggleClick = () => { 233 + core.userPreferences.set('comments_show_deleted', !core.userPreferences.get('comments_show_deleted')); 234 + }; 235 + 236 + @action 237 + private readonly onToggleClip = () => { 238 + this.clipped = !this.clipped; 239 + }; 240 + 241 + @action 242 + private readonly onToggleEdit = () => { 243 + this.editing = !this.editing; 244 + }; 245 + 246 + @action 247 + private readonly onToggleForceShow = () => { 248 + this.forceShow = !this.forceShow; 249 + }; 250 + 251 + @action 252 + private readonly onToggleNewReply = () => { 253 + this.showNewReply = !this.showNewReply; 254 + }; 255 + 256 + @action 257 + private readonly onTogglePinned = () => { 258 + if (this.xhr.pin != null || !this.props.comment.canPin) { 259 + return; 260 + } 261 + 262 + this.xhr.pin = $.ajax(route('comments.pin', { comment: this.props.comment.id }), { 263 + method: this.props.comment.pinned ? 'DELETE' : 'POST', 264 + }); 265 + this.xhr.pin.done((data) => { 266 + $.publish('comment:updated', data); 267 + }) 268 + .fail(onError) 269 + .always(action(() => { 270 + this.xhr.pin = undefined; 271 + })); 272 + }; 273 + 274 + @action 275 + private readonly onToggleReplies = () => { 276 + this.expandReplies = !this.expandReplies; 277 + }; 278 + 279 + @action 280 + private readonly onToggleVote = (e: React.MouseEvent<HTMLElement>) => { 281 + const target = e.currentTarget; 282 + 283 + if (this.postingVote || core.userLogin.showIfGuest(createClickCallback(target))) { 284 + return; 285 + } 286 + 287 + let method: string; 288 + let storeMethod: 'addUserVote' | 'removeUserVote'; 289 + 290 + if (this.hasVoted) { 291 + method = 'DELETE'; 292 + storeMethod = 'removeUserVote'; 293 + } else { 294 + method = 'POST'; 295 + storeMethod = 'addUserVote'; 296 + } 297 + 298 + this.xhr.vote = $.ajax(route('comments.vote', { comment: this.props.comment.id }), { method }); 299 + this.xhr.vote 300 + .done((data) => { 301 + $.publish('comment:updated', data); 302 + store[storeMethod](this.props.comment); 303 + }) 304 + .fail(onError) 305 + .always(action(() => { 306 + this.xhr.vote = undefined; 307 + })); 308 + }; 309 + 310 + private renderBlocked() { 311 + return ( 312 + <div className={classWithModifiers('comment__main', 'deleted')}> 313 + <span 314 + className={this.props.depth > 0 ? 'comment__avatar' : undefined} 315 + style={{ height: 'auto' }} 316 + /> 317 + 318 + <div className='comment__container'> 319 + <div className='comment__message'> 320 + <p className='osu-md osu-md--comment osu-md__paragraph'> 321 + {trans('users.blocks.comment_text')} 322 + {' '} 323 + {this.renderForceShowButton()} 324 + </p> 325 + </div> 326 + </div> 327 + </div> 328 + ); 329 + } 330 + 331 + private readonly renderComment = (sourceComment: CommentModel) => { 332 + const comment = store.comments.get(sourceComment.id); 333 + 334 + if (comment == null || (comment.isDeleted && !core.userPreferences.get('comments_show_deleted'))) { 335 + return; 336 + } 337 + 338 + return ( 339 + <Comment 340 + key={comment.id} 341 + comment={comment} 342 + depth={this.props.depth + 1} 343 + expandReplies={this.props.expandReplies} 344 + modifiers={this.props.modifiers} 345 + /> 346 + ); 347 + }; 348 + 349 + private renderCommentableMeta() { 350 + if (!this.props.showCommentableMeta) return; 351 + 352 + const meta = this.meta; 353 + 354 + if (meta == null) return; 355 + 356 + return ( 357 + <div className='comment__commentable-meta'> 358 + {this.props.comment.commentableType != null && ( 359 + <span className='comment__commentable-meta-type'> 360 + <span className='comment__commentable-meta-icon fas fa-comment' /> 361 + {' '} 362 + {trans(`comments.commentable_name.${this.props.comment.commentableType}`)} 363 + </span> 364 + )} 365 + {'url' in meta 366 + ? <a className='comment__link' href={meta.url}>{meta.title}</a> 367 + : <span>{meta.title}</span> 368 + } 369 + </div> 370 + ); 371 + } 372 + 373 + private renderDelete() { 374 + if (this.props.comment.isDeleted || !this.props.comment.canDelete) return; 375 + 376 + return ( 377 + <div className='comment__row-item'> 378 + <button 379 + className='comment__action' 380 + onClick={this.onDelete} 381 + type='button' 382 + > 383 + {trans('common.buttons.delete')} 384 + </button> 385 + </div> 386 + ); 387 + } 388 + 389 + private renderDeletedBy() { 390 + if (this.props.comment.deletedAt == null || !this.props.comment.canModerate) return; 391 + 392 + return ( 393 + <div className='comment__row-item comment__row-item--info'> 394 + <StringWithComponent 395 + mappings={{ 396 + timeago: ( 397 + <TimeWithTooltip 398 + dateTime={this.props.comment.deletedAt} 399 + relative 400 + /> 401 + ), 402 + user: ( 403 + this.props.comment.deletedById == null 404 + ? trans('comments.deleted_by_system') 405 + : <UserLink user={userStore.get(this.props.comment.deletedById) ?? deletedUser} /> 406 + ), 407 + }} 408 + pattern={trans('comments.deleted_by')} 409 + /> 410 + </div> 411 + ); 412 + } 413 + 414 + private renderEdit() { 415 + if (!this.props.comment.canEdit) return; 416 + 417 + return ( 418 + <div className='comment__row-item'> 419 + <button 420 + className={classWithModifiers('comment__action', { active: this.editing })} 421 + onClick={this.onToggleEdit} 422 + type='button' 423 + > 424 + {trans('common.buttons.edit')} 425 + </button> 426 + </div> 427 + ); 428 + } 429 + 430 + private renderEditedBy() { 431 + if (this.props.comment.editedAt == null || this.props.comment.isDeleted) { 432 + return; 433 + } 434 + 435 + return ( 436 + <div className='comment__row-item comment__row-item--info'> 437 + <StringWithComponent 438 + mappings={{ 439 + timeago: <TimeWithTooltip dateTime={this.props.comment.editedAt} relative />, 440 + user: <UserLink user={this.getUser(this.props.comment.editedById)} />, 441 + }} 442 + pattern={trans('comments.edited')} 443 + /> 444 + </div> 445 + ); 446 + } 447 + 448 + private renderFooter() { 449 + return ( 450 + <div className='comment__row comment__row--footer'> 451 + {this.props.comment.canHaveVote && 452 + <div className='comment__row-item visible-xs'> 453 + {this.renderVoteButton(true)} 454 + </div> 455 + } 456 + 457 + <div className='comment__row-item comment__row-item--info'> 458 + <TimeWithTooltip dateTime={this.props.comment.createdAt} relative /> 459 + </div> 460 + 461 + {this.renderPermalink()} 462 + {this.renderReplyButton()} 463 + {this.renderEdit()} 464 + {this.renderRestore()} 465 + {this.renderDelete()} 466 + {this.renderPin()} 467 + {this.renderReport()} 468 + {this.renderEditedBy()} 469 + {this.renderDeletedBy()} 470 + {this.renderForceShow()} 471 + {this.renderRepliesText()} 472 + </div> 473 + ); 474 + } 475 + 476 + private renderForceShow() { 477 + if (!this.isBlocked || this.props.comment.isDeleted) return; 478 + 479 + return ( 480 + <div className='comment__row-item'> 481 + {this.renderForceShowButton()} 482 + </div> 483 + ); 484 + } 485 + 486 + private renderForceShowButton() { 487 + return ( 488 + <button 489 + className='comment__action' 490 + onClick={this.onToggleForceShow} 491 + type='button' 492 + > 493 + {this.forceShow 494 + ? trans('users.blocks.hide_comment') 495 + : trans('users.blocks.show_comment') 496 + } 497 + </button> 498 + ); 499 + } 500 + 501 + private renderMain() { 502 + return ( 503 + <> 504 + {this.renderRepliesToggle()} 505 + {this.renderCommentableMeta()} 506 + {this.renderToolbar()} 507 + 508 + <div 509 + className={classWithModifiers('comment__main', { 510 + clip: this.clipped && this.isLongContent, 511 + deleted: this.props.comment.isDeleted || this.isBlocked, 512 + })} 513 + style={{ 514 + '--clip-lines': clipLines, 515 + '--line-height': this.lines == null ? undefined : `${this.lines.lineHeight}px`, 516 + } as React.CSSProperties} 517 + > 518 + {this.props.comment.canHaveVote && 519 + <div className='comment__float-container comment__float-container--left hidden-xs'> 520 + {this.renderVoteButton(false)} 521 + </div> 522 + } 523 + 524 + {this.renderUserAvatar()} 525 + 526 + <div className='comment__container'> 527 + <div className='comment__row comment__row--header'> 528 + {<UserLink className='comment__row-item' user={this.user} />} 529 + {this.renderOwnerBadge()} 530 + 531 + {this.props.comment.pinned && 532 + <span className='comment__row-item comment__row-item--pinned'> 533 + <span className='fa fa-thumbtack' /> 534 + {' '} 535 + {trans('comments.pinned')} 536 + </span> 537 + } 538 + 539 + {this.renderParentLink()} 540 + 541 + {this.props.comment.isDeleted && 542 + <span className='comment__row-item comment__row-item--deleted'> 543 + {trans('comments.deleted')} 544 + </span> 545 + } 546 + </div> 547 + 548 + {this.editing 549 + ? <div className='comment__editor'> 550 + <CommentEditor 551 + close={this.onCloseEdit} 552 + id={this.props.comment.id} 553 + message={this.props.comment.message} 554 + modifiers={this.props.modifiers} 555 + /> 556 + </div> 557 + : this.props.comment.messageHtml != null && 558 + <> 559 + <div 560 + className='comment__message' 561 + dangerouslySetInnerHTML={{ 562 + __html: this.props.comment.messageHtml, 563 + }} 564 + /> 565 + {this.isLongContent && this.renderToggleClipButton()} 566 + </> 567 + } 568 + 569 + {this.renderFooter()} 570 + 571 + {this.renderReplyBox()} 572 + </div> 573 + </div> 574 + 575 + {this.props.comment.repliesCount > 0 && 576 + <div className={classWithModifiers('comment__replies', { 577 + hidden: !this.expandReplies, 578 + indented: this.props.depth < maxDepth, 579 + })}> 580 + {this.replies.map(this.renderComment)} 581 + 582 + <DeletedCommentsCount comments={this.replies} /> 583 + 584 + <CommentShowMore 585 + ref={this.showMoreRef} 586 + comments={this.replies} 587 + label={this.replies.length === 0 ? trans('comments.load_replies') : undefined} 588 + modifiers={this.props.modifiers} 589 + parent={this.props.comment} 590 + total={this.props.comment.repliesCount} 591 + /> 592 + </div> 593 + } 594 + </> 595 + ); 596 + } 597 + 598 + private renderOwnerBadge() { 599 + const meta = this.meta; 600 + 601 + if (meta == null || !('owner_id' in meta) || meta.owner_id == null || this.props.comment.userId !== meta.owner_id) { 602 + return; 603 + } 604 + 605 + return ( 606 + <div className='comment__row-item'> 607 + <div className='comment__owner-badge'>{meta.owner_title}</div> 608 + </div> 609 + ); 610 + } 611 + 612 + private renderParentLink() { 613 + const parent = this.parentComment; 614 + 615 + if (parent == null) return; 616 + 617 + const parentUser = this.getUser(parent.userId); 618 + 619 + const content = ( 620 + <> 621 + <span className='fas fa-reply' /> 622 + {` ${parentUser.username}`} 623 + </> 624 + ); 625 + 626 + return ( 627 + <span className='comment__row-item comment__row-item--parent'> 628 + {this.props.linkParent 629 + ? ( 630 + <a 631 + className='comment__link' 632 + href={route('comments.show', { comment: parent.id })} 633 + title={this.parentPreview} 634 + > 635 + {content} 636 + </a> 637 + ) : ( 638 + <span title={this.parentPreview}>{content}</span> 639 + )} 640 + </span> 641 + ); 642 + } 643 + 644 + private renderPermalink() { 645 + return ( 646 + <div className='comment__row-item'> 647 + <span className='comment__action comment__action--permalink'> 648 + <ClickToCopy 649 + label={trans('common.buttons.permalink')} 650 + value={route('comments.show', { comment: this.props.comment.id })} 651 + valueAsUrl 652 + /> 653 + </span> 654 + </div> 655 + ); 656 + } 657 + 658 + private renderPin() { 659 + if (!this.props.comment.canPin) return; 660 + 661 + return ( 662 + <div className='comment__row-item'> 663 + <button 664 + className='comment__action' 665 + onClick={this.onTogglePinned} 666 + type='button' 667 + > 668 + {trans(`common.buttons.${this.props.comment.pinned ? 'unpin' : 'pin'}`)} 669 + </button> 670 + </div> 671 + ); 672 + } 673 + 674 + private renderRepliesText() { 675 + if (this.props.comment.repliesCount === 0) return; 676 + 677 + let label: string; 678 + let callback: () => void; 679 + 680 + if (!this.expandReplies && this.replies.length === 0) { 681 + callback = this.onLoadReplies; 682 + label = trans('comments.load_replies'); 683 + } else { 684 + callback = this.onToggleReplies; 685 + label = transChoice('comments.replies_count', this.props.comment.repliesCount); 686 + } 687 + 688 + return ( 689 + <div className='comment__row-item comment__row-item--replies'> 690 + <ShowMoreLink 691 + callback={callback} 692 + direction={this.expandReplies ? 'up' : 'down'} 693 + hasMore 694 + label={label} 695 + modifiers='comment-replies' 696 + /> 697 + </div> 698 + ); 699 + } 700 + 701 + private renderRepliesToggle() { 702 + if (this.props.depth > 0 || this.replies.length === 0) return; 703 + 704 + return ( 705 + <div className='comment__float-container comment__float-container--right'> 706 + <button 707 + className='comment__top-show-replies' 708 + onClick={this.onToggleReplies} 709 + type='button' 710 + > 711 + <span className={`fas ${this.expandReplies ? 'fa-angle-up' : 'fa-angle-down'}`} /> 712 + </button> 713 + </div> 714 + ); 715 + } 716 + 717 + private renderReplyBox() { 718 + if (!this.showNewReply) return; 719 + 720 + return ( 721 + <div className='comment__reply-box'> 722 + <CommentEditor 723 + close={this.onCloseReplyBox} 724 + commentableMeta={this.meta} 725 + modifiers={this.props.modifiers} 726 + onPosted={this.onReplyPosted} 727 + parent={this.props.comment} 728 + /> 729 + </div> 730 + ); 731 + } 732 + 733 + private renderReplyButton() { 734 + if (this.props.comment.isDeleted) return; 735 + 736 + return ( 737 + <div className='comment__row-item'> 738 + <button 739 + className={classWithModifiers('comment__action', { active: this.showNewReply })} 740 + onClick={this.onToggleNewReply} 741 + type='button' 742 + > 743 + {trans('common.buttons.reply')} 744 + </button> 745 + </div> 746 + ); 747 + } 748 + 749 + private renderReport() { 750 + if (!this.props.comment.canReport) return; 751 + 752 + return ( 753 + <div className='comment__row-item'> 754 + <ReportReportable 755 + className='comment__action' 756 + reportableId={this.props.comment.id.toString()} 757 + reportableType='comment' 758 + user={this.user} 759 + /> 760 + </div> 761 + ); 762 + } 763 + 764 + private renderRestore() { 765 + if (!this.props.comment.isDeleted || !this.props.comment.canRestore) return; 766 + 767 + return ( 768 + <div className='comment__row-item'> 769 + <button 770 + className='comment__action' 771 + onClick={this.onRestore} 772 + type='button' 773 + > 774 + {trans('common.buttons.restore')} 775 + </button> 776 + </div> 777 + ); 778 + } 779 + 780 + private renderToggleClipButton() { 781 + return ( 782 + <button 783 + className='comment__toggle-clip' 784 + onClick={this.onToggleClip} 785 + type='button' 786 + > 787 + {trans(`common.buttons.${this.clipped ? 'read_more' : 'show_less'}`)} 788 + </button> 789 + ); 790 + } 791 + 792 + private renderToolbar() { 793 + if (!this.props.showToolbar) return; 794 + 795 + return ( 796 + <div className='comment__toolbar'> 797 + <div className='sort'> 798 + <div className='sort__items'> 799 + <button 800 + className='sort__item sort__item--button' 801 + onClick={this.onShowDeletedToggleClick} 802 + type='button' 803 + > 804 + <span className='sort__item-icon'> 805 + <span className={core.userPreferences.get('comments_show_deleted') 806 + ?'fas fa-check-square' 807 + : 'far fa-square' 808 + } /> 809 + </span> 810 + {trans('common.buttons.show_deleted')} 811 + </button> 812 + </div> 813 + </div> 814 + </div> 815 + ); 816 + } 817 + 818 + private renderUserAvatar() { 819 + const user = this.user; 820 + 821 + return ('id' in user) 822 + ? ( 823 + <a 824 + className='comment__avatar js-usercard' 825 + data-user-id={user.id} 826 + href={route('users.show', { user: user.id })} 827 + > 828 + <UserAvatar modifiers='full-circle' user={user} /> 829 + </a> 830 + ) : ( 831 + <span className='comment__avatar'> 832 + <UserAvatar modifiers='full-circle' user={{ avatar_url: undefined, ...user }} /> 833 + </span> 834 + ); 835 + } 836 + 837 + private renderVoteButton(inline: boolean) { 838 + const hasVoted = this.hasVoted; 839 + 840 + const className = classWithModifiers('comment-vote', 841 + this.props.modifiers, 842 + { 843 + disabled: !this.props.comment.canVote, 844 + inline, 845 + on: hasVoted, 846 + posting: this.postingVote, 847 + }, 848 + ); 849 + 850 + const hover = !inline && !hasVoted 851 + ? <div className='comment-vote__hover'>+1</div> 852 + : null; 853 + 854 + return ( 855 + <button 856 + className={className} 857 + disabled={this.postingVote || !this.props.comment.canVote} 858 + onClick={this.onToggleVote} 859 + type='button' 860 + > 861 + <span className='comment-vote__text'> 862 + +{formatNumberSuffixed(this.props.comment.votesCount)} 863 + </span> 864 + {this.postingVote && 865 + <span className='comment-vote__spinner'> 866 + <Spinner /> 867 + </span> 868 + } 869 + {hover} 870 + </button> 871 + ); 872 + } 873 + }
+4 -2
resources/js/components/comments-manager.coffee
··· 91 91 92 92 return if uiState.comments.loadingFollow 93 93 94 - uiState.comments.loadingFollow = true 94 + runInAction -> 95 + uiState.comments.loadingFollow = !uiState.comments.userFollow 95 96 96 97 $.ajax route('follows.store'), 97 98 data: params 98 99 dataType: 'json' 99 100 method: if uiState.comments.userFollow then 'DELETE' else 'POST' 100 101 .always => 101 - uiState.comments.loadingFollow = false 102 + runInAction -> 103 + uiState.comments.loadingFollow = null 102 104 .done => 103 105 uiState.comments.userFollow = !uiState.comments.userFollow 104 106 .fail (xhr, status) =>
-25
resources/js/components/comments-sort.coffee
··· 1 - # Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - # See the LICENCE file in the repository root for full licence text. 3 - 4 - import { Observer } from 'mobx-react' 5 - import core from 'osu-core-singleton' 6 - import * as React from 'react' 7 - import { button, div } from 'react-dom-factories' 8 - import { Sort } from './sort' 9 - 10 - el = React.createElement 11 - 12 - uiState = core.dataStore.uiState 13 - 14 - export class CommentsSort extends React.PureComponent 15 - handleChange: (e) => 16 - $.publish 'comments:sort', sort: e.target.dataset.value 17 - 18 - 19 - render: => 20 - el Observer, null, () => 21 - el Sort, 22 - currentValue: uiState.comments.loadingSort ? uiState.comments.currentSort 23 - modifiers: @props.modifiers 24 - onChange: @handleChange 25 - values: ['new', 'old', 'top']
+32
resources/js/components/comments-sort.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import { observer } from 'mobx-react'; 5 + import core from 'osu-core-singleton'; 6 + import * as React from 'react'; 7 + import type { Modifiers } from 'utils/css'; 8 + import { Sort } from './sort'; 9 + 10 + const uiState = core.dataStore.uiState; 11 + 12 + interface Props { 13 + modifiers?: Modifiers; 14 + } 15 + 16 + @observer 17 + export default class CommentsSort extends React.Component<Props> { 18 + render() { 19 + return ( 20 + <Sort 21 + currentValue={uiState.comments.loadingSort ?? uiState.comments.currentSort} 22 + modifiers={this.props.modifiers} 23 + onChange={this.handleChange} 24 + values={['new', 'old', 'top']} 25 + /> 26 + ); 27 + } 28 + 29 + private handleChange(this: void, e: React.MouseEvent<HTMLButtonElement>) { 30 + $.publish('comments:sort', { sort: e.currentTarget.dataset.value }); 31 + } 32 + }
-125
resources/js/components/comments.coffee
··· 1 - # Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 - # See the LICENCE file in the repository root for full licence text. 3 - 4 - import { Observer } from 'mobx-react' 5 - import core from 'osu-core-singleton' 6 - import * as React from 'react' 7 - import { button, div, h2, span } from 'react-dom-factories' 8 - import { classWithModifiers, mergeModifiers } from 'utils/css' 9 - import { formatNumber } from 'utils/html' 10 - import { trans } from 'utils/lang' 11 - import { Comment } from './comment' 12 - import CommentEditor from './comment-editor' 13 - import CommentShowMore from './comment-show-more' 14 - import { CommentsSort } from './comments-sort' 15 - import DeletedCommentsCount from './deleted-comments-count' 16 - import { Spinner } from './spinner' 17 - 18 - el = React.createElement 19 - 20 - store = core.dataStore.commentStore 21 - uiState = core.dataStore.uiState 22 - 23 - export class Comments extends React.PureComponent 24 - render: => 25 - el Observer, null, () => 26 - # TODO: comments should be passed in as props? 27 - comments = uiState.comments.topLevelCommentIds.map (id) -> store.comments.get(id) 28 - pinnedComments = uiState.comments.pinnedCommentIds.map (id) -> store.comments.get(id) 29 - 30 - div className: classWithModifiers('comments', @props.modifiers), id: 'comments', 31 - h2 className: 'comments__title', 32 - trans('comments.title') 33 - span className: 'comments__count', formatNumber(uiState.comments.total) 34 - 35 - if pinnedComments.length > 0 36 - div className: "comments__items comments__items--pinned", 37 - @renderComments pinnedComments, true 38 - 39 - div className: 'comments__new', 40 - el CommentEditor, 41 - commentableMeta: @props.commentableMeta 42 - focus: false 43 - modifiers: @props.modifiers 44 - 45 - div className: 'comments__items comments__items--toolbar', 46 - el CommentsSort, 47 - modifiers: @props.modifiers 48 - div className: classWithModifiers('sort', @props.modifiers), 49 - div className: 'sort__items', 50 - @renderFollowToggle() 51 - @renderShowDeletedToggle() 52 - 53 - if comments.length > 0 54 - div className: "comments__items #{if uiState.comments.loadingSort? then 'comments__items--loading' else ''}", 55 - @renderComments comments, false 56 - 57 - el DeletedCommentsCount, { comments, modifiers: 'top' } 58 - 59 - el CommentShowMore, 60 - commentableMeta: @props.commentableMeta 61 - comments: comments 62 - modifiers: mergeModifiers 'top', @props.modifiers 63 - sort: uiState.comments.currentSort 64 - top: true 65 - total: uiState.comments.topLevelCount 66 - else 67 - div 68 - className: 'comments__items comments__items--empty' 69 - trans('comments.empty') 70 - 71 - 72 - renderComment: (comment, pinned = false) => 73 - return null if comment.isDeleted && !core.userPreferences.get('comments_show_deleted') 74 - 75 - el Comment, 76 - key: comment.id 77 - comment: comment 78 - depth: 0 79 - modifiers: @props.modifiers 80 - expandReplies: if pinned then false else null 81 - 82 - 83 - renderComments: (comments, pinned) => 84 - @renderComment(comment, pinned) for comment in comments when comment.pinned == pinned 85 - 86 - 87 - renderShowDeletedToggle: => 88 - button 89 - type: 'button' 90 - className: 'sort__item sort__item--button' 91 - onClick: @toggleShowDeleted 92 - span className: 'sort__item-icon', 93 - span className: if core.userPreferences.get('comments_show_deleted') then 'fas fa-check-square' else 'far fa-square' 94 - trans('common.buttons.show_deleted') 95 - 96 - 97 - renderFollowToggle: => 98 - if uiState.comments.userFollow 99 - icon = 'fas fa-eye-slash' 100 - label = trans('common.buttons.watch.to_0') 101 - else 102 - icon = 'fas fa-eye' 103 - label = trans('common.buttons.watch.to_1') 104 - 105 - iconEl = 106 - if @props.loadingFollow 107 - el Spinner, modifiers: ['center-inline'] 108 - else 109 - span className: icon 110 - 111 - button 112 - type: 'button' 113 - className: 'sort__item sort__item--button' 114 - onClick: @toggleFollow 115 - disabled: @props.loadingFollow 116 - span className: 'sort__item-icon', iconEl 117 - label 118 - 119 - 120 - toggleShowDeleted: -> 121 - core.userPreferences.set('comments_show_deleted', !core.userPreferences.get('comments_show_deleted')) 122 - 123 - 124 - toggleFollow: -> 125 - $.publish 'comments:toggle-follow'
+203
resources/js/components/comments.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import { CommentableMetaJson } from 'interfaces/comment-json'; 5 + import { computed, makeObservable } from 'mobx'; 6 + import { observer } from 'mobx-react'; 7 + import { Comment as CommentModel } from 'models/comment'; 8 + import core from 'osu-core-singleton'; 9 + import * as React from 'react'; 10 + import { classWithModifiers, mergeModifiers, Modifiers } from 'utils/css'; 11 + import { formatNumber } from 'utils/html'; 12 + import { trans } from 'utils/lang'; 13 + import Comment from './comment'; 14 + import CommentEditor from './comment-editor'; 15 + import CommentShowMore from './comment-show-more'; 16 + import CommentsSort from './comments-sort'; 17 + import DeletedCommentsCount from './deleted-comments-count'; 18 + import { Spinner } from './spinner'; 19 + 20 + const store = core.dataStore.commentStore; 21 + const uiState = core.dataStore.uiState; 22 + 23 + interface Props { 24 + commentableMeta: CommentableMetaJson; 25 + modifiers?: Modifiers; 26 + } 27 + 28 + @observer 29 + export default class Comments extends React.Component<Props> { 30 + @computed 31 + private get comments() { 32 + const ret = []; 33 + for (const id of uiState.comments.topLevelCommentIds) { 34 + const comment = store.comments.get(id); 35 + 36 + if (comment != null && !comment.pinned) { 37 + ret.push(comment); 38 + } 39 + } 40 + 41 + return ret; 42 + } 43 + 44 + @computed 45 + private get pinnedComments() { 46 + const ret = []; 47 + for (const id of uiState.comments.pinnedCommentIds) { 48 + const comment = store.comments.get(id); 49 + 50 + if (comment != null) { 51 + ret.push(comment); 52 + } 53 + } 54 + 55 + return ret; 56 + } 57 + 58 + constructor(props: Props) { 59 + super(props); 60 + 61 + makeObservable(this); 62 + } 63 + 64 + render() { 65 + const comments = this.comments; 66 + const pinnedComments = this.pinnedComments; 67 + 68 + return ( 69 + <div 70 + className={classWithModifiers('comments', this.props.modifiers)} 71 + id='comments' 72 + > 73 + <h2 className='comments__title'> 74 + {trans('comments.title')} 75 + <span className='comments__count'>{formatNumber(uiState.comments.total)}</span> 76 + </h2> 77 + 78 + {pinnedComments.length > 0 && 79 + <div className='comments__items comments__items--pinned'> 80 + {this.renderComments(pinnedComments, true)} 81 + </div> 82 + } 83 + 84 + <div className='comments__new'> 85 + <CommentEditor 86 + commentableMeta={this.props.commentableMeta} 87 + focus={false} 88 + modifiers={this.props.modifiers} 89 + /> 90 + </div> 91 + 92 + <div className='comments__items comments__items--toolbar'> 93 + <CommentsSort modifiers={this.props.modifiers} /> 94 + <div className={classWithModifiers('sort', this.props.modifiers)}> 95 + <div className='sort__items'> 96 + {this.renderFollowToggle()} 97 + {this.renderShowDeletedToggle()} 98 + </div> 99 + </div> 100 + </div> 101 + 102 + {comments.length === 0 103 + ? ( 104 + <div className='comments__items comments__items--empty'> 105 + {pinnedComments.length === 0 ? trans('comments.empty') : trans('comments.empty_other')} 106 + </div> 107 + ) : ( 108 + <div className={classWithModifiers('comments__items', { loading: uiState.comments.loadingSort != null })}> 109 + {this.renderComments(comments, false)} 110 + 111 + <DeletedCommentsCount comments={comments} modifiers='top' /> 112 + 113 + <CommentShowMore 114 + commentableMeta={this.props.commentableMeta} 115 + comments={comments} 116 + modifiers={mergeModifiers('top', this.props.modifiers)} 117 + top 118 + total={uiState.comments.topLevelCount} 119 + /> 120 + </div> 121 + )} 122 + </div> 123 + ); 124 + } 125 + 126 + private onToggleFollow(this: void) { 127 + $.publish('comments:toggle-follow'); 128 + } 129 + 130 + private onToggleShowDeleted(this: void) { 131 + core.userPreferences.set('comments_show_deleted', !core.userPreferences.get('comments_show_deleted')); 132 + } 133 + 134 + private renderComment(comment: CommentModel, expandReplies?: boolean) { 135 + if (comment.isDeleted && !core.userPreferences.get('comments_show_deleted')) { 136 + return; 137 + } 138 + 139 + return ( 140 + <Comment 141 + key={comment.id} 142 + comment={comment} 143 + depth={0} 144 + expandReplies={expandReplies} 145 + modifiers={this.props.modifiers} 146 + /> 147 + ); 148 + } 149 + 150 + private renderComments(comments: CommentModel[], pinned: boolean) { 151 + const expandReplies = pinned ? false : undefined; 152 + 153 + return comments.map((comment) => this.renderComment(comment, expandReplies)); 154 + } 155 + 156 + private renderFollowToggle() { 157 + let icon: React.ReactNode; 158 + let label: string; 159 + 160 + if (uiState.comments.loadingFollow != null) { 161 + icon = <Spinner modifiers='center-inline' />; 162 + } 163 + 164 + if (uiState.comments.userFollow) { 165 + icon ??= <span className='fas fa-eye-slash' />; 166 + label = trans('common.buttons.watch.to_0'); 167 + } else { 168 + icon ??= <span className='fas fa-eye' />; 169 + label = trans('common.buttons.watch.to_1'); 170 + } 171 + 172 + return ( 173 + <button 174 + className='sort__item sort__item--button' 175 + disabled={uiState.comments.loadingFollow != null} 176 + onClick={this.onToggleFollow} 177 + type='button' 178 + > 179 + <span className='sort__item-icon'>{icon}</span> 180 + {label} 181 + </button> 182 + ); 183 + } 184 + 185 + private renderShowDeletedToggle() { 186 + const iconClass = core.userPreferences.get('comments_show_deleted') 187 + ? 'fas fa-check-square' 188 + : 'far fa-square'; 189 + 190 + return ( 191 + <button 192 + className='sort__item sort__item--button' 193 + onClick={this.onToggleShowDeleted} 194 + type='button' 195 + > 196 + <span className='sort__item-icon'> 197 + <span className={iconClass} /> 198 + </span> 199 + {trans('common.buttons.show_deleted')} 200 + </button> 201 + ); 202 + } 203 + }
+2 -2
resources/js/components/deleted-comments-count.tsx
··· 3 3 4 4 import { Comment } from 'models/comment'; 5 5 import * as React from 'react'; 6 - import { classWithModifiers } from 'utils/css'; 6 + import { classWithModifiers, Modifiers } from 'utils/css'; 7 7 import { transChoice } from 'utils/lang'; 8 8 9 9 interface Props { 10 10 comments: Comment[]; 11 - modifiers: string[] | undefined; 11 + modifiers?: Modifiers; 12 12 } 13 13 14 14 export default class DeletedCommentsCount extends React.Component<Props> {
+2 -2
resources/js/components/sort.tsx
··· 3 3 4 4 import core from 'osu-core-singleton'; 5 5 import * as React from 'react'; 6 - import { classWithModifiers } from 'utils/css'; 6 + import { classWithModifiers, Modifiers } from 'utils/css'; 7 7 import { trans } from 'utils/lang'; 8 8 9 9 interface Props { 10 10 currentValue: string; 11 - modifiers?: string[]; 11 + modifiers?: Modifiers; 12 12 onChange(event: React.MouseEvent<HTMLButtonElement>): void; 13 13 showTitle?: boolean; 14 14 title?: string;
+4 -3
resources/js/contest-voting/art-entry-list.coffee
··· 14 14 15 15 selected = new Set(@state.selected) 16 16 17 - entries = @state.contest.entries.map (entry, index) => 17 + displayIndex = -1 18 + entries = @state.contest.entries.map (entry) => 18 19 isSelected = selected.has(entry.id) 19 20 20 21 return null if @state.showVotedOnly && !isSelected 21 22 22 23 el ArtEntry, 23 - key: index, 24 + key: entry.id, 24 25 contest: @state.contest, 25 - displayIndex: index, 26 + displayIndex: ++displayIndex, 26 27 entry: entry, 27 28 isSelected: isSelected 28 29 options: @state.options,
+3 -1
resources/js/core/react-turbolinks.ts
··· 4 4 import { removeLeftoverPortalContainers } from 'components/portal'; 5 5 import TurbolinksReload from 'core/turbolinks-reload'; 6 6 import { runInAction } from 'mobx'; 7 + import OsuCore from 'osu-core'; 7 8 import * as React from 'react'; 8 9 import * as ReactDOM from 'react-dom'; 9 10 import { currentUrl } from 'utils/turbolinks'; ··· 18 19 private scrolled = false; 19 20 private timeoutScroll?: number; 20 21 21 - constructor(private turbolinksReload: TurbolinksReload) { 22 + constructor(private core: OsuCore, private turbolinksReload: TurbolinksReload) { 22 23 $(document).on('turbolinks:before-cache', this.handleBeforeCache); 23 24 $(document).on('turbolinks:before-visit', this.handleBeforeVisit); 24 25 $(document).on('turbolinks:load', this.handleLoad); ··· 84 85 this.setNewUrl(); 85 86 this.pageReady = true; 86 87 removeLeftoverPortalContainers(); 88 + this.core.updateCurrentUser(); 87 89 this.loadScripts(false); 88 90 this.boot(); 89 91 };
+10
resources/js/entrypoints/account-edit.tsx
··· 3 3 4 4 import { ClientJson } from 'interfaces/client-json'; 5 5 import { OwnClientJson } from 'interfaces/own-client-json'; 6 + import LegacyApiKey from 'legacy-api-key'; 7 + import LegacyIrcKey from 'legacy-irc-key'; 6 8 import { AuthorizedClients } from 'oauth/authorized-clients'; 7 9 import { OwnClients } from 'oauth/own-clients'; 8 10 import core from 'osu-core-singleton'; ··· 17 19 18 20 return <AuthorizedClients />; 19 21 }); 22 + 23 + core.reactTurbolinks.register('legacy-api-key', (container: HTMLElement) => ( 24 + <LegacyApiKey container={container} /> 25 + )); 26 + 27 + core.reactTurbolinks.register('legacy-irc-key', (container: HTMLElement) => ( 28 + <LegacyIrcKey container={container} /> 29 + )); 20 30 21 31 core.reactTurbolinks.register('own-clients', () => { 22 32 const json = parseJsonNullable<OwnClientJson[]>('json-own-clients', true);
+2 -2
resources/js/entrypoints/comments-index.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 { Main } from 'comments-index/main' 4 + import CommentsIndex from 'comments-index' 5 5 import { CommentsManager } from 'components/comments-manager' 6 6 import core from 'osu-core-singleton' 7 7 import { createElement } from 'react' ··· 13 13 core.dataStore.uiState.initializeWithCommentBundleJson(commentBundle) 14 14 15 15 createElement CommentsManager, 16 - component: Main 16 + component: CommentsIndex 17 17 user: commentBundle.user
+2 -2
resources/js/entrypoints/comments-show.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 { Main } from 'comments-show/main' 4 + import CommentsShow from 'comments-show' 5 5 import { CommentsManager } from 'components/comments-manager' 6 6 import core from 'osu-core-singleton' 7 7 import { createElement } from 'react' ··· 13 13 core.dataStore.uiState.initializeWithCommentBundleJson(commentBundle) 14 14 15 15 createElement CommentsManager, 16 - component: Main 16 + component: CommentsShow
+1 -1
resources/js/interfaces/beatmapset-discussion-post-responses.ts
··· 1 1 // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 - import { BeatmapsetWithDiscussionsJson } from './beatmapset-json'; 4 + import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; 5 5 6 6 export interface BeatmapsetDiscussionPostStoreResponseJson { 7 7 beatmap_discussion_id: number;
+2 -4
resources/js/interfaces/beatmapset-json.ts
··· 50 50 can_delete: boolean; 51 51 can_edit_metadata: boolean; 52 52 can_edit_offset: boolean; 53 + can_edit_tags: boolean; 53 54 can_hype: boolean; 54 55 can_hype_reason: string; 55 56 can_love: boolean; 56 57 can_remove_from_loved: boolean; 57 58 is_watching: boolean; 58 - new_hype_time: string; 59 + new_hype_time: string | null; 59 60 nomination_modes: Partial<Record<GameMode, 'full' | 'limited'>>; 60 61 remaining_hype: number; 61 62 } ··· 107 108 108 109 type BeatmapsetJson = BeatmapsetJsonDefaultAttributes & Partial<BeatmapsetJsonAvailableIncludes>; 109 110 export default BeatmapsetJson; 110 - 111 - type DiscussionsRequiredAttributes = 'beatmaps' | 'current_user_attributes' | 'discussions' | 'events' | 'nominations' | 'related_users'; 112 - export type BeatmapsetWithDiscussionsJson = BeatmapsetJson & Required<Pick<BeatmapsetJson, DiscussionsRequiredAttributes>>;
+9
resources/js/interfaces/beatmapset-with-discussions-json.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import BeatmapsetExtendedJson from './beatmapset-extended-json'; 5 + 6 + type DiscussionsRequiredAttributes = 'beatmaps' | 'current_user_attributes' | 'discussions' | 'events' | 'nominations' | 'related_users'; 7 + type BeatmapsetWithDiscussionsJson = BeatmapsetExtendedJson & Required<Pick<BeatmapsetExtendedJson, DiscussionsRequiredAttributes>>; 8 + 9 + export default BeatmapsetWithDiscussionsJson;
+8
resources/js/interfaces/legacy-api-key-json.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + export default interface LegacyApiKeyJson { 5 + api_key: string; 6 + app_name: string; 7 + app_url: string; 8 + }
+6
resources/js/interfaces/legacy-irc-key-json.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + export default interface LegacyIrcKeyJson { 5 + token: string; 6 + }
+85
resources/js/legacy-api-key/controller.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import LegacyApiKeyJson from 'interfaces/legacy-api-key-json'; 5 + import { route } from 'laroute'; 6 + import { action, makeObservable, observable, reaction, runInAction } from 'mobx'; 7 + import { onError } from 'utils/ajax'; 8 + 9 + interface State { 10 + legacy_api_key: LegacyApiKeyJson | null; 11 + showing_form: boolean; 12 + } 13 + 14 + type DatasetState = Partial<State> & Required<Pick<State, 'legacy_api_key'>>; 15 + 16 + export default class Controller { 17 + @observable state; 18 + private stateSyncDisposer; 19 + @observable private xhrCreate?: JQuery.jqXHR<LegacyApiKeyJson>; 20 + @observable private xhrDelete?: JQuery.jqXHR<void>; 21 + 22 + get isCreating() { 23 + return this.xhrCreate != null; 24 + } 25 + 26 + get isDeleting() { 27 + return this.xhrDelete != null; 28 + } 29 + 30 + get key() { 31 + return this.state.legacy_api_key; 32 + } 33 + 34 + constructor(private container: HTMLElement) { 35 + this.state = { 36 + ...JSON.parse(container.dataset.state ?? '') as DatasetState, 37 + showing_form: false, 38 + }; 39 + 40 + makeObservable(this); 41 + 42 + this.stateSyncDisposer = reaction( 43 + () => JSON.stringify(this.state), 44 + (stateString) => this.container.dataset.state = stateString, 45 + ); 46 + } 47 + 48 + @action 49 + createKey(appName: string, appUrl: string) { 50 + this.xhrCreate = $.ajax(route('legacy-api-key.store'), { 51 + data: { 52 + legacy_api_key: { 53 + app_name: appName, 54 + app_url: appUrl, 55 + }, 56 + }, 57 + method: 'POST', 58 + }); 59 + this.xhrCreate 60 + .done((json) => runInAction(() => { 61 + this.state.legacy_api_key = json; 62 + })).always(action(() => { 63 + this.xhrCreate = undefined; 64 + })); 65 + 66 + return this.xhrCreate; 67 + } 68 + 69 + @action 70 + deleteKey() { 71 + this.xhrDelete = $.ajax(route('legacy-api-key.destroy'), { method: 'DELETE' }) 72 + .fail(onError) 73 + .done(action(() => { 74 + this.state.legacy_api_key = null; 75 + })).always(action(() => { 76 + this.xhrDelete = undefined; 77 + })); 78 + } 79 + 80 + destroy() { 81 + this.xhrCreate?.abort(); 82 + this.xhrDelete?.abort(); 83 + this.stateSyncDisposer(); 84 + } 85 + }
+117
resources/js/legacy-api-key/details.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import BigButton from 'components/big-button'; 5 + import StringWithComponent from 'components/string-with-component'; 6 + import { action, makeObservable, observable } from 'mobx'; 7 + import { observer } from 'mobx-react'; 8 + import * as React from 'react'; 9 + import { trans } from 'utils/lang'; 10 + import Controller from './controller'; 11 + 12 + const docsUrl = 'https://github.com/ppy/osu-api/wiki'; 13 + 14 + interface Props { 15 + controller: Controller; 16 + } 17 + 18 + @observer 19 + export default class Details extends React.Component<Props> { 20 + @observable private keyVisible = false; 21 + 22 + constructor(props: Props) { 23 + super(props); 24 + 25 + makeObservable(this); 26 + } 27 + 28 + render() { 29 + const key = this.props.controller.key; 30 + 31 + if (key == null) { 32 + throw new Error('rendering Key component with no key available'); 33 + } 34 + 35 + return ( 36 + <div className='legacy-api-details'> 37 + <div className='legacy-api-details__content'> 38 + <div className='legacy-api-details__entry'> 39 + <div className='legacy-api-details__label'> 40 + {trans('model_validation.legacy_api_key.attributes.app_name')} 41 + </div> 42 + <div className='legacy-api-details__value'> 43 + {key.app_name} 44 + </div> 45 + </div> 46 + <div className='legacy-api-details__entry'> 47 + <div className='legacy-api-details__label'> 48 + {trans('model_validation.legacy_api_key.attributes.app_url')} 49 + </div> 50 + <div className='legacy-api-details__value'> 51 + {key.app_url} 52 + </div> 53 + </div> 54 + <div className='legacy-api-details__entry'> 55 + <div className='legacy-api-details__label'> 56 + {trans('model_validation.legacy_api_key.attributes.api_key')} 57 + </div> 58 + <div className='legacy-api-details__value'> 59 + {this.keyVisible ? key.api_key : '***'} 60 + </div> 61 + </div> 62 + <div className='legacy-api-details__entry'> 63 + <div className='legacy-api-details__value'> 64 + <div> 65 + {trans('legacy_api_key.warning.line1')}<br /> 66 + {trans('legacy_api_key.warning.line2')}<br /> 67 + {trans('legacy_api_key.warning.line3')} 68 + </div> 69 + </div> 70 + </div> 71 + <div className='legacy-api-details__entry'> 72 + <div className='legacy-api-details__value'> 73 + <StringWithComponent 74 + mappings={{ github: ( 75 + <a href={docsUrl}> 76 + {trans('legacy_api_key.docs.github')} 77 + </a> 78 + ) }} 79 + pattern={trans('legacy_api_key.docs._')} 80 + /> 81 + </div> 82 + </div> 83 + </div> 84 + <div className='legacy-api-details__actions'> 85 + <BigButton 86 + icon={this.keyVisible ? 'fas fa-eye-slash' : 'fas fa-eye'} 87 + modifiers={['account-edit', 'settings-oauth']} 88 + props={{ 89 + onClick: this.onClickToggleKeyVisibility, 90 + }} 91 + text={trans(`legacy_api_key.view.${this.keyVisible ? 'hide' : 'show'}`)} 92 + /> 93 + <BigButton 94 + icon='fas fa-trash' 95 + isBusy={this.props.controller.isDeleting} 96 + modifiers={['account-edit', 'danger', 'settings-oauth']} 97 + props={{ 98 + onClick: this.deleteClicked, 99 + }} 100 + text={trans('legacy_api_key.view.delete')} 101 + /> 102 + </div> 103 + </div> 104 + ); 105 + } 106 + 107 + private readonly deleteClicked = () => { 108 + if (!confirm(trans('common.confirmation'))) return; 109 + 110 + this.props.controller.deleteKey(); 111 + }; 112 + 113 + @action 114 + private readonly onClickToggleKeyVisibility = () => { 115 + this.keyVisible = !this.keyVisible; 116 + }; 117 + }
+118
resources/js/legacy-api-key/form.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import { Spinner } from 'components/spinner'; 5 + import StringWithComponent from 'components/string-with-component'; 6 + import { ValidatingInput } from 'components/validating-input'; 7 + import { FormErrors } from 'form-errors'; 8 + import { action, makeObservable, observable } from 'mobx'; 9 + import { observer } from 'mobx-react'; 10 + import * as React from 'react'; 11 + import { trans } from 'utils/lang'; 12 + import Controller from './controller'; 13 + 14 + interface Props { 15 + controller: Controller; 16 + } 17 + 18 + @observer 19 + export default class Form extends React.Component<Props> { 20 + @observable private appName = ''; 21 + @observable private appUrl = ''; 22 + private errors = new FormErrors(); 23 + 24 + constructor(props: Props) { 25 + super(props); 26 + 27 + makeObservable(this); 28 + } 29 + 30 + @action 31 + readonly onCancelClick = () => { 32 + if (this.appUrl !== '' || this.appName !== '') { 33 + if (!confirm(trans('common.confirmation'))) return; 34 + } 35 + 36 + this.props.controller.state.showing_form = false; 37 + }; 38 + 39 + render() { 40 + return ( 41 + <div className='oauth-client-details'> 42 + <div className='oauth-client-details__header'> 43 + {trans('legacy_api_key.new')} 44 + </div> 45 + 46 + <form className='oauth-client-details__content'> 47 + <label className='oauth-client-details__group'> 48 + <div className='oauth-client-details__label'>{trans('model_validation.legacy_api_key.attributes.app_name')}</div> 49 + <ValidatingInput 50 + blockName='oauth-client-details' 51 + errors={this.errors} 52 + name='legacy_api_key[app_name]' 53 + onInput={this.onAppNameInput} 54 + required 55 + value={this.appName} 56 + /> 57 + </label> 58 + 59 + <label className='oauth-client-details__group'> 60 + <div className='oauth-client-details__label'>{trans('model_validation.legacy_api_key.attributes.app_url')}</div> 61 + <ValidatingInput 62 + blockName='oauth-client-details' 63 + errors={this.errors} 64 + name='legacy_api_key[app_url]' 65 + onInput={this.onAppUrlInput} 66 + required 67 + type='url' 68 + value={this.appUrl} 69 + /> 70 + </label> 71 + 72 + <div> 73 + <StringWithComponent 74 + mappings={{ link: ( 75 + <a href={`${process.env.DOCS_URL}#terms-of-use`}> 76 + {trans('oauth.new_client.terms_of_use.link')} 77 + </a> 78 + ) }} 79 + pattern={trans('oauth.new_client.terms_of_use._')} 80 + /> 81 + </div> 82 + 83 + <div className='oauth-client-details__buttons'> 84 + <button className='btn-osu-big' onClick={this.onSubmit}> 85 + {this.props.controller.isCreating ? <Spinner /> : trans('legacy_api_key.form.create')} 86 + </button> 87 + <button className='btn-osu-big' onClick={this.onCancelClick} type='button'>{trans('common.buttons.cancel')}</button> 88 + </div> 89 + </form> 90 + </div> 91 + ); 92 + } 93 + 94 + @action 95 + private readonly onAppNameInput = (e: React.KeyboardEvent<HTMLInputElement>) => { 96 + this.appName = e.currentTarget.value; 97 + }; 98 + 99 + @action 100 + private readonly onAppUrlInput = (e: React.KeyboardEvent<HTMLInputElement>) => { 101 + this.appUrl = e.currentTarget.value; 102 + }; 103 + 104 + @action 105 + private readonly onSubmit = (e: React.SyntheticEvent) => { 106 + e.preventDefault(); 107 + 108 + if (this.props.controller.isCreating) { 109 + return; 110 + } 111 + 112 + this.props.controller.createKey(this.appName, this.appUrl) 113 + .fail(this.errors.handleResponse) 114 + .done(action(() => { 115 + this.props.controller.state.showing_form = false; 116 + })); 117 + }; 118 + }
+83
resources/js/legacy-api-key/index.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import BigButton from 'components/big-button'; 5 + import Modal from 'components/modal'; 6 + import { action, makeObservable } from 'mobx'; 7 + import { observer } from 'mobx-react'; 8 + import * as React from 'react'; 9 + import { trans } from 'utils/lang'; 10 + import Controller from './controller'; 11 + import Details from './details'; 12 + import Form from './form'; 13 + 14 + interface Props { 15 + container: HTMLElement; 16 + } 17 + 18 + @observer 19 + export default class LegacyApiKey extends React.Component<Props> { 20 + private controller; 21 + private readonly formRef = React.createRef<Form>(); 22 + 23 + constructor(props: Props) { 24 + super(props); 25 + 26 + this.controller = new Controller(this.props.container); 27 + 28 + makeObservable(this); 29 + } 30 + 31 + componentWillUnmount() { 32 + this.controller.destroy(); 33 + } 34 + 35 + render() { 36 + return this.controller.key == null ? this.renderEmpty() : this.renderDetails(); 37 + } 38 + 39 + private readonly onModalClose = () => { 40 + this.formRef.current?.onCancelClick(); 41 + }; 42 + 43 + @action 44 + private readonly onNewKeyClick = () => { 45 + this.controller.state.showing_form = true; 46 + }; 47 + 48 + private renderDetails() { 49 + return <Details controller={this.controller} />; 50 + } 51 + 52 + private renderEmpty() { 53 + return ( 54 + <> 55 + <p> 56 + {trans('legacy_api_key.none')} 57 + </p> 58 + <div> 59 + <BigButton 60 + icon='fas fa-plus' 61 + props={{ 62 + onClick: this.onNewKeyClick, 63 + }} 64 + text={trans('legacy_api_key.new')} 65 + /> 66 + </div> 67 + {this.renderForm()} 68 + </> 69 + ); 70 + } 71 + 72 + private renderForm() { 73 + if (!this.controller.state.showing_form) { 74 + return null; 75 + } 76 + 77 + return ( 78 + <Modal onClose={this.onModalClose}> 79 + <Form ref={this.formRef} controller={this.controller} /> 80 + </Modal> 81 + ); 82 + } 83 + }
+73
resources/js/legacy-irc-key/controller.ts
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import LegacyIrcKeyJson from 'interfaces/legacy-irc-key-json'; 5 + import { route } from 'laroute'; 6 + import { action, makeObservable, observable, reaction, runInAction } from 'mobx'; 7 + import { onError } from 'utils/ajax'; 8 + 9 + interface State { 10 + legacy_irc_key: LegacyIrcKeyJson | null; 11 + } 12 + 13 + export default class Controller { 14 + @observable state; 15 + private stateSyncDisposer; 16 + @observable private xhrCreate?: JQuery.jqXHR<LegacyIrcKeyJson>; 17 + @observable private xhrDelete?: JQuery.jqXHR<void>; 18 + 19 + get isCreating() { 20 + return this.xhrCreate != null; 21 + } 22 + 23 + get isDeleting() { 24 + return this.xhrDelete != null; 25 + } 26 + 27 + get key() { 28 + return this.state.legacy_irc_key; 29 + } 30 + 31 + constructor(private container: HTMLElement) { 32 + this.state = JSON.parse(container.dataset.state ?? '') as State; 33 + 34 + makeObservable(this); 35 + 36 + this.stateSyncDisposer = reaction( 37 + () => JSON.stringify(this.state), 38 + (stateString) => this.container.dataset.state = stateString, 39 + ); 40 + } 41 + 42 + @action 43 + createKey() { 44 + this.xhrCreate = $.ajax(route('legacy-irc-key.store'), { 45 + method: 'POST', 46 + }); 47 + this.xhrCreate 48 + .done((json) => runInAction(() => { 49 + this.state.legacy_irc_key = json; 50 + })).always(action(() => { 51 + this.xhrCreate = undefined; 52 + })); 53 + 54 + return this.xhrCreate; 55 + } 56 + 57 + @action 58 + deleteKey() { 59 + this.xhrDelete = $.ajax(route('legacy-irc-key.destroy'), { method: 'DELETE' }) 60 + .fail(onError) 61 + .done(action(() => { 62 + this.state.legacy_irc_key = null; 63 + })).always(action(() => { 64 + this.xhrDelete = undefined; 65 + })); 66 + } 67 + 68 + destroy() { 69 + this.xhrCreate?.abort(); 70 + this.xhrDelete?.abort(); 71 + this.stateSyncDisposer(); 72 + } 73 + }
+111
resources/js/legacy-irc-key/details.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import BigButton from 'components/big-button'; 5 + import { action, makeObservable, observable } from 'mobx'; 6 + import { observer } from 'mobx-react'; 7 + import core from 'osu-core-singleton'; 8 + import * as React from 'react'; 9 + import { trans } from 'utils/lang'; 10 + import Controller from './controller'; 11 + 12 + const serverHost = 'irc.ppy.sh'; 13 + const serverPort = '6667'; 14 + 15 + interface Props { 16 + controller: Controller; 17 + } 18 + 19 + @observer 20 + export default class Details extends React.Component<Props> { 21 + @observable private keyVisible = false; 22 + 23 + constructor(props: Props) { 24 + super(props); 25 + 26 + makeObservable(this); 27 + } 28 + 29 + render() { 30 + const key = this.props.controller.key; 31 + 32 + if (key == null) { 33 + throw new Error('rendering Details component with no key available'); 34 + } 35 + 36 + const user = core.currentUser; 37 + 38 + if (user == null) { 39 + throw new Error('rendering Details component with no current user available'); 40 + } 41 + 42 + return ( 43 + <div className='legacy-api-details'> 44 + <div className='legacy-api-details__content'> 45 + <div className='legacy-api-details__entry'> 46 + <div className='legacy-api-details__label'> 47 + {trans('legacy_irc_key.form.server_host')} 48 + </div> 49 + <div className='legacy-api-details__value'> 50 + {serverHost} 51 + </div> 52 + </div> 53 + <div className='legacy-api-details__entry'> 54 + <div className='legacy-api-details__label'> 55 + {trans('legacy_irc_key.form.server_port')} 56 + </div> 57 + <div className='legacy-api-details__value'> 58 + {serverPort} 59 + </div> 60 + </div> 61 + <div className='legacy-api-details__entry'> 62 + <div className='legacy-api-details__label'> 63 + {trans('legacy_irc_key.form.username')} 64 + </div> 65 + <div className='legacy-api-details__value'> 66 + {user.username} 67 + </div> 68 + </div> 69 + <div className='legacy-api-details__entry'> 70 + <div className='legacy-api-details__label'> 71 + {trans('legacy_irc_key.form.token')} 72 + </div> 73 + <div className='legacy-api-details__value'> 74 + {this.keyVisible ? key.token : '***'} 75 + </div> 76 + </div> 77 + </div> 78 + <div className='legacy-api-details__actions'> 79 + <BigButton 80 + icon={this.keyVisible ? 'fas fa-eye-slash' : 'fas fa-eye'} 81 + modifiers={['account-edit', 'settings-oauth']} 82 + props={{ 83 + onClick: this.onClickToggleKeyVisibility, 84 + }} 85 + text={trans(`legacy_irc_key.view.${this.keyVisible ? 'hide' : 'show'}`)} 86 + /> 87 + <BigButton 88 + icon='fas fa-trash' 89 + isBusy={this.props.controller.isDeleting} 90 + modifiers={['account-edit', 'danger', 'settings-oauth']} 91 + props={{ 92 + onClick: this.deleteClicked, 93 + }} 94 + text={trans('legacy_irc_key.view.delete')} 95 + /> 96 + </div> 97 + </div> 98 + ); 99 + } 100 + 101 + private readonly deleteClicked = () => { 102 + if (!confirm(trans('common.confirmation'))) return; 103 + 104 + this.props.controller.deleteKey(); 105 + }; 106 + 107 + @action 108 + private readonly onClickToggleKeyVisibility = () => { 109 + this.keyVisible = !this.keyVisible; 110 + }; 111 + }
+65
resources/js/legacy-irc-key/index.tsx
··· 1 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 2 + // See the LICENCE file in the repository root for full licence text. 3 + 4 + import BigButton from 'components/big-button'; 5 + import { action, makeObservable } from 'mobx'; 6 + import { observer } from 'mobx-react'; 7 + import * as React from 'react'; 8 + import { trans } from 'utils/lang'; 9 + import Controller from './controller'; 10 + import Details from './details'; 11 + 12 + interface Props { 13 + container: HTMLElement; 14 + } 15 + 16 + @observer 17 + export default class LegacyIrcKey extends React.Component<Props> { 18 + private controller; 19 + 20 + constructor(props: Props) { 21 + super(props); 22 + 23 + this.controller = new Controller(this.props.container); 24 + 25 + makeObservable(this); 26 + } 27 + 28 + componentWillUnmount() { 29 + this.controller.destroy(); 30 + } 31 + 32 + render() { 33 + return this.controller.key == null ? this.renderEmpty() : this.renderDetails(); 34 + } 35 + 36 + @action 37 + private readonly onNewKeyClick = () => { 38 + if (!confirm(trans('legacy_irc_key.confirm_new'))) return; 39 + 40 + this.controller.createKey(); 41 + }; 42 + 43 + private renderDetails() { 44 + return <Details controller={this.controller} />; 45 + } 46 + 47 + private renderEmpty() { 48 + return ( 49 + <> 50 + <p> 51 + {trans('legacy_irc_key.none')} 52 + </p> 53 + <div> 54 + <BigButton 55 + icon='fas fa-plus' 56 + props={{ 57 + onClick: this.onNewKeyClick, 58 + }} 59 + text={trans('legacy_irc_key.new')} 60 + /> 61 + </div> 62 + </> 63 + ); 64 + } 65 + }
+11 -5
resources/js/models/oauth/own-client.ts
··· 3 3 4 4 import { OwnClientJson } from 'interfaces/own-client-json'; 5 5 import { route } from 'laroute'; 6 - import { action, makeObservable, observable } from 'mobx'; 6 + import { action, computed, makeObservable, observable } from 'mobx'; 7 7 import { Client } from 'models/oauth/client'; 8 8 9 9 export class OwnClient extends Client { 10 10 @observable isResetting = false; 11 11 @observable isUpdating = false; 12 - @observable redirect: string; 13 12 secret: string; 14 13 14 + @observable private redirectOrig: string; 15 + 16 + @computed 17 + get redirect() { 18 + return this.redirectOrig.replace(/,/g, '\r\n'); 19 + } 20 + 15 21 constructor(client: OwnClientJson) { 16 22 super(client); 17 23 18 - this.redirect = client.redirect; 24 + this.redirectOrig = client.redirect; 19 25 this.secret = client.secret; 20 26 21 27 makeObservable(this); ··· 64 70 this.scopes = new Set(json.scopes); 65 71 this.userId = json.user_id; 66 72 this.user = json.user; 67 - this.redirect = json.redirect; 73 + this.redirectOrig = json.redirect; 68 74 this.secret = json.secret; 69 75 } 70 76 71 77 @action 72 - updateWith(partial: Partial<OwnClient>) { 78 + updateWith(partial: Partial<OwnClientJson>) { 73 79 const { redirect } = partial; 74 80 this.isUpdating = true; 75 81
+1 -1
resources/js/news-show/main.tsx
··· 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 4 import AdminMenu from 'components/admin-menu'; 5 - import { Comments } from 'components/comments'; 5 + import Comments from 'components/comments'; 6 6 import { CommentsManager } from 'components/comments-manager'; 7 7 import NewsHeader from 'components/news-header'; 8 8 import StringWithComponent from 'components/string-with-component';
+1 -1
resources/js/oauth/authorized-client.tsx
··· 39 39 </div> 40 40 <div> 41 41 <BigButton 42 - disabled={client.isRevoking || client.revoked} 42 + disabled={client.revoked} 43 43 icon={client.revoked ? 'fas fa-ban' : 'fas fa-trash'} 44 44 isBusy={client.isRevoking} 45 45 modifiers={['account-edit', 'danger', 'settings-oauth']}
+70 -65
resources/js/oauth/client-details.tsx
··· 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 4 import { Spinner } from 'components/spinner'; 5 - import { ValidatingInput } from 'components/validating-input'; 6 5 import { FormErrors } from 'form-errors'; 7 - import { action, makeObservable } from 'mobx'; 6 + import { action, makeObservable, observable } from 'mobx'; 8 7 import { observer } from 'mobx-react'; 9 8 import { OwnClient as Client } from 'models/oauth/own-client'; 10 9 import core from 'osu-core-singleton'; 11 10 import * as React from 'react'; 11 + import TextareaAutosize from 'react-autosize-textarea'; 12 12 import { onError } from 'utils/ajax'; 13 + import { classWithModifiers } from 'utils/css'; 13 14 import { trans } from 'utils/lang'; 14 15 15 16 const uiState = core.dataStore.uiState; ··· 27 28 28 29 @observer 29 30 export class ClientDetails extends React.Component<Props, State> { 30 - state: Readonly<State> = { 31 - isSecretVisible: false, 32 - redirect: this.props.client.redirect, 33 - }; 34 - 35 - private errors = new FormErrors(); 31 + private readonly errors = new FormErrors(); 32 + @observable private isSecretVisible = false; 33 + @observable private redirect = this.props.client.redirect.replace(/,/g, '\r\n'); 36 34 37 35 constructor(props: Props) { 38 36 super(props); ··· 40 38 makeObservable(this); 41 39 } 42 40 43 - @action 44 - handleClose = () => { 45 - uiState.account.client = null; 46 - }; 47 - 48 - @action 49 - handleDelete = () => { 50 - if (this.props.client.isRevoking) return; 51 - if (!confirm(trans('oauth.own_clients.confirm_delete'))) return; 52 - 53 - this.props.client.delete().then(action(() => { 54 - uiState.account.client = null; 55 - })); 56 - }; 57 - 58 - @action 59 - handleInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => { 60 - const target = event.target as HTMLInputElement; 61 - const { name, value } = target; 62 - 63 - this.setState({ 64 - [name]: value, 65 - }); 66 - }; 67 - 68 - @action 69 - handleReset = () => { 70 - if (!confirm(trans('oauth.own_clients.confirm_reset'))) return; 71 - if (this.props.client.isResetting) return; 72 - 73 - this.props.client.resetSecret() 74 - .done(() => this.setState({ isSecretVisible: true })) 75 - .fail(onError); 76 - }; 77 - 78 - @action 79 - handleToggleSecret = () => { 80 - this.setState({ isSecretVisible: !this.state.isSecretVisible }); 81 - }; 82 - 83 - @action 84 - handleUpdate = () => { 85 - if (this.props.client.isUpdating) return; 86 - this.props.client.updateWith(this.state).then(() => { 87 - this.errors.clear(); 88 - }).catch(this.errors.handleResponse); 89 - }; 90 - 91 41 render() { 92 42 return ( 93 43 <div className='oauth-client-details'> ··· 103 53 <div className='oauth-client-details__label'>{trans('oauth.client.secret')}</div> 104 54 <div> 105 55 { 106 - this.state.isSecretVisible 56 + this.isSecretVisible 107 57 ? this.props.client.secret 108 58 : 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' 109 59 } ··· 114 64 onClick={this.handleToggleSecret} 115 65 type='button' 116 66 > 117 - {trans(`oauth.client.secret_visible.${this.state.isSecretVisible}`)} 67 + {trans(`oauth.client.secret_visible.${this.isSecretVisible}`)} 118 68 </button> 119 69 <button 120 70 className='btn-osu-big btn-osu-big--danger' ··· 128 78 </div> 129 79 130 80 <label className='oauth-client-details__group'> 131 - <div className='oauth-client-details__label'>{trans('oauth.client.redirect')}</div> 132 - <ValidatingInput 133 - blockName='oauth-client-details' 134 - errors={this.errors} 81 + <div className='oauth-client-details__label'> 82 + {trans('oauth.client.redirect')} 83 + </div> 84 + <TextareaAutosize 85 + async 86 + className={classWithModifiers( 87 + 'oauth-client-details__input', 88 + 'textarea', 89 + { 'has-error': (this.errors.get('redirect') ?? []).length > 0 }, 90 + )} 135 91 name='redirect' 136 - onChange={this.handleInputChange} 137 - type='text' 138 - value={this.state.redirect} 92 + onChange={this.handleOnChangeRedirect} 93 + value={this.redirect} 139 94 /> 95 + {(this.errors.get('redirect') ?? []).map((message, index) => ( 96 + <div key={index} className='oauth-client-details__error'> 97 + {message} 98 + </div> 99 + ))} 140 100 </label> 141 101 142 102 <div className='oauth-client-details__buttons'> ··· 165 125 </div> 166 126 ); 167 127 } 128 + 129 + @action 130 + private readonly handleClose = () => { 131 + uiState.account.client = null; 132 + }; 133 + 134 + @action 135 + private readonly handleDelete = () => { 136 + if (this.props.client.isRevoking) return; 137 + if (!confirm(trans('oauth.own_clients.confirm_delete'))) return; 138 + 139 + this.props.client.delete().then(action(() => { 140 + uiState.account.client = null; 141 + })); 142 + }; 143 + 144 + @action 145 + private readonly handleOnChangeRedirect = (event: React.ChangeEvent<HTMLTextAreaElement>) => { 146 + this.redirect = event.currentTarget.value; 147 + }; 148 + 149 + @action 150 + private readonly handleReset = () => { 151 + if (!confirm(trans('oauth.own_clients.confirm_reset'))) return; 152 + if (this.props.client.isResetting) return; 153 + 154 + this.props.client.resetSecret() 155 + .done(action(() => { 156 + this.isSecretVisible = true; 157 + })) 158 + .fail(onError); 159 + }; 160 + 161 + @action 162 + private readonly handleToggleSecret = () => { 163 + this.isSecretVisible = !this.isSecretVisible; 164 + }; 165 + 166 + @action 167 + private readonly handleUpdate = () => { 168 + if (this.props.client.isUpdating) return; 169 + this.props.client.updateWith({ redirect: this.redirect }).then(() => { 170 + this.errors.clear(); 171 + }).catch(this.errors.handleResponse); 172 + }; 168 173 }
+43 -19
resources/js/oauth/new-client.tsx
··· 11 11 import { observer } from 'mobx-react'; 12 12 import core from 'osu-core-singleton'; 13 13 import * as React from 'react'; 14 + import TextareaAutosize from 'react-autosize-textarea'; 15 + import { classWithModifiers } from 'utils/css'; 14 16 import { trans } from 'utils/lang'; 15 17 16 18 const store = core.dataStore.ownClientStore; ··· 42 44 <form autoComplete='off' className='oauth-client-details__content'> 43 45 {this.renderRemainingErrors()} 44 46 45 - {NewClient.inputFields.map((name) => ( 46 - <label key={name} className='oauth-client-details__group'> 47 - <div className='oauth-client-details__label'>{trans(`oauth.client.${name}`)}</div> 48 - <ValidatingInput 49 - blockName='oauth-client-details' 50 - errors={this.errors} 51 - name={name} 52 - onInput={this.handleInput} 53 - type='text' 54 - value={this.params[name]} 55 - /> 56 - </label> 57 - ))} 47 + <label className='oauth-client-details__group'> 48 + <div className='oauth-client-details__label'> 49 + {trans('oauth.client.name')} 50 + </div> 51 + <ValidatingInput 52 + blockName='oauth-client-details' 53 + errors={this.errors} 54 + name='name' 55 + onChange={this.handleOnChangeName} 56 + type='text' 57 + value={this.params.name} 58 + /> 59 + </label> 60 + 61 + <label className='oauth-client-details__group'> 62 + <div className='oauth-client-details__label'> 63 + {trans('oauth.client.redirect')} 64 + </div> 65 + <TextareaAutosize 66 + async 67 + className={classWithModifiers( 68 + 'oauth-client-details__input', 69 + 'textarea', 70 + { 'has-error': (this.errors.get('redirect') ?? []).length > 0 }, 71 + )} 72 + name='redirect' 73 + onChange={this.handleOnChangeRedirect} 74 + value={this.params.redirect} 75 + /> 76 + {(this.errors.get('redirect') ?? []).map((message, index) => ( 77 + <div key={index} className='oauth-client-details__error'> 78 + {message} 79 + </div> 80 + ))} 81 + </label> 58 82 59 83 <div> 60 84 <StringWithComponent ··· 85 109 }; 86 110 87 111 @action 88 - private readonly handleInput = (event: React.SyntheticEvent<HTMLInputElement>) => { 89 - const target = event.currentTarget; 90 - const { name, value } = target; 112 + private readonly handleOnChangeName = (event: React.ChangeEvent<HTMLInputElement>) => { 113 + this.params.name = event.currentTarget.value; 114 + }; 91 115 92 - if (name === 'name' || name === 'redirect') { 93 - this.params[name] = value; 94 - } 116 + @action 117 + private readonly handleOnChangeRedirect = (event: React.ChangeEvent<HTMLTextAreaElement>) => { 118 + this.params.redirect = event.currentTarget.value; 95 119 }; 96 120 97 121 @action
+8 -2
resources/js/oauth/own-client.tsx
··· 8 8 import core from 'osu-core-singleton'; 9 9 import * as React from 'react'; 10 10 import { onError } from 'utils/ajax'; 11 + import { formatNumber } from 'utils/html'; 11 12 import { trans } from 'utils/lang'; 12 13 13 14 const uiState = core.dataStore.uiState; ··· 32 33 33 34 render() { 34 35 const client = this.props.client; 36 + const redirects = client.redirect.split('\r\n'); 35 37 36 38 return ( 37 39 <div className='oauth-client'> 38 40 <button className='oauth-client__details oauth-client__details--button' onClick={this.showClientDetails}> 39 41 <div className='oauth-client__name'>{client.name}</div> 40 - <div className='oauth-client__redirect'>{client.redirect}</div> 42 + <div className='oauth-client__redirect'> 43 + {redirects[0]} 44 + {' '} 45 + {redirects.length > 1 ? <small>(+{formatNumber(redirects.length - 1)})</small> : null} 46 + </div> 41 47 </button> 42 48 <div className='oauth-client__actions'> 43 49 <BigButton ··· 50 56 text={trans('common.buttons.edit')} 51 57 /> 52 58 <BigButton 53 - disabled={client.isRevoking || client.revoked} 59 + disabled={client.revoked} 54 60 icon={client.revoked ? 'fas fa-ban' : 'fas fa-trash'} 55 61 isBusy={client.isRevoking} 56 62 modifiers={['account-edit', 'danger', 'settings-oauth']}
+71 -37
resources/js/osu-core.ts
··· 31 31 import NotificationsWorker from 'notifications/worker'; 32 32 import SocketWorker from 'socket-worker'; 33 33 import RootDataStore from 'stores/root-data-store'; 34 + import { parseJsonNullable } from 'utils/json'; 34 35 35 36 // will this replace main.coffee eventually? 36 37 export default class OsuCore { 37 - beatmapsetSearchController: BeatmapsetSearchController; 38 - readonly captcha = new Captcha(); 39 - readonly chatWorker = new ChatWorker(); 40 - readonly clickMenu = new ClickMenu(); 38 + readonly beatmapsetSearchController; 39 + readonly captcha; 40 + readonly chatWorker; 41 + readonly clickMenu; 41 42 @observable currentUser?: CurrentUserJson; 42 - readonly currentUserModel = new UserModel(this); 43 - dataStore: RootDataStore; 44 - readonly enchant: Enchant; 45 - readonly forumPoll = new ForumPoll(); 46 - readonly forumPostEdit = new ForumPostEdit(); 47 - readonly forumPostInput = new ForumPostInput(); 48 - readonly forumPostReport = new ForumPostReport(); 49 - readonly localtime = new Localtime(); 50 - readonly mobileToggle = new MobileToggle(); 51 - notificationsWorker: NotificationsWorker; 52 - readonly osuAudio: OsuAudio; 53 - readonly reactTurbolinks: ReactTurbolinks; 54 - readonly referenceLinkTooltip = new ReferenceLinkTooltip(); 55 - readonly scorePins = new ScorePins(); 56 - @observable scrolling = false; 57 - socketWorker: SocketWorker; 58 - readonly stickyHeader = new StickyHeader(); 59 - readonly timeago = new Timeago(); 60 - readonly turbolinksReload = new TurbolinksReload(); 61 - readonly userLogin: UserLogin; 62 - userLoginObserver: UserLoginObserver; 63 - readonly userPreferences = new UserPreferences(); 64 - readonly userVerification = new UserVerification(); 65 - windowFocusObserver: WindowFocusObserver; 66 - readonly windowSize = new WindowSize(); 43 + readonly currentUserModel; 44 + readonly dataStore; 45 + readonly enchant; 46 + readonly forumPoll; 47 + readonly forumPostEdit; 48 + readonly forumPostInput; 49 + readonly forumPostReport; 50 + readonly localtime; 51 + readonly mobileToggle; 52 + readonly notificationsWorker; 53 + readonly osuAudio; 54 + readonly reactTurbolinks; 55 + readonly referenceLinkTooltip; 56 + readonly scorePins; 57 + readonly socketWorker; 58 + readonly stickyHeader; 59 + readonly timeago; 60 + readonly turbolinksReload; 61 + readonly userLogin; 62 + readonly userLoginObserver; 63 + readonly userPreferences; 64 + readonly userVerification; 65 + readonly windowFocusObserver; 66 + readonly windowSize; 67 67 68 68 @computed 69 69 get currentUserOrFail() { ··· 75 75 } 76 76 77 77 constructor() { 78 - // refresh current user on page reload (and initial page load) 79 - $(document).on('turbolinks:load.osu-core', this.onPageLoad); 78 + // Set current user on first page load. Further updates are done in 79 + // reactTurbolinks before the new page is rendered. 80 + // This needs to be fired before everything else (turbolinks:load etc). 81 + const isLoading = document.readyState === 'loading'; 82 + if (isLoading) { 83 + document.addEventListener('DOMContentLoaded', this.updateCurrentUser); 84 + } 80 85 $.subscribe('user:update', this.onCurrentUserUpdate); 81 86 87 + this.captcha = new Captcha(); 88 + this.chatWorker = new ChatWorker(); 89 + this.clickMenu = new ClickMenu(); 90 + this.currentUserModel = new UserModel(this); 91 + this.forumPoll = new ForumPoll(); 92 + this.forumPostEdit = new ForumPostEdit(); 93 + this.forumPostInput = new ForumPostInput(); 94 + this.forumPostReport = new ForumPostReport(); 95 + this.localtime = new Localtime(); 96 + this.mobileToggle = new MobileToggle(); 97 + this.referenceLinkTooltip = new ReferenceLinkTooltip(); 98 + this.scorePins = new ScorePins(); 99 + this.stickyHeader = new StickyHeader(); 100 + this.timeago = new Timeago(); 101 + this.turbolinksReload = new TurbolinksReload(); 102 + this.userPreferences = new UserPreferences(); 103 + this.userVerification = new UserVerification(); 104 + this.windowSize = new WindowSize(); 105 + 82 106 this.enchant = new Enchant(this.turbolinksReload); 83 107 this.osuAudio = new OsuAudio(this.userPreferences); 84 - this.reactTurbolinks = new ReactTurbolinks(this.turbolinksReload); 108 + this.reactTurbolinks = new ReactTurbolinks(this, this.turbolinksReload); 85 109 this.userLogin = new UserLogin(this.captcha); 86 110 // should probably figure how to conditionally or lazy initialize these so they don't all init when not needed. 87 111 // TODO: requires dynamic imports to lazy load modules. ··· 95 119 this.notificationsWorker = new NotificationsWorker(this.socketWorker); 96 120 97 121 makeObservable(this); 122 + 123 + if (!isLoading) { 124 + this.updateCurrentUser(); 125 + } 98 126 } 99 127 100 - private onCurrentUserUpdate = (event: unknown, user: CurrentUserJson) => { 101 - this.setCurrentUser(user); 128 + readonly updateCurrentUser = () => { 129 + // Remove from DOM so only new data is parsed on navigation. 130 + const currentUser = parseJsonNullable<typeof window.currentUser>('json-current-user', true); 131 + 132 + if (currentUser != null) { 133 + window.currentUser = currentUser; 134 + this.setCurrentUser(window.currentUser); 135 + } 102 136 }; 103 137 104 - private onPageLoad = () => { 105 - this.setCurrentUser(window.currentUser); 138 + private readonly onCurrentUserUpdate = (event: unknown, user: CurrentUserJson) => { 139 + this.setCurrentUser(user); 106 140 }; 107 141 108 142 @action 109 - private setCurrentUser = (userOrEmpty: typeof window.currentUser) => { 143 + private readonly setCurrentUser = (userOrEmpty: typeof window.currentUser) => { 110 144 const user = userOrEmpty.id == null ? undefined : userOrEmpty; 111 145 112 146 if (user != null) {
+1 -1
resources/js/register-components.tsx
··· 5 5 import BeatmapsetEvents, { Props as BeatmapsetEventsProps } from 'components/beatmapset-events'; 6 6 import BlockButton from 'components/block-button'; 7 7 import ChatIcon from 'components/chat-icon'; 8 - import { Comments } from 'components/comments'; 8 + import Comments from 'components/comments'; 9 9 import { CommentsManager, Props as CommentsManagerProps } from 'components/comments-manager'; 10 10 import CountdownTimer from 'components/countdown-timer'; 11 11 import { LandingNews } from 'components/landing-news';
+3 -1
resources/js/utils/ajax.ts
··· 28 28 }; 29 29 30 30 export const fileuploadFailCallback = (event: unknown, data: JQueryFileUploadDone) => { 31 - error(data.jqXHR, data.textStatus, () => data.submit?.()); 31 + error(data.jqXHR, data.textStatus, () => { 32 + data.submit?.(); 33 + }); 32 34 }; 33 35 34 36 export function isJqXHR(obj: unknown): obj is JQuery.jqXHR {
+11
resources/js/utils/beatmapset-discussion-helper.ts
··· 9 9 import BeatmapsetDiscussionJson, { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; 10 10 import BeatmapsetDiscussionPostJson from 'interfaces/beatmapset-discussion-post-json'; 11 11 import BeatmapsetJson from 'interfaces/beatmapset-json'; 12 + import GameMode from 'interfaces/game-mode'; 12 13 import UserJson from 'interfaces/user-json'; 13 14 import { route } from 'laroute'; 14 15 import { assign, padStart, sortBy } from 'lodash'; ··· 143 144 && discussion.timestamp != null 144 145 && nearbyDiscussionsMessageTypes.has(discussion.message_type) 145 146 && (discussion.user_id !== core.currentUserOrFail.id || moment(discussion.updated_at).diff(moment(), 'hour') <= -24); 147 + } 148 + 149 + export function isUserFullNominator(user?: UserJson | null, gameMode?: GameMode) { 150 + return user != null && user.groups != null && user.groups.some((group) => { 151 + if (gameMode != null) { 152 + return (group.identifier === 'bng' || group.identifier === 'nat') && group.playmodes?.includes(gameMode); 153 + } else { 154 + return (group.identifier === 'bng' || group.identifier === 'nat'); 155 + } 156 + }); 146 157 } 147 158 148 159 export function linkTimestamp(text: string, classNames: string[] = []) {
+1 -1
resources/js/utils/json.ts
··· 72 72 * @param remove true to remove the element after parsing; false, otherwise. 73 73 */ 74 74 export function parseJsonNullable<T>(id: string, remove = false): T | undefined { 75 - const element = window.newBody?.querySelector(`#${id}`); 75 + const element = (window.newBody ?? document.body).querySelector(`#${id}`); 76 76 if (!(element instanceof HTMLScriptElement)) return undefined; 77 77 const json = JSON.parse(element.text) as T; 78 78
+8
resources/js/utils/url.ts
··· 117 117 return el.outerHTML; 118 118 } 119 119 120 + // Default url transformer changes non-conforming url to javascript:void(0) 121 + // which causes warning from React. 122 + export function safeReactMarkdownUrl(url: string | undefined) { 123 + if (url !== 'javascript:void(0)') { 124 + return url; 125 + } 126 + } 127 + 120 128 export function updateQueryString(url: string | null, params: Record<string, string | null | undefined>, hash?: string) { 121 129 const docUrl = currentUrl(); 122 130 const urlObj = new URL(url ?? docUrl.href, docUrl.origin);
+6
resources/lang/en/accounts.php
··· 20 20 'title' => 'Email', 21 21 ], 22 22 23 + 'legacy_api' => [ 24 + 'api' => 'api', 25 + 'irc' => 'irc', 26 + 'title' => 'Legacy API', 27 + ], 28 + 23 29 'password' => [ 24 30 'current' => 'current password', 25 31 'new' => 'new password',
+1
resources/lang/en/beatmappacks.php
··· 36 36 'artist' => 'Artist/Album', 37 37 'chart' => 'Spotlights', 38 38 'featured' => 'Featured Artist', 39 + 'loved' => 'Project Loved', 39 40 'standard' => 'Standard', 40 41 'theme' => 'Theme', 41 42 'tournament' => 'Tournament',
+2
resources/lang/en/beatmaps.php
··· 175 175 ], 176 176 177 177 'nominations' => [ 178 + 'already_nominated' => 'You\'ve already nominated this beatmap.', 179 + 'cannot_nominate' => 'You cannot nominate this beatmap game mode.', 178 180 'delete' => 'Delete', 179 181 'delete_own_confirm' => 'Are you sure? The beatmap will be deleted and you will be redirected back to your profile.', 180 182 'delete_other_confirm' => 'Are you sure? The beatmap will be deleted and you will be redirected back to the user\'s profile.',
+1
resources/lang/en/comments.php
··· 11 11 'edited' => 'edited :timeago by :user', 12 12 'pinned' => 'pinned', 13 13 'empty' => 'No comments yet.', 14 + 'empty_other' => 'No other comments yet.', 14 15 'load_replies' => 'load replies', 15 16 'replies_count' => ':count_delimited reply|:count_delimited replies', 16 17 'title' => 'Comments',
+30
resources/lang/en/legacy_api_key.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 + return [ 7 + 'new' => 'New Legacy API Key', 8 + 'none' => 'No key.', 9 + 10 + 'docs' => [ 11 + '_' => 'Documentation is available at :github.', 12 + 'github' => 'GitHub', 13 + ], 14 + 15 + 'form' => [ 16 + 'create' => 'Create Key', 17 + ], 18 + 19 + 'view' => [ 20 + 'hide' => 'Hide Key', 21 + 'show' => 'Show Key', 22 + 'delete' => 'Delete', 23 + ], 24 + 25 + 'warning' => [ 26 + 'line1' => 'Do not give this out to others.', 27 + 'line2' => "It's equivalent to giving out your password.", 28 + 'line3' => 'Your account may be compromised.', 29 + ], 30 + ];
+23
resources/lang/en/legacy_irc_key.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 + return [ 7 + 'confirm_new' => 'Create new IRC password?', 8 + 'new' => 'New Legacy IRC Password', 9 + 'none' => 'IRC Password not set.', 10 + 11 + 'form' => [ 12 + 'server_host' => 'server', 13 + 'server_port' => 'port', 14 + 'token' => 'server password', 15 + 'username' => 'username', 16 + ], 17 + 18 + 'view' => [ 19 + 'hide' => 'Hide Password', 20 + 'show' => 'Show Password', 21 + 'delete' => 'Delete', 22 + ], 23 + ];
+8 -1
resources/lang/en/model_validation.php
··· 111 111 112 112 'legacy_api_key' => [ 113 113 'exists' => 'Only one API key is provided per user for the moment.', 114 + 115 + 'attributes' => [ 116 + 'api_key' => 'api key', 117 + 'app_name' => 'application name', 118 + 'app_url' => 'application url', 119 + ], 114 120 ], 115 121 116 122 'oauth' => [ 117 123 'client' => [ 118 124 'too_many' => 'Exceeded maximum number of allowed OAuth applications.', 119 - 'url' => 'Please enter a valid URL.', 125 + 'url' => 'Please enter valid URLs.', 120 126 121 127 'attributes' => [ 122 128 'name' => 'Application Name', ··· 169 175 170 176 'user_report' => [ 171 177 'no_ranked_beatmapset' => 'Ranked beatmaps cannot be reported', 178 + 'not_in_channel' => 'You\'re not in this channel.', 172 179 'reason_not_valid' => ':reason is not valid for this report type.', 173 180 'self' => "You can't report yourself!", 174 181 ],
+1 -1
resources/lang/en/oauth.php
··· 27 27 'client' => [ 28 28 'id' => 'Client ID', 29 29 'name' => 'Application Name', 30 - 'redirect' => 'Application Callback URL', 30 + 'redirect' => 'Application Callback URLs', 31 31 'reset' => 'Reset client secret', 32 32 'reset_failed' => 'Failed to reset client secret', 33 33 'secret' => 'Client Secret',
+37
resources/views/accounts/_edit_legacy_api.blade.php
··· 1 + {{-- 2 + Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 3 + See the LICENCE file in the repository root for full licence text. 4 + --}} 5 + <div class="account-edit"> 6 + <div class="account-edit__section"> 7 + <h2 class="account-edit__section-title"> 8 + {{ osu_trans('accounts.edit.legacy_api.title') }} 9 + </h2> 10 + </div> 11 + 12 + <div class="account-edit__input-groups"> 13 + <div class="account-edit__input-group"> 14 + <div class="account-edit-entry"> 15 + <div class="account-edit-entry__label account-edit-entry__label--top-pinned">{{ osu_trans('accounts.edit.legacy_api.api') }}</div> 16 + <div class="account-edit__input-groups"> 17 + <div 18 + class="js-react--legacy-api-key" 19 + data-state="{{ json_encode(['legacy_api_key' => $legacyApiKeyJson]) }}" 20 + ></div> 21 + </div> 22 + </div> 23 + </div> 24 + 25 + <div class="account-edit__input-group"> 26 + <div class="account-edit-entry"> 27 + <div class="account-edit-entry__label account-edit-entry__label--top-pinned">{{ osu_trans('accounts.edit.legacy_api.irc') }}</div> 28 + <div class="account-edit__input-groups"> 29 + <div 30 + class="js-react--legacy-irc-key" 31 + data-state="{{ json_encode(['legacy_irc_key' => $legacyIrcKeyJson]) }}" 32 + ></div> 33 + </div> 34 + </div> 35 + </div> 36 + </div> 37 + </div>
+4
resources/views/accounts/edit.blade.php
··· 163 163 <div class="osu-page" id="oauth"> 164 164 @include('accounts._edit_oauth') 165 165 </div> 166 + 167 + <div class="osu-page" id="legacy-api"> 168 + @include('accounts._edit_legacy_api') 169 + </div> 166 170 @endsection 167 171 168 172 @section("script")
+32 -7
resources/views/comments/index.blade.php
··· 4 4 --}} 5 5 @extends('master') 6 6 7 + @php 8 + $commentBundleJson = $commentBundle->toArray(); 9 + 10 + $links = [ 11 + [ 12 + 'title' => osu_trans('comments.index.nav_title'), 13 + 'url' => route('comments.index'), 14 + ], 15 + ]; 16 + 17 + $userJson = $commentBundleJson['user'] ?? null; 18 + if ($userJson !== null) { 19 + $links[] = [ 20 + 'title' => $userJson['username'], 21 + 'url' => route('users.show', ['user' => $userJson['id']]), 22 + ]; 23 + $links[] = [ 24 + 'title' => osu_trans('comments.index.nav_comments'), 25 + 'url' => route('comments.index', ['user_id' => $userJson['id']]), 26 + ]; 27 + } 28 + @endphp 29 + 7 30 @section('content') 8 - <div class="js-react--comments-index osu-layout osu-layout--full"></div> 31 + @include('layout._page_header_v4', ['params' => [ 32 + 'links' => $links, 33 + 'linksBreadcrumb' => true, 34 + 'theme' => 'comments', 35 + ]]) 36 + <div class="osu-page osu-page--comments"> 37 + <div class="js-react--comments-index u-contents"></div> 9 38 10 - {{-- temporary pagination to be used by react component above --}} 11 - <div class="hidden"> 12 - <div class="js-comments-pagination"> 13 - @include('objects._pagination_v2', ['object' => $commentPagination]) 14 - </div> 39 + @include('objects._pagination_v2', ['object' => $commentPagination]) 15 40 </div> 16 41 17 42 <script id="json-index" type="application/json"> 18 - {!! json_encode($commentBundle->toArray()) !!} 43 + {!! json_encode($commentBundleJson) !!} 19 44 </script> 20 45 @include('layout._react_js', ['src' => 'js/comments-index.js']) 21 46 @endsection
+24 -2
resources/views/comments/show.blade.php
··· 4 4 --}} 5 5 @extends('master') 6 6 7 + @php 8 + $json = $commentBundle->toArray(); 9 + $commentJson = $json['comments'][0]; 10 + @endphp 11 + 7 12 @section('content') 8 - <div class="js-react--comments-show osu-layout osu-layout--full"></div> 13 + @include('layout._page_header_v4', ['params' => [ 14 + 'links' => [ 15 + [ 16 + 'title' => trans('comments.index.nav_title'), 17 + 'url' => route('comments.index'), 18 + ], 19 + [ 20 + 'title' => trans('comments.show.nav_title'), 21 + 'url' => route('comments.show', ['comment' => $commentJson['id']]), 22 + ], 23 + ], 24 + 'linksBreadcrumb' => true, 25 + 'theme' => 'comments', 26 + ]]) 27 + 28 + <div class="osu-page osu-page--comment"> 29 + <div class="js-react--comments-show u-contents"></div> 30 + </div> 9 31 10 32 <script id="json-show" type="application/json"> 11 - {!! json_encode($commentBundle->toArray()) !!} 33 + {!! json_encode($json) !!} 12 34 </script> 13 35 14 36 @include('layout._react_js', ['src' => 'js/comments-show.js'])
+2 -4
resources/views/layout/_current_user.blade.php
··· 9 9 ? '{}' 10 10 : json_encode(json_item($user, new App\Transformers\CurrentUserTransformer())); 11 11 @endphp 12 - <script class="js-current-user"> 13 - var currentUser = {!! $userJson !!}; 14 - // self-destruct to avoid rerun by turbolinks 15 - $('.js-current-user').remove(); 12 + <script id="json-current-user" type="application/json"> 13 + {!! $userJson !!} 16 14 </script>
+3
tests/Browser/SanityTest.php
··· 283 283 continue; 284 284 } 285 285 } 286 + } elseif ($line['message'] === "security - Error with Permissions-Policy header: Unrecognized feature: 'ch-ua-form-factor'.") { 287 + // we don't use ch-ua-* crap and this error is thrown by youtube.com as of 2023-05-16 288 + continue; 286 289 } 287 290 288 291 $return[] = $line;
+44 -2
tests/Controllers/BeatmapsetsControllerTest.php
··· 233 233 */ 234 234 public function testBeatmapsetUpdateMetadataAsProjectLoved(string $state): void 235 235 { 236 - $owner = User::factory()->create(); 237 236 $beatmapset = Beatmapset::factory()->create([ 238 237 'approved' => Beatmapset::STATES[$state], 239 - 'user_id' => $owner, 238 + 'user_id' => User::factory(), 240 239 ]); 240 + $owner = $beatmapset->user; 241 241 $newGenre = Genre::factory()->create(); 242 242 $newLanguage = Language::factory()->create(); 243 243 ··· 277 277 { 278 278 $beatmapset = Beatmapset::factory()->create([ 279 279 'approved' => Beatmapset::STATES['ranked'], 280 + 'user_id' => User::factory(), 280 281 ]); 281 282 282 283 $user = $userGroupOrOwner === 'owner' ··· 300 301 $this->assertSame($expectedOffset, $beatmapset->offset); 301 302 } 302 303 304 + /** 305 + * @dataProvider dataProviderForTestBeatmapsetUpdateTags 306 + */ 307 + public function testBeatmapsetUpdateTags(string $userGroupOrOwner, bool $ok): void 308 + { 309 + $beatmapset = Beatmapset::factory()->create([ 310 + 'approved' => Beatmapset::STATES['ranked'], 311 + 'user_id' => User::factory(), 312 + ]); 313 + 314 + $user = $userGroupOrOwner === 'owner' 315 + ? $beatmapset->user 316 + : User::factory()->withGroup($userGroupOrOwner)->create(); 317 + 318 + $newTags = "{$beatmapset->tags} more_tag"; 319 + $expectedTags = $ok ? $newTags : $beatmapset->tags; 320 + 321 + $this->expectCountChange(fn () => BeatmapsetEvent::count(), $ok ? 1 : 0); 322 + 323 + $this->actingAsVerified($user) 324 + ->put(route('beatmapsets.update', ['beatmapset' => $beatmapset->getKey()]), [ 325 + 'beatmapset' => [ 326 + 'tags' => $newTags, 327 + ], 328 + ])->assertStatus($ok ? 200 : 403); 329 + 330 + $this->assertSame($expectedTags, $beatmapset->fresh()->tags); 331 + } 332 + 303 333 public function beatmapsetStatesDataProvider() 304 334 { 305 335 return array_map(function ($state) { ··· 315 345 ['default', false], 316 346 ['gmt', false], 317 347 ['nat', false], 348 + ['owner', false], 349 + ]; 350 + } 351 + 352 + public function dataProviderForTestBeatmapsetUpdateTags(): array 353 + { 354 + return [ 355 + ['admin', true], 356 + ['bng', false], 357 + ['default', false], 358 + ['gmt', true], 359 + ['nat', true], 318 360 ['owner', false], 319 361 ]; 320 362 }
-8
tests/Controllers/Chat/ChannelsControllerTest.php
··· 12 12 use App\Models\Chat\Message; 13 13 use App\Models\Multiplayer\Score; 14 14 use App\Models\User; 15 - use Faker; 16 15 use Illuminate\Testing\AssertableJsonString; 17 16 use Illuminate\Testing\Fluent\AssertableJson; 18 17 use Tests\TestCase; 19 18 20 19 class ChannelsControllerTest extends TestCase 21 20 { 22 - protected static $faker; 23 - 24 21 private User $user; 25 22 private User $anotherUser; 26 23 private Channel $pmChannel; 27 24 private Channel $privateChannel; 28 25 private Channel $publicChannel; 29 26 private Message $publicMessage; 30 - 31 - public static function setUpBeforeClass(): void 32 - { 33 - self::$faker = Faker\Factory::create(); 34 - } 35 27 36 28 //region GET /chat/channels - Get Channel List 37 29 public function testChannelIndexWhenGuest()
+18
tests/Models/UserReportTest.php
··· 12 12 use App\Models\BeatmapDiscussion; 13 13 use App\Models\BeatmapDiscussionPost; 14 14 use App\Models\Beatmapset; 15 + use App\Models\Chat\Channel; 15 16 use App\Models\Chat\Message; 16 17 use App\Models\Forum; 17 18 use App\Models\Traits\ReportableInterface; ··· 52 53 $userColumn = 'poster_id'; 53 54 } 54 55 56 + if ($class === Message::class) { 57 + $modelFactory = $modelFactory->state([ 58 + 'channel_id' => Channel::factory()->type('public'), 59 + ]); 60 + } 61 + 55 62 return $class === User::class 56 63 ? $modelFactory->create() 57 64 : $modelFactory->create([$userColumn => User::factory()]); ··· 86 93 $beatmapset->reportBy($reporter, static::reportParams()); 87 94 } 88 95 96 + public function testCannotReportIfNotInChannel() 97 + { 98 + $channel = Channel::factory()->type('pm')->create(); 99 + $message = Message::factory()->create(['channel_id' => $channel, 'user_id' => $channel->users()->first()]); 100 + $reporter = User::factory()->create(); 101 + 102 + $this->expectException(ValidationException::class); 103 + $message->reportBy($reporter, static::reportParams()); 104 + } 105 + 89 106 /** 90 107 * @dataProvider reportableClasses 91 108 */ ··· 167 184 { 168 185 $reportable = static::makeReportable($class); 169 186 $reporter = User::factory()->create(); 187 + 170 188 $report = $reportable->reportBy($reporter, static::reportParams()); 171 189 172 190 $report->routeNotificationForSlack(null);
+89 -1
tests/karma/utils/beatmapset-discussion-helper.spec.ts
··· 2 2 // See the LICENCE file in the repository root for full licence text. 3 3 4 4 import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; 5 + import GameMode from 'interfaces/game-mode'; 6 + import UserGroupJson from 'interfaces/user-group-json'; 7 + import UserJson from 'interfaces/user-json'; 5 8 import User from 'models/user'; 6 9 import * as moment from 'moment'; 7 - import { discussionMode, maxLengthTimeline, nearbyDiscussions, validMessageLength } from 'utils/beatmapset-discussion-helper'; 10 + import { discussionMode, isUserFullNominator, maxLengthTimeline, nearbyDiscussions, validMessageLength } from 'utils/beatmapset-discussion-helper'; 11 + 12 + interface TestCase<T> { 13 + description: string; 14 + expected: T; 15 + } 8 16 9 17 const template: BeatmapsetDiscussionJson = Object.freeze({ 10 18 beatmap_id: 1, ··· 76 84 cases.forEach((test) => { 77 85 it(test.description, () => { 78 86 expect(discussionMode(test.json)).toBe(test.expected); 87 + }); 88 + }); 89 + }); 90 + 91 + describe('.isUserFullNominator', () => { 92 + const userTemplate = currentUser.toJson(); 93 + const groupsTemplate: UserGroupJson = { 94 + colour: null, 95 + has_listing: true, 96 + has_playmodes: true, 97 + id: 1, 98 + identifier: 'placeholder', 99 + is_probationary: false, 100 + name: 'test', 101 + short_name: 'test', 102 + }; 103 + 104 + const allowedGroups = ['bng', 'nat']; 105 + const unallowedGroups = ['admin', 'bng_limited', 'bot', 'dev', 'loved']; 106 + 107 + describe('with no gameMode', () => { 108 + const cases = []; 109 + for (const identifier of allowedGroups) { 110 + cases.push({ 111 + description: `${identifier} is full nominator`, 112 + expected: true, 113 + user: { ...userTemplate, groups: [{ ...groupsTemplate, identifier }] }, 114 + }); 115 + } 116 + 117 + for (const identifier of unallowedGroups) { 118 + cases.push({ 119 + description: `${identifier} is not full nominator`, 120 + expected: false, 121 + user: { ...userTemplate, groups: [{ ...groupsTemplate, identifier }] }, 122 + }); 123 + } 124 + 125 + cases.push({ 126 + description: 'groupless is not full nominator', 127 + expected: false, 128 + user: { ...userTemplate }, 129 + }); 130 + 131 + cases.forEach((test) => { 132 + it(test.description, () => { 133 + expect(isUserFullNominator(test.user)).toBe(test.expected); 134 + }); 135 + }); 136 + }); 137 + 138 + describe('with gameMode', () => { 139 + const cases: (TestCase<boolean> & { gameMode: GameMode; user: UserJson })[] = []; 140 + for (const identifier of allowedGroups) { 141 + cases.push({ 142 + description: `${identifier} with matching playmode is full nominator`, 143 + expected: true, 144 + gameMode: 'osu', 145 + user: { ...userTemplate, groups: [{ ...groupsTemplate, identifier, playmodes: ['osu'] }] }, 146 + }); 147 + 148 + cases.push({ 149 + description: `${identifier} without matching playmode is not full nominator`, 150 + expected: false, 151 + gameMode: 'osu', 152 + user: { ...userTemplate, groups: [{ ...groupsTemplate, identifier, playmodes: ['taiko'] }] }, 153 + }); 154 + 155 + cases.push({ 156 + description: `${identifier} without playmodes is not full nominator`, 157 + expected: false, 158 + gameMode: 'osu', 159 + user: { ...userTemplate, groups: [{ ...groupsTemplate, identifier }] }, 160 + }); 161 + } 162 + 163 + cases.forEach((test) => { 164 + it(test.description, () => { 165 + expect(isUserFullNominator(test.user, test.gameMode)).toBe(test.expected); 166 + }); 79 167 }); 80 168 }); 81 169 });
+3 -3
yarn.lock
··· 7888 7888 integrity sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg== 7889 7889 7890 7890 socket.io-parser@~4.2.1: 7891 - version "4.2.1" 7892 - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.1.tgz#01c96efa11ded938dcb21cbe590c26af5eff65e5" 7893 - integrity sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g== 7891 + version "4.2.3" 7892 + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.3.tgz#926bcc6658e2ae0883dc9dee69acbdc76e4e3667" 7893 + integrity sha512-JMafRntWVO2DCJimKsRTh/wnqVvO4hrfwOqtO7f+uzwsQMuxO6VwImtYxaQ+ieoyshWOTJyV0fA21lccEXRPpQ== 7894 7894 dependencies: 7895 7895 "@socket.io/component-emitter" "~3.1.0" 7896 7896 debug "~4.3.1"