Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Http/Controllers/Users/LookupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public function index()

$transformer->setMode($rulesetName);
$includes[] = 'global_rank';
$includes[] = 'statistics.country_rank';
$users->with(User::statisticsRelationName($rulesetName));
}

Expand Down
4 changes: 4 additions & 0 deletions resources/css/bem/rank-value.less
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@
&--iron {
--font-weight: 400;
}

a& {
.link-plain();
}
}
19 changes: 15 additions & 4 deletions resources/js/components/user-card-tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,6 +24,10 @@ interface Props {
lookup: string;
}

interface UserCardQtipApi {
target: JQuery<HTMLElement>;
}

const userCardTooltipClass = 'qtip--user-card';
let inCard = false;
let tooltipWithActiveMenu: string | null = null;
Expand Down Expand Up @@ -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');
Expand All @@ -198,6 +205,7 @@ export function startListening() {
@observer
export class UserCardTooltip extends React.PureComponent<Props> {
@observable private readonly activeKeyState = new ActiveKeyState<string>();
private readonly currentMode?: Ruleset;
private readonly disposer?: () => void;
@observable private user?: UserJson;
private xhr?;
Expand All @@ -214,11 +222,13 @@ export class UserCardTooltip extends React.PureComponent<Props> {
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;
}))
Expand Down Expand Up @@ -249,6 +259,7 @@ export class UserCardTooltip extends React.PureComponent<Props> {
<KeyContext.Provider value={this.props.lookup}>
<UserCard
activated={activated}
currentMode={this.currentMode}
reportable={this.reportable}
user={this.user}
/>
Expand Down
13 changes: 12 additions & 1 deletion resources/js/components/user-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -32,6 +34,7 @@ export const viewModes: ViewMode[] = ['card', 'list', 'brick'];

interface Props {
activated: boolean;
currentMode?: Ruleset;
mode: ViewMode;
modifiers?: Modifiers;
reportable?: Reportable;
Expand Down Expand Up @@ -77,6 +80,10 @@ export class UserCard extends React.PureComponent<Props, State> {
&& !core.currentUserModel.blocks.has(this.user.id);
}

private get currentMode() {
return this.props.currentMode ?? 'osu';
}

private get isOnline() {
return this.user.is_online;
}
Expand Down Expand Up @@ -204,7 +211,11 @@ export class UserCard extends React.PureComponent<Props, State> {
<div className='user-card__icons user-card__icons--card'>
<a
className='user-card__icon user-card__icon--flag'
href={route('rankings', { country: this.user.country_code, mode: 'osu', type: 'performance' })}
href={performanceRankingUrl({
country: this.user.country_code,
mode: this.currentMode,
rank: this.user.statistics?.country_rank,
})}
>
<FlagCountry country={this.user.country} />
</a>
Expand Down
1 change: 1 addition & 0 deletions resources/js/components/user-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default class UserLink extends React.PureComponent<Props> {
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}
Expand Down
7 changes: 6 additions & 1 deletion resources/js/profile-page/cover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -87,7 +88,11 @@ export default class Cover extends React.Component<Props> {
{this.props.user.country?.code != null &&
<a
className='profile-info__flag'
href={route('rankings', { country: this.props.user.country.code, mode: this.props.currentMode, type: 'performance' })}
href={performanceRankingUrl({
country: this.props.user.country.code,
mode: this.props.currentMode,
rank: this.props.user.statistics?.country_rank,
})}
>
<FlagCountry country={this.props.user.country} />
<span className='profile-info__flag-text'>{this.props.user.country.name}</span>
Expand Down
15 changes: 13 additions & 2 deletions resources/js/profile-page/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,19 @@ export default class Detail extends React.Component<Props> {
<div>
<div className='profile-detail__chart-numbers profile-detail__chart-numbers--top'>
<div className='profile-detail__values'>
<Rank highest={this.user.rank_highest} stats={this.user.statistics} type='global' />
<Rank stats={this.user.statistics} type='country' />
<Rank
country={this.user.country}
highest={this.user.rank_highest}
mode={this.props.controller.currentMode}
stats={this.user.statistics}
type='global'
/>
<Rank
country={this.user.country}
mode={this.props.controller.currentMode}
stats={this.user.statistics}
type='country'
/>
</div>
<div className='profile-detail__values'>
<Matchmaking allStats={this.user.matchmaking_stats} />
Expand Down
46 changes: 32 additions & 14 deletions resources/js/profile-page/rank.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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[] = [];
Expand All @@ -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 (
<ValueDisplay
label={trans(`users.show.rank.${type}_simple`)}
modifiers='rank'
value={
<div
className={classWithModifiers('rank-value', tier ?? 'base')}
data-html-title={tooltip.join('')}
data-tooltip-position='bottom left'
style={{
'--colour': tierVar,
} as React.CSSProperties}
title=''
>
{rank != null ? (
`#${formatNumber(rank)}`
) : '-'}
</div>
href == null ? (
<div {...rankValueProps}>
{rankText}
</div>
) : (
<a {...rankValueProps} href={href}>
{rankText}
</a>
)
}
/>
);
Expand Down
33 changes: 33 additions & 0 deletions resources/js/utils/ranking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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',
});
}
16 changes: 14 additions & 2 deletions resources/js/utils/user.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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[] }>;
}
1 change: 1 addition & 0 deletions resources/views/rankings/_main_column.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class="u-contents"
<a
class="ranking-page-table-main__link js-usercard"
data-tooltip-position="right center"
data-usercard-ruleset="{{ $params['mode'] }}"
data-user-id="{{ $object->getKey() }}"
href="{{ route('users.show', ['user' => $object->getKey(), 'mode' => $params['mode']]) }}"
>
Expand Down
48 changes: 48 additions & 0 deletions tests/Controllers/Users/LookupControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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);
}
}
Loading