diff --git a/app/Http/Controllers/Users/LookupController.php b/app/Http/Controllers/Users/LookupController.php index 9cf4b64b720..bc3aaf66cb1 100644 --- a/app/Http/Controllers/Users/LookupController.php +++ b/app/Http/Controllers/Users/LookupController.php @@ -68,6 +68,7 @@ public function index() $transformer->setMode($rulesetName); $includes[] = 'global_rank'; + $includes[] = 'statistics.country_rank'; $users->with(User::statisticsRelationName($rulesetName)); } diff --git a/resources/css/bem/rank-value.less b/resources/css/bem/rank-value.less index 09ac302cc17..5db61b81ce6 100644 --- a/resources/css/bem/rank-value.less +++ b/resources/css/bem/rank-value.less @@ -17,4 +17,8 @@ &--iron { --font-weight: 400; } + + a& { + .link-plain(); + } } diff --git a/resources/js/components/user-card-tooltip.tsx b/resources/js/components/user-card-tooltip.tsx index 9ada31307cd..a5487090318 100644 --- a/resources/js/components/user-card-tooltip.tsx +++ b/resources/js/components/user-card-tooltip.tsx @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. import Reportable from 'interfaces/reportable'; +import Ruleset, { ensureRuleset, rulesetIds } from 'interfaces/ruleset'; import UserJson from 'interfaces/user-json'; import * as _ from 'lodash'; import { autorun, makeObservable, observable, runInAction } from 'mobx'; @@ -23,6 +24,10 @@ interface Props { lookup: string; } +interface UserCardQtipApi { + target: JQuery; +} + const userCardTooltipClass = 'qtip--user-card'; let inCard = false; let tooltipWithActiveMenu: string | null = null; @@ -171,13 +176,15 @@ function onRemoveUserCard(_event: unknown, element: HTMLElement | null) { } } -function shouldShow(event: JQuery.Event, api: any) { +function shouldShow(event: JQuery.Event, api: UserCardQtipApi) { if (tooltipWithActiveMenu != null || core.windowSize.isMobile) { return event.preventDefault(); } // keyed React components can end up with reused DOM elements with a previously set tooltip. - const target = api.target[0] as HTMLElement; + const target = api.target[0]; + if (target == null) return event.preventDefault(); + if (target._tooltip !== target.dataset.userId) { event.preventDefault(); $(target).trigger('mouseover'); @@ -198,6 +205,7 @@ export function startListening() { @observer export class UserCardTooltip extends React.PureComponent { @observable private readonly activeKeyState = new ActiveKeyState(); + private readonly currentMode?: Ruleset; private readonly disposer?: () => void; @observable private user?: UserJson; private xhr?; @@ -214,11 +222,13 @@ export class UserCardTooltip extends React.PureComponent { super(props); makeObservable(this); + this.currentMode = ensureRuleset(this.props.container.dataset.usercardRuleset ?? ''); + const rulesetId = this.currentMode == null ? undefined : rulesetIds[this.currentMode]; const currentUser = core.currentUser; - if (currentUser != null && this.props.lookup === currentUser.id.toString()) { + if (currentUser != null && this.props.lookup === currentUser.id.toString() && rulesetId == null) { this.user = currentUser; } else { - this.xhr = apiLookupUsers([this.props.lookup]) + this.xhr = apiLookupUsers([this.props.lookup], undefined, rulesetId) .done((response) => runInAction(() => { this.user = response.users[0] ?? userNotFoundJson; })) @@ -249,6 +259,7 @@ export class UserCardTooltip extends React.PureComponent { diff --git a/resources/js/components/user-card.tsx b/resources/js/components/user-card.tsx index 120fe344894..75a4a2f87dc 100644 --- a/resources/js/components/user-card.tsx +++ b/resources/js/components/user-card.tsx @@ -4,6 +4,7 @@ import BlockButton from 'components/block-button'; import FriendButton from 'components/friend-button'; import Reportable from 'interfaces/reportable'; +import Ruleset from 'interfaces/ruleset'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; import { action, makeObservable, observable } from 'mobx'; @@ -12,6 +13,7 @@ import core from 'osu-core-singleton'; import * as React from 'react'; import { classWithModifiers, Modifiers } from 'utils/css'; import { trans } from 'utils/lang'; +import { performanceRankingUrl } from 'utils/ranking'; import { present } from 'utils/string'; import { giftSupporterTagUrl } from 'utils/url'; import FlagCountry from './flag-country'; @@ -32,6 +34,7 @@ export const viewModes: ViewMode[] = ['card', 'list', 'brick']; interface Props { activated: boolean; + currentMode?: Ruleset; mode: ViewMode; modifiers?: Modifiers; reportable?: Reportable; @@ -77,6 +80,10 @@ export class UserCard extends React.PureComponent { && !core.currentUserModel.blocks.has(this.user.id); } + private get currentMode() { + return this.props.currentMode ?? 'osu'; + } + private get isOnline() { return this.user.is_online; } @@ -204,7 +211,11 @@ export class UserCard extends React.PureComponent {
diff --git a/resources/js/components/user-link.tsx b/resources/js/components/user-link.tsx index 03d7c4477d9..664384da695 100644 --- a/resources/js/components/user-link.tsx +++ b/resources/js/components/user-link.tsx @@ -33,6 +33,7 @@ export default class UserLink extends React.PureComponent { data-reportable={JSON.stringify(this.props.reportable)} data-tooltip-position={this.props.tooltipPosition} data-user-id={this.props.user.id} + data-usercard-ruleset={this.props.mode} href={href} > {this.props.children ?? this.props.user.username} diff --git a/resources/js/profile-page/cover.tsx b/resources/js/profile-page/cover.tsx index 40748ee1325..2c42eb9489f 100644 --- a/resources/js/profile-page/cover.tsx +++ b/resources/js/profile-page/cover.tsx @@ -16,6 +16,7 @@ import core from 'osu-core-singleton'; import * as React from 'react'; import { classWithModifiers, Modifiers, urlPresence } from 'utils/css'; import { trans } from 'utils/lang'; +import { performanceRankingUrl } from 'utils/ranking'; import SeasonStats from './season-stats'; interface Props { @@ -87,7 +88,11 @@ export default class Cover extends React.Component { {this.props.user.country?.code != null && {this.props.user.country.name} diff --git a/resources/js/profile-page/detail.tsx b/resources/js/profile-page/detail.tsx index ea8c63764a5..6aeef0a2816 100644 --- a/resources/js/profile-page/detail.tsx +++ b/resources/js/profile-page/detail.tsx @@ -66,8 +66,19 @@ export default class Detail extends React.Component {
- - + +
diff --git a/resources/js/profile-page/rank.tsx b/resources/js/profile-page/rank.tsx index 97467b89bc0..f4783ba9151 100644 --- a/resources/js/profile-page/rank.tsx +++ b/resources/js/profile-page/rank.tsx @@ -2,16 +2,21 @@ // See the LICENCE file in the repository root for full licence text. import ValueDisplay from 'components/value-display'; +import CountryJson from 'interfaces/country-json'; import RankHighestJson from 'interfaces/rank-highest-json'; +import Ruleset from 'interfaces/ruleset'; import UserStatisticsJson, { RankType } from 'interfaces/user-statistics-json'; import * as moment from 'moment'; import * as React from 'react'; import { classWithModifiers } from 'utils/css'; import { formatNumber } from 'utils/html'; import { trans } from 'utils/lang'; +import { performanceRankingUrl, rankingPageFromRank } from 'utils/ranking'; interface Props { + country?: CountryJson | null; highest?: RankHighestJson | null; + mode: Ruleset; stats: UserStatisticsJson; type: RankType; } @@ -45,7 +50,7 @@ function globalTier(stats: UserStatisticsJson) { : null; } -export default function Rank({ highest, stats, type }: Props) { +export default function Rank({ country, highest, mode, stats, type }: Props) { const key = `${type}_rank` as const; const rank = stats[key]; const tooltip: string[] = []; @@ -71,25 +76,38 @@ export default function Rank({ highest, stats, type }: Props) { const tier = type === 'global' ? globalTier(stats) : null; const tierVar = tier == null ? '' : `var(--level-tier-${tier})`; + const rankValueProps = { + className: classWithModifiers('rank-value', tier ?? 'base'), + 'data-html-title': tooltip.join(''), + 'data-tooltip-position': 'bottom left', + style: { + '--colour': tierVar, + } as React.CSSProperties, + title: '', + }; + const href = rankingPageFromRank(rank) == null || (type === 'country' && country?.code == null) + ? null + : performanceRankingUrl({ + country: type === 'country' ? country?.code : null, + mode, + rank, + }); + const rankText = rank != null ? `#${formatNumber(rank)}` : '-'; return ( - {rank != null ? ( - `#${formatNumber(rank)}` - ) : '-'} -
+ href == null ? ( +
+ {rankText} +
+ ) : ( +
+ {rankText} + + ) } /> ); diff --git a/resources/js/utils/ranking.ts b/resources/js/utils/ranking.ts new file mode 100644 index 00000000000..1c963e8dca1 --- /dev/null +++ b/resources/js/utils/ranking.ts @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import Ruleset from 'interfaces/ruleset'; +import { route } from 'laroute'; + +const rankingMaxResults = 10000; +const rankingPageSize = 50; + +interface PerformanceRankingUrlOptions { + country?: string | null; + mode: Ruleset; + rank?: number | null; +} + +export function rankingPageFromRank(rank?: number | null) { + if (rank == null || !Number.isInteger(rank) || rank < 1 || rank > rankingMaxResults) { + return null; + } + + return Math.ceil(rank / rankingPageSize); +} + +export function performanceRankingUrl({ country, mode, rank }: PerformanceRankingUrlOptions) { + const page = rankingPageFromRank(rank); + + return route('rankings', { + country: country ?? undefined, + mode, + page: page ?? undefined, + type: 'performance', + }); +} diff --git a/resources/js/utils/user.ts b/resources/js/utils/user.ts index 1345ac81dee..b46cdd9d4cf 100644 --- a/resources/js/utils/user.ts +++ b/resources/js/utils/user.ts @@ -1,12 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. +import { type RulesetId } from 'interfaces/ruleset'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; -export function apiLookupUsers(idsOrUsernames: (string | null | undefined)[], excludeBots?: boolean) { +interface LookupUserParams { + exclude_bots?: boolean; + ids: (string | null | undefined)[]; + ruleset_id?: RulesetId; +} + +export function apiLookupUsers(idsOrUsernames: (string | null | undefined)[], excludeBots?: boolean, rulesetId?: RulesetId) { + const data: LookupUserParams = { exclude_bots: excludeBots, ids: idsOrUsernames }; + if (rulesetId != null) { + data.ruleset_id = rulesetId; + } + return $.ajax(route('users.lookup'), { - data: { exclude_bots: excludeBots, ids: idsOrUsernames }, + data, dataType: 'json', }) as JQuery.jqXHR<{ users: UserJson[] }>; } diff --git a/resources/views/rankings/_main_column.blade.php b/resources/views/rankings/_main_column.blade.php index bf4c49eb8a3..a247906b145 100644 --- a/resources/views/rankings/_main_column.blade.php +++ b/resources/views/rankings/_main_column.blade.php @@ -78,6 +78,7 @@ class="u-contents" diff --git a/tests/Controllers/Users/LookupControllerTest.php b/tests/Controllers/Users/LookupControllerTest.php new file mode 100644 index 00000000000..4857981a352 --- /dev/null +++ b/tests/Controllers/Users/LookupControllerTest.php @@ -0,0 +1,48 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +namespace Tests\Controllers\Users; + +use App\Models\Beatmap; +use App\Models\Country; +use App\Models\User; +use App\Models\UserStatistics\Model as UserStatisticsModel; +use Tests\TestCase; + +class LookupControllerTest extends TestCase +{ + public function testRulesetLookupIncludesStatistics(): void + { + $ruleset = 'taiko'; + $country = 'JP'; + Country::factory()->create(['acronym' => $country]); + $user = User::factory()->create(['country_acronym' => $country]); + $higherRankedUser = User::factory()->create(['country_acronym' => $country]); + $class = UserStatisticsModel::getClass($ruleset); + + $class::factory()->create([ + 'country_acronym' => $country, + 'rank_score' => 1000, + 'rank_score_index' => 1, + 'user_id' => $higherRankedUser->getKey(), + ]); + $class::factory()->create([ + 'country_acronym' => $country, + 'rank_score' => 500, + 'rank_score_index' => 2, + 'user_id' => $user->getKey(), + ]); + + $this + ->getJson(route('users.lookup', [ + 'ids' => [$user->getKey()], + 'ruleset_id' => Beatmap::MODES[$ruleset], + ])) + ->assertOk() + ->assertJsonPath('users.0.global_rank.rank', 2) + ->assertJsonPath('users.0.statistics.global_rank', 2) + ->assertJsonPath('users.0.statistics.country_rank', 2); + } +} diff --git a/tests/karma/utils/ranking.spec.ts b/tests/karma/utils/ranking.spec.ts new file mode 100644 index 00000000000..39dc6b8f3d6 --- /dev/null +++ b/tests/karma/utils/ranking.spec.ts @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import { rankingPageFromRank } from 'utils/ranking'; + +describe('utils/ranking', () => { + describe('rankingPageFromRank', () => { + it('returns the page containing the rank', () => { + expect(rankingPageFromRank(1)).toBe(1); + expect(rankingPageFromRank(50)).toBe(1); + expect(rankingPageFromRank(51)).toBe(2); + expect(rankingPageFromRank(10000)).toBe(200); + }); + + it('returns null for ranks outside visible rankings', () => { + expect(rankingPageFromRank(null)).toBeNull(); + expect(rankingPageFromRank(0)).toBeNull(); + expect(rankingPageFromRank(10001)).toBeNull(); + }); + }); +});