diff --git a/app/Http/Controllers/CommentsController.php b/app/Http/Controllers/CommentsController.php index c17d77b199e..b89ce074b41 100644 --- a/app/Http/Controllers/CommentsController.php +++ b/app/Http/Controllers/CommentsController.php @@ -67,11 +67,12 @@ public function destroy($id) * * `pinned_comments` is only included when `commentable_type` and `commentable_id` are specified. * - * @queryParam commentable_type The type of resource to get comments for. - * @queryParam commentable_id The id of the resource to get comments for. - * @queryParam cursor Pagination option. See [CommentSort](#commentsort) for detail. The format follows [Cursor](#cursor) except it's not currently included in the response. - * @queryParam parent_id Limit to comments which are reply to the specified id. Specify 0 to get top level comments. - * @queryParam sort Sort option as defined in [CommentSort](#commentsort). Defaults to `new` for guests and user-specified default when authenticated. + * @queryParam after Return comments which come after the specified comment id as per sort option. No-example + * @queryParam commentable_type The type of resource to get comments for. Example: beatmapset + * @queryParam commentable_id The id of the resource to get comments for. Example: 1 + * @queryParam cursor Pagination option. See [CommentSort](#commentsort) for detail. The format follows [Cursor](#cursor) except it's not currently included in the response. No-example + * @queryParam parent_id Limit to comments which are reply to the specified id. Specify 0 to get top level comments. Example: 1 + * @queryParam sort Sort option as defined in [CommentSort](#commentsort). Defaults to `new` for guests and user-specified default when authenticated. Example: new */ public function index() { diff --git a/app/Http/Controllers/GroupHistoryController.php b/app/Http/Controllers/GroupHistoryController.php index e6d7949a0b4..0d409450179 100644 --- a/app/Http/Controllers/GroupHistoryController.php +++ b/app/Http/Controllers/GroupHistoryController.php @@ -7,6 +7,7 @@ namespace App\Http\Controllers; +use App\Models\Group; use App\Models\User; use App\Models\UserGroupEvent; @@ -56,7 +57,7 @@ public function index() if ($skipQuery) { $cursor = null; - $events = []; + $events = collect(); } else { $cursorHelper = UserGroupEvent::makeDbCursorHelper($params['sort']); [$events, $hasMore] = $query @@ -66,9 +67,20 @@ public function index() $cursor = $cursorHelper->next($events, $hasMore); } - return [ + $eventGroupIds = $events->pluck('group_id'); + $groups = app('groups')->all()->filter( + fn (Group $group) => + $eventGroupIds->contains($group->getKey()) || + priv_check('GroupShow', $group)->can(), + ); + $json = [ 'events' => json_collection($events, 'UserGroupEvent'), + 'groups' => json_collection($groups, 'Group'), ...cursor_for_response($cursor), ]; + + return is_json_request() + ? $json + : ext_view('group_history.index', compact('json')); } } diff --git a/app/Http/Controllers/GroupsController.php b/app/Http/Controllers/GroupsController.php index d02003e05df..3ad0ba3220e 100644 --- a/app/Http/Controllers/GroupsController.php +++ b/app/Http/Controllers/GroupsController.php @@ -12,7 +12,8 @@ class GroupsController extends Controller public function show($id) { $group = app('groups')->byIdOrFail($id); - abort_unless($group->hasListing(), 404); + abort_if($group->identifier === 'default', 404); + priv_check('GroupShow', $group)->ensureCan(); $currentMode = default_mode(); $users = $group->users() diff --git a/app/Libraries/CommentBundle.php b/app/Libraries/CommentBundle.php index 7404eb0f023..359d3380cec 100644 --- a/app/Libraries/CommentBundle.php +++ b/app/Libraries/CommentBundle.php @@ -171,7 +171,9 @@ public function countForPaginator() private function getComments($query, $isChildren = true, $pinnedOnly = false) { - $sortOrCursorHelper = $pinnedOnly ? 'new' : $this->params->cursorHelper; + $cursorHelper = $pinnedOnly + ? Comment::makeDbCursorHelper('new') + : $this->params->cursorHelper; $queryLimit = $this->params->limit; if (!$isChildren) { @@ -180,14 +182,20 @@ private function getComments($query, $isChildren = true, $pinnedOnly = false) } $queryLimit++; - $cursor = $this->params->cursor; + + if ($this->params->after === null) { + $cursor = $this->params->cursor; + } else { + $lastComment = Comment::findOrFail($this->params->after); + $cursor = $cursorHelper->next([$lastComment]); + } if ($cursor === null) { $query->offset(max_offset($this->params->page, $this->params->limit)); } } - $query->cursorSort($sortOrCursorHelper, $cursor ?? null); + $query->cursorSort($cursorHelper, $cursor ?? null); if (!$this->includeDeleted) { $query->whereNull('deleted_at'); diff --git a/app/Libraries/CommentBundleParams.php b/app/Libraries/CommentBundleParams.php index 8b7a0fbab63..2c84fa2e469 100644 --- a/app/Libraries/CommentBundleParams.php +++ b/app/Libraries/CommentBundleParams.php @@ -13,6 +13,7 @@ class CommentBundleParams const DEFAULT_PAGE = 1; const DEFAULT_LIMIT = 50; + public ?int $after = null; public $userId; public $commentableId; public $commentableType; @@ -64,6 +65,7 @@ public function setAll($params) $this->cursorHelper = Comment::makeDbCursorHelper($params['sort'] ?? $this->sort); $this->cursor = get_arr($params['cursor'] ?? null); $this->sort = $this->cursorHelper->getSortName(); + $this->after = get_int($params['after'] ?? null); } public function filterByParentId() diff --git a/app/Libraries/OsuAuthorize.php b/app/Libraries/OsuAuthorize.php index dc353501019..bc9116222b0 100644 --- a/app/Libraries/OsuAuthorize.php +++ b/app/Libraries/OsuAuthorize.php @@ -21,6 +21,7 @@ use App\Models\Forum\Topic; use App\Models\Forum\TopicCover; use App\Models\Genre; +use App\Models\Group; use App\Models\Language; use App\Models\LegacyMatch\LegacyMatch; use App\Models\Multiplayer\Room; @@ -1768,6 +1769,15 @@ public function checkForumTopicVote(?User $user, Topic $topic): string return 'ok'; } + public function checkGroupShow(?User $user, Group $group): string + { + if ($group->hasListing() || $user?->isGroup($group)) { + return 'ok'; + } + + return 'unauthorized'; + } + public function checkIsOwnClient(?User $user, Client $client): string { if ($user === null || $user->getKey() !== $client->user_id) { @@ -1917,6 +1927,10 @@ public function checkScorePin(?User $user, ScoreBest|Solo\Score $score): string public function checkUserGroupEventShowActor(?User $user, UserGroupEvent $event): string { + if ($event->group->identifier === 'default') { + return $user?->isPrivileged() ? 'ok' : 'unauthorized'; + } + if ($user?->isGroup($event->group)) { return 'ok'; } diff --git a/app/Libraries/RouteSection.php b/app/Libraries/RouteSection.php index af03c091baf..ef13fd82132 100644 --- a/app/Libraries/RouteSection.php +++ b/app/Libraries/RouteSection.php @@ -70,6 +70,9 @@ class RouteSection 'friends_controller' => [ '_' => 'home', ], + 'group_history_controller' => [ + '_' => 'home', + ], 'groups_controller' => [ '_' => 'home', ], diff --git a/app/Libraries/Search/BeatmapsetQueryParser.php b/app/Libraries/Search/BeatmapsetQueryParser.php index b040e6932e1..7d9c7aeee15 100644 --- a/app/Libraries/Search/BeatmapsetQueryParser.php +++ b/app/Libraries/Search/BeatmapsetQueryParser.php @@ -59,7 +59,7 @@ public static function parse(?string $query): array $option = static::makeIntRangeOption($op, $m['value']); break; case 'status': - $option = static::makeIntRangeOption($op, Beatmapset::STATES[$m['value']] ?? null); + $option = static::makeIntRangeOption($op, static::statePrefixSearch($m['value'])); break; case 'creator': $option = static::makeTextOption($op, $m['value']); @@ -224,4 +224,23 @@ private static function makeTextOption($operator, $value) return presence(trim($value, '"')); } } + + private static function statePrefixSearch($value): ?int + { + if (!present($value)) { + return null; + } + + if (isset(Beatmapset::STATES[$value])) { + return Beatmapset::STATES[$value]; + } + + foreach (Beatmapset::STATES as $string => $int) { + if (starts_with($string, $value)) { + return $int; + } + } + + return null; + } } diff --git a/app/Models/Multiplayer/Room.php b/app/Models/Multiplayer/Room.php index fd6e409635f..c310f408a5f 100644 --- a/app/Models/Multiplayer/Room.php +++ b/app/Models/Multiplayer/Room.php @@ -597,7 +597,7 @@ public function startPlay(User $user, PlaylistItem $playlistItem) return $this->getConnection()->transaction(function () use ($user, $playlistItem) { $agg = UserScoreAggregate::new($user, $this); - if ($agg->isNew) { + if ($agg->wasRecentlyCreated) { $this->incrementInstance('participant_count'); } diff --git a/app/Models/Multiplayer/UserScoreAggregate.php b/app/Models/Multiplayer/UserScoreAggregate.php index 817aa75fdfe..38dbe095e19 100644 --- a/app/Models/Multiplayer/UserScoreAggregate.php +++ b/app/Models/Multiplayer/UserScoreAggregate.php @@ -43,8 +43,6 @@ class UserScoreAggregate extends Model ]; protected $table = 'multiplayer_rooms_high'; - public $isNew = false; - public static function getPlaylistItemUserHighScore(Score $score) { return PlaylistItemUserHighScore::firstOrNew([ @@ -53,19 +51,18 @@ public static function getPlaylistItemUserHighScore(Score $score) ]); } - public static function lookupOrDefault(User $user, Room $room): self + public static function lookupOrDefault(User $user, Room $room): static { - $obj = static::firstOrNew([ - 'user_id' => $user->getKey(), + return static::firstOrNew([ 'room_id' => $room->getKey(), + 'user_id' => $user->getKey(), + ], [ + 'accuracy' => 0, + 'attempts' => 0, + 'completed' => 0, + 'pp' => 0, + 'total_score' => 0, ]); - - foreach (['total_score', 'accuracy', 'pp', 'attempts', 'completed'] as $key) { - // init if required - $obj->$key = $obj->$key ?? 0; - } - - return $obj; } public static function updatePlaylistItemUserHighScore(PlaylistItemUserHighScore $highScore, Score $score) @@ -87,7 +84,6 @@ public static function new(User $user, Room $room): self $obj = static::lookupOrDefault($user, $room); if (!$obj->exists) { - $obj->isNew = true; $obj->save(); // force a save now to avoid being trolled later. $obj->recalculate(); } diff --git a/package.json b/package.json index f69f4f855f0..4c0264aeacd 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@discordapp/twemoji": "^14.0.2", - "@fortawesome/fontawesome-free": "^5.6.3", + "@fortawesome/fontawesome-free": "^5.15.4", "@types/bootstrap": "^3.3.0", "@types/d3": "^7.1.0", "@types/grecaptcha": "^3.0.1", diff --git a/resources/css/bem-index.less b/resources/css/bem-index.less index 71b4044667c..4a90ab34aff 100644 --- a/resources/css/bem-index.less +++ b/resources/css/bem-index.less @@ -178,6 +178,9 @@ @import "bem/game-mode"; @import "bem/game-mode-link"; @import "bem/grid-items"; +@import "bem/group-history"; +@import "bem/group-history-event"; +@import "bem/group-history-search-form"; @import "bem/header-buttons"; @import "bem/header-nav-mobile"; @import "bem/header-nav-v4"; diff --git a/resources/css/bem/group-history-event.less b/resources/css/bem/group-history-event.less new file mode 100644 index 00000000000..047659bae5d --- /dev/null +++ b/resources/css/bem/group-history-event.less @@ -0,0 +1,64 @@ +// 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. + +.group-history-event { + @_top: group-history-event; + + align-items: center; + display: flex; + font-size: @font-size--title-small; + gap: 15px; + + &__icon { + @icons: { + group-add: users; + group-remove: users-slash; + group-rename: users-cog; + user-add: user-plus; + user-add-playmodes: user-tag; + user-remove: user-minus; + user-remove-playmodes: user-tag; + user-set-default: user-cog; + }; + each(@icons, { + .@{_top}--@{key} & { + @icon-var: 'fa-var-@{value}'; + --icon: @@icon-var; + } + }); + + .fas(); + background-color: var(--group-colour, @osu-colour-b1); + border-radius: 10000px; + color: @osu-colour-b6; + padding: 3px 6px; + + &::before { + content: var(--icon); + } + } + + &__info { + color: @osu-colour-f1; + display: flex; + flex-direction: column; + flex-shrink: 0; + font-size: @font-size--normal; + gap: 0 15px; + + @media @desktop { + flex-direction: row-reverse; + } + } + + &__message { + @bold-events: group-add, group-remove, group-rename; + each(@bold-events, { + .@{_top}--@{value} & { + font-weight: bold; + } + }); + + flex-grow: 1; + } +} diff --git a/resources/css/bem/group-history-search-form.less b/resources/css/bem/group-history-search-form.less new file mode 100644 index 00000000000..8ba7d3657e5 --- /dev/null +++ b/resources/css/bem/group-history-search-form.less @@ -0,0 +1,63 @@ +// 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. + +.group-history-search-form { + --input-bg: @osu-colour-b5; + --input-border-radius: @border-radius--large; + background: @osu-colour-b4; + + &__content { + --vertical-gutter: 20px; + .default-gutter-v2(); + padding-top: var(--vertical-gutter); + padding-bottom: var(--vertical-gutter); + + &--buttons { + --vertical-gutter: 10px; + background-color: @osu-colour-b3; + display: flex; + gap: 10px; + justify-content: center; + } + + &--inputs { + display: grid; + gap: 10px; + grid-template-columns: repeat(2, 1fr) repeat(2, 180px); + + @media @mobile { + grid-template-columns: repeat(2, 1fr); + + > :nth-child(-n + 2) { + grid-column: span 2; + } + } + } + } + + &__input { + .reset-input(); + font-size: @font-size--title-small-3; + width: 100%; + } + + &__label { + color: var(--label-colour); + padding-bottom: 5px; + } + + &__select-container { + position: relative; + + &::after { + .fas(); + .center-content(); + content: @fa-var-chevron-down; + height: 100%; + padding-left: 10px; + position: absolute; + right: 5px; + pointer-events: none; + } + } +} diff --git a/resources/css/bem/group-history.less b/resources/css/bem/group-history.less new file mode 100644 index 00000000000..bda5856168d --- /dev/null +++ b/resources/css/bem/group-history.less @@ -0,0 +1,22 @@ +// 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. + +.group-history { + &__events { + display: flex; + flex-direction: column; + gap: 10px; + } + + &__none { + font-size: @font-size--title-small-3; + margin: 0; + text-align: center; + } + + &__staff-log { + font-size: @font-size--normal; + margin: 20px 0 0; + text-align: center; + } +} diff --git a/resources/css/bem/osu-page.less b/resources/css/bem/osu-page.less index ceffd47457c..bcf9500bcad 100644 --- a/resources/css/bem/osu-page.less +++ b/resources/css/bem/osu-page.less @@ -136,18 +136,6 @@ padding-bottom: 20px; } - &--download { - .page-width(20px); - .default; - - display: flex; - flex-direction: column; - - @media @desktop { - .page-width-desktop(20px); - } - } - &--forum { background-color: @osu-colour-b5; color: @osu-colour-c1; @@ -159,13 +147,6 @@ color: @osu-colour-c1; } - &--forum-topic-feature-vote { - .default-box-shadow(); - background-color: #fff; - padding: 20px; - margin-bottom: 5px; - } - &--forum-topic-reply { background-color: @osu-colour-b2; color: @osu-colour-c1; @@ -196,12 +177,6 @@ } } - &--forum-topic-watches-list { - .default(); - padding-top: 10px; - padding-bottom: 10px; - } - &--full { flex: 1 0 auto; } @@ -217,16 +192,6 @@ .default; } - &--header { - .default; - margin-bottom: 0; - } - - &--header-news, &--groups { - .default-box-shadow(); - background-color: #333; - } - &--info-bar { .default-gutter-v2(); padding-top: 5px; @@ -270,30 +235,12 @@ } } - &--store-product { - .default-gutter-v2(); - padding-top: 20px; - padding-bottom: 20px; - background-color: @osu-colour-b5; - color: @osu-colour-c1; - } - &--supporter { .default; display: flex; flex-direction: column; } - &--users { - .default(); - background-color: hsl(var(--hsl-b3)); - margin-bottom: 0; - } - - &--users-show-header { - .default-box-shadow(); - } - &--wiki { .default(); font-size: @font-size--wiki; diff --git a/resources/css/bem/show-more-link.less b/resources/css/bem/show-more-link.less index 7e60b002891..226815b2e71 100644 --- a/resources/css/bem/show-more-link.less +++ b/resources/css/bem/show-more-link.less @@ -38,7 +38,8 @@ margin: 40px 0; } - &--chat-conversation-earlier-messages { + &--chat-conversation-earlier-messages, + &--group-history { margin: 20px auto 0; } diff --git a/resources/js/components/comment-show-more.tsx b/resources/js/components/comment-show-more.tsx index aa36b60742a..05aa699957c 100644 --- a/resources/js/components/comment-show-more.tsx +++ b/resources/js/components/comment-show-more.tsx @@ -67,12 +67,7 @@ export default class CommentShowMore extends React.Component { const lastComment = last(this.props.comments); if (lastComment != null) { - // TODO: convert to plain after_id params of some sort instead of cursor - params.cursor = { - created_at: lastComment.createdAt, - id: lastComment.id, - votes_count: lastComment.votesCount, - }; + params.after = lastComment.id; } this.xhr = $.ajax(route('comments.index'), { data: params, dataType: 'json' }); diff --git a/resources/js/contest-voting/art-entry-list.coffee b/resources/js/contest-voting/art-entry-list.coffee index 1856fe25cf2..c1c1f80cb28 100644 --- a/resources/js/contest-voting/art-entry-list.coffee +++ b/resources/js/contest-voting/art-entry-list.coffee @@ -14,8 +14,8 @@ export class ArtEntryList extends BaseEntryList selected = new Set(@state.selected) - displayIndex = -1 - entries = @state.contest.entries.map (entry) => + galleryIndex = -1 + entries = @state.contest.entries.map (entry, index) => isSelected = selected.has(entry.id) return null if @state.showVotedOnly && !isSelected @@ -23,7 +23,8 @@ export class ArtEntryList extends BaseEntryList el ArtEntry, key: entry.id, contest: @state.contest, - displayIndex: ++displayIndex, + galleryIndex: ++galleryIndex, + index: index entry: entry, isSelected: isSelected options: @state.options, @@ -32,7 +33,7 @@ export class ArtEntryList extends BaseEntryList if @state.contest.show_votes partitions = _.partition entries, (i) -> - i != null && i.props.displayIndex < 3 + i != null && i.props.index < 3 div className: 'contest__art-list', div className: 'contest__vote-summary--art', diff --git a/resources/js/contest-voting/art-entry.coffee b/resources/js/contest-voting/art-entry.coffee index c0ae5e9eb5d..4df767e8754 100644 --- a/resources/js/contest-voting/art-entry.coffee +++ b/resources/js/contest-voting/art-entry.coffee @@ -22,11 +22,11 @@ export class ArtEntry extends React.Component showUserLink = @props.entry.user?.id? thumbnailShape = @props.contest.thumbnail_shape galleryId = "contest-#{@props.contest.id}" - buttonId = "#{galleryId}:#{@props.displayIndex}" + buttonId = "#{galleryId}:#{@props.entry.id}" hideVoteButton = (@props.selected.length >= @props.contest.max_votes || votingOver) && !isSelected if showVotes - place = @props.displayIndex + 1 + place = @props.index + 1 top3 = place <= 3 usersVotedPercentage = _.round((@props.entry.results.votes / @props.contest.users_voted_count)*100, 2) @@ -39,7 +39,7 @@ export class ArtEntry extends React.Component entryLinkProps['data-width'] = @props.entry.artMeta.width entryLinkProps['data-height'] = @props.entry.artMeta.height entryLinkProps['data-gallery-id'] = galleryId - entryLinkProps['data-index'] = @props.displayIndex + entryLinkProps['data-index'] = @props.galleryIndex entryLinkProps['data-button-id'] = buttonId else entryLinkProps.rel = 'nofollow noreferrer' diff --git a/resources/js/entrypoints/group-history.tsx b/resources/js/entrypoints/group-history.tsx new file mode 100644 index 00000000000..a7cc98bb67d --- /dev/null +++ b/resources/js/entrypoints/group-history.tsx @@ -0,0 +1,17 @@ +// 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 groupStore from 'group-history/group-store'; +import GroupHistoryJson from 'group-history/json'; +import Main from 'group-history/main'; +import core from 'osu-core-singleton'; +import * as React from 'react'; +import { parseJson } from 'utils/json'; + +core.reactTurbolinks.register('group-history', () => { + const json: GroupHistoryJson = parseJson('json-group-history'); + + groupStore.updateMany(json.groups); + + return
; +}); diff --git a/resources/js/group-history/event.tsx b/resources/js/group-history/event.tsx new file mode 100644 index 00000000000..7bcf1117c76 --- /dev/null +++ b/resources/js/group-history/event.tsx @@ -0,0 +1,92 @@ +// 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 StringWithComponent from 'components/string-with-component'; +import TimeWithTooltip from 'components/time-with-tooltip'; +import UserLink from 'components/user-link'; +import UserGroupEventJson from 'interfaces/user-group-event-json'; +import { route } from 'laroute'; +import { kebabCase } from 'lodash'; +import * as React from 'react'; +import { classWithModifiers, groupColour } from 'utils/css'; +import { trans, transArray } from 'utils/lang'; +import groupStore from './group-store'; + +interface Props { + event: UserGroupEventJson; +} + +export default class Event extends React.PureComponent { + private get messageMappings() { + const event = this.props.event; + const mappings: Record = { + group: ( + + {event.group_name} + + ), + }; + + if ('playmodes' in event && event.playmodes != null) { + mappings.playmodes = transArray( + event.playmodes.map((mode) => trans(`beatmaps.mode.${mode}`)), + ); + } + + if ('previous_group_name' in event) { + mappings.previous_group = ( + + {event.previous_group_name} + + ); + } + + if (event.user_id != null) { + mappings.user = ; + } + + return mappings; + } + + private get messagePattern() { + const event = this.props.event; + const type = event.type === 'user_add' && event.playmodes != null + ? 'user_add_with_playmodes' + : event.type; + + return trans(`group_history.event.message.${type}`); + } + + render() { + return ( +
+ +
+ +
+
+ + {this.props.event.actor?.id != null && ( + + , + }} + pattern={trans('group_history.event.actor')} + /> + + )} +
+
+ ); + } +} diff --git a/resources/js/group-history/events.tsx b/resources/js/group-history/events.tsx new file mode 100644 index 00000000000..86aa5524594 --- /dev/null +++ b/resources/js/group-history/events.tsx @@ -0,0 +1,29 @@ +// 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 UserGroupEventJson from 'interfaces/user-group-event-json'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { trans } from 'utils/lang'; +import Event from './event'; + +interface Props { + events: UserGroupEventJson[]; +} + +@observer +export default class Events extends React.Component { + render() { + return this.props.events.length > 0 ? ( +
+ {this.props.events.map((event) => ( + + ))} +
+ ) : ( +

+ {trans('group_history.none')} +

+ ); + } +} diff --git a/resources/js/group-history/group-store.ts b/resources/js/group-history/group-store.ts new file mode 100644 index 00000000000..ac7b3fec0bd --- /dev/null +++ b/resources/js/group-history/group-store.ts @@ -0,0 +1,45 @@ +// 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 GroupJson from 'interfaces/group-json'; +import { sortBy } from 'lodash'; +import { action, computed, makeObservable, observable } from 'mobx'; + +class GroupStore { + @observable byId = observable.map(); + + @computed + get byIdentifier() { + return this.groups.reduce( + (prev, group) => { + prev.set(group.identifier, group); + return prev; + }, + new Map(), + ); + } + + @computed + get groups() { + return sortBy([...this.byId.values()], 'name'); + } + + constructor() { + makeObservable(this); + } + + @action + update(group: GroupJson): void { + this.byId.set(group.id, group); + } + + @action + updateMany(groups: GroupJson[]): void { + for (const group of groups) { + this.update(group); + } + } +} + +const groupStore = new GroupStore(); +export default groupStore; diff --git a/resources/js/group-history/json.ts b/resources/js/group-history/json.ts new file mode 100644 index 00000000000..36176688b71 --- /dev/null +++ b/resources/js/group-history/json.ts @@ -0,0 +1,11 @@ +// 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 GroupJson from 'interfaces/group-json'; +import UserGroupEventJson from 'interfaces/user-group-event-json'; + +export default interface GroupHistoryJson { + cursor_string: string | null; + events: UserGroupEventJson[]; + groups: GroupJson[]; +} diff --git a/resources/js/group-history/main.tsx b/resources/js/group-history/main.tsx new file mode 100644 index 00000000000..9d28d247f79 --- /dev/null +++ b/resources/js/group-history/main.tsx @@ -0,0 +1,133 @@ +// 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 HeaderV4 from 'components/header-v4'; +import ShowMoreLink from 'components/show-more-link'; +import StringWithComponent from 'components/string-with-component'; +import UserGroupEventJson from 'interfaces/user-group-event-json'; +import { route } from 'laroute'; +import { action, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { onErrorWithCallback } from 'utils/ajax'; +import { trans } from 'utils/lang'; +import { wikiUrl } from 'utils/url'; +import Events from './events'; +import groupStore from './group-store'; +import GroupHistoryJson from './json'; +import { getQueryFromUrl, GroupHistoryQuery, setUrlFromQuery } from './query'; +import SearchForm from './search-form'; + +interface Props { + cursorString: string | null; + events: UserGroupEventJson[]; +} + +@observer +export default class Main extends React.Component { + @observable private currentQuery: GroupHistoryQuery; + @observable private cursorString: string | null; + @observable private events: UserGroupEventJson[]; + @observable private loading?: 'more' | 'new'; + @observable private newQuery: GroupHistoryQuery; + private xhr?: JQuery.jqXHR; + + constructor(props: Props) { + super(props); + + const { parseError, query } = getQueryFromUrl(); + + this.currentQuery = query; + this.cursorString = props.cursorString; + this.events = props.events; + this.newQuery = { ...this.currentQuery }; + + makeObservable(this); + + if (parseError) { + this.onSearch(); + } + } + + componentWillUnmount() { + this.xhr?.abort(); + } + + render() { + return ( + <> + +
+ +
+
+ + +

+ + {trans('group_history.staff_log.wiki_articles')} + + ), + }} + pattern={trans('group_history.staff_log._')} + /> +

+
+ + ); + } + + @action + private loadEvents(query: GroupHistoryQuery & { cursor_string?: string }) { + this.xhr?.abort(); + this.loading = query.cursor_string == null ? 'new' : 'more'; + + this.xhr = $.ajax( + route('group-history.index'), + { + data: query, + dataType: 'JSON', + method: 'GET', + }, + ); + this.xhr + .done(action((response: GroupHistoryJson) => { + this.cursorString = response.cursor_string; + groupStore.updateMany(response.groups); + + if (query.cursor_string == null) { + this.currentQuery = { ...query }; + this.events = response.events; + setUrlFromQuery(query); + } else { + this.events.push(...response.events); + } + })) + .fail(onErrorWithCallback(() => this.loadEvents(query))) + .always(action(() => this.loading = undefined)); + } + + private readonly onSearch = () => this.loadEvents(this.newQuery); + + private readonly onShowMore = () => { + if (this.cursorString != null) { + this.loadEvents({ + ...this.currentQuery, + cursor_string: this.cursorString, + }); + } + }; +} diff --git a/resources/js/group-history/query.ts b/resources/js/group-history/query.ts new file mode 100644 index 00000000000..ed266607bdc --- /dev/null +++ b/resources/js/group-history/query.ts @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import moment from 'moment'; +import { currentUrlParams } from 'utils/turbolinks'; +import { updateQueryString } from 'utils/url'; +import groupStore from './group-store'; + +export interface GroupHistoryQuery { + after?: string; + before?: string; + group?: string; + user?: string; +} + +export const emptyQuery: Readonly = Object.freeze({ + after: undefined, + before: undefined, + group: undefined, + user: undefined, +}); + +const paramValidators: Record boolean> = { + after: (value: string) => moment(value, moment.HTML5_FMT.DATE, true).isValid(), + before: (value: string) => moment(value, moment.HTML5_FMT.DATE, true).isValid(), + group: (value: string) => groupStore.byIdentifier.has(value), + user: (value: string) => value.length > 0, +}; + +export function getQueryFromUrl(): { parseError: boolean; query: GroupHistoryQuery } { + const params = currentUrlParams(); + let parseError = false; + const query: GroupHistoryQuery = {}; + + for (const key of Object.keys(emptyQuery) as (keyof GroupHistoryQuery)[]) { + const value = params.get(key); + + if (value != null && !paramValidators[key](value)) { + parseError = true; + query[key] = undefined; + } else { + query[key] = value ?? undefined; + } + } + + return { parseError, query }; +} + +export function setUrlFromQuery(query: Readonly): void { + history.replaceState( + history.state, + '', + updateQueryString(null, query), + ); +} diff --git a/resources/js/group-history/search-form.tsx b/resources/js/group-history/search-form.tsx new file mode 100644 index 00000000000..bca1da12ef7 --- /dev/null +++ b/resources/js/group-history/search-form.tsx @@ -0,0 +1,148 @@ +// 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 BigButton from 'components/big-button'; +import InputContainer from 'components/input-container'; +import { isEqual } from 'lodash'; +import { action, computed, makeObservable } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { trans } from 'utils/lang'; +import groupStore from './group-store'; +import { emptyQuery, GroupHistoryQuery } from './query'; + +const bn = 'group-history-search-form'; + +interface Props { + currentQuery: GroupHistoryQuery; + loading: boolean; + newQuery: GroupHistoryQuery; + onSearch: () => void; +} + +@observer +export default class SearchForm extends React.Component { + @computed + private get newQueryIsEmpty() { + return isEqual(this.props.newQuery, emptyQuery); + } + + @computed + private get newQueryIsSame() { + return isEqual(this.props.newQuery, this.props.currentQuery); + } + + constructor(props: Props) { + super(props); + makeObservable(this); + } + + render() { + return ( +
+
+ +
+ +
+
+ + + + + + + + + +
+
+ + +
+
+ ); + } + + @action + private readonly onDateChange = (event: React.ChangeEvent) => { + event.preventDefault(); + + this.props.newQuery[event.currentTarget.name as 'after' | 'before'] = + event.currentTarget.value || undefined; + }; + + @action + private readonly onGroupChange = (event: React.ChangeEvent) => { + event.preventDefault(); + + this.props.newQuery.group = event.currentTarget.value || undefined; + }; + + @action + private readonly onReset = (event: React.MouseEvent) => { + event.preventDefault(); + + Object.assign(this.props.newQuery, emptyQuery); + }; + + private readonly onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!this.newQueryIsSame) { + this.props.onSearch(); + } + }; + + @action + private readonly onUserChange = (event: React.ChangeEvent) => { + event.preventDefault(); + + this.props.newQuery.user = event.currentTarget.value || undefined; + }; +} diff --git a/resources/lang/en/group_history.php b/resources/lang/en/group_history.php new file mode 100644 index 00000000000..805d3675399 --- /dev/null +++ b/resources/lang/en/group_history.php @@ -0,0 +1,38 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +return [ + 'none' => 'No group history found!', + + 'event' => [ + 'actor' => 'by :user', + + 'message' => [ + 'group_add' => ':group created.', + 'group_remove' => ':group deleted.', + 'group_rename' => ':previous_group renamed to :group.', + 'user_add' => ':user added to :group.', + 'user_add_with_playmodes' => ':user added to :group for :playmodes.', + 'user_add_playmodes' => ':playmodes added to :user\'s :group membership.', + 'user_remove' => ':user removed from :group.', + 'user_remove_playmodes' => ':playmodes removed from :user\'s :group membership.', + 'user_set_default' => ':user\'s default group set to :group.', + ], + ], + + 'form' => [ + 'after' => 'After', + 'before' => 'Before', + 'group' => 'Group', + 'group_all' => 'All groups', + 'user' => 'User', + 'user_prompt' => 'Username or ID', + ], + + 'staff_log' => [ + '_' => 'Older group history can be found in :wiki_articles.', + 'wiki_articles' => 'the staff log wiki articles', + ], +]; diff --git a/resources/lang/en/page_title.php b/resources/lang/en/page_title.php index 8d627192274..ce369e7ac1b 100644 --- a/resources/lang/en/page_title.php +++ b/resources/lang/en/page_title.php @@ -66,6 +66,9 @@ 'contests_controller' => [ '_' => 'contests', ], + 'group_history_controller' => [ + '_' => 'group history', + ], 'groups_controller' => [ 'show' => 'groups', ], diff --git a/resources/views/group_history/index.blade.php b/resources/views/group_history/index.blade.php new file mode 100644 index 00000000000..d235a94efa3 --- /dev/null +++ b/resources/views/group_history/index.blade.php @@ -0,0 +1,19 @@ +{{-- + 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. +--}} +@extends('master') + +@section('content') +
+@endsection + +@section("script") + @parent + + + + @include('layout._react_js', ['src' => 'js/group-history.js']) +@endsection diff --git a/tests/Libraries/Search/BeatmapsetQueryParserTest.php b/tests/Libraries/Search/BeatmapsetQueryParserTest.php index 50a7b57a53a..a51d7f924d6 100644 --- a/tests/Libraries/Search/BeatmapsetQueryParserTest.php +++ b/tests/Libraries/Search/BeatmapsetQueryParserTest.php @@ -98,6 +98,8 @@ public function queryDataProvider() ['bpm=bad', ['keywords' => 'bpm=bad', 'options' => []]], ['divisor 'divisor []]], ['status=noidea', ['keywords' => 'status=noidea', 'options' => []]], + ['status=l', ['keywords' => null, 'options' => ['status' => ['gte' => Beatmapset::STATES['loved'], 'lte' => Beatmapset::STATES['loved']]]]], + ['status=lo', ['keywords' => null, 'options' => ['status' => ['gte' => Beatmapset::STATES['loved'], 'lte' => Beatmapset::STATES['loved']]]]], ]; } } diff --git a/tests/Models/Multiplayer/RoomTest.php b/tests/Models/Multiplayer/RoomTest.php index 0a174ded628..e10a870fe00 100644 --- a/tests/Models/Multiplayer/RoomTest.php +++ b/tests/Models/Multiplayer/RoomTest.php @@ -103,6 +103,22 @@ public function testRoomHasEnded() $room->startPlay($user, $playlistItem); } + public function testStartPlay(): void + { + $user = User::factory()->create(); + $room = Room::factory()->create(); + $playlistItem = PlaylistItem::factory()->create(['room_id' => $room]); + + $this->expectCountChange(fn () => $room->participant_count, 1); + $this->expectCountChange(fn () => $room->userHighScores()->count(), 1); + $this->expectCountChange(fn () => $room->scores()->count(), 1); + + $room->startPlay($user, $playlistItem); + $room->refresh(); + + $this->assertSame($user->getKey(), $room->scores()->last()->user_id); + } + public function testMaxAttemptsReached() { $user = User::factory()->create(); diff --git a/yarn.lock b/yarn.lock index 2155208e7a3..b070815c1e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -227,10 +227,10 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@fortawesome/fontawesome-free@^5.6.3": - version "5.9.0" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.9.0.tgz#1aa5c59efb1b8c6eb6277d1e3e8c8f31998b8c8e" - integrity sha512-g795BBEzM/Hq2SYNPm/NQTIp3IWd4eXSH0ds87Na2jnrAUFX3wkyZAI4Gwj9DOaWMuz2/01i8oWI7P7T/XLkhg== +"@fortawesome/fontawesome-free@^5.15.4": + version "5.15.4" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz#ecda5712b61ac852c760d8b3c79c96adca5554e5" + integrity sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg== "@istanbuljs/schema@^0.1.2": version "0.1.2"