From ffee8c717dff737d6a4ba7073dcc860addb73ca4 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 1 Jun 2023 17:46:27 +0900 Subject: [PATCH 001/203] convert to typescript (rename) --- resources/js/beatmap-discussions/{main.coffee => main.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename resources/js/beatmap-discussions/{main.coffee => main.tsx} (100%) diff --git a/resources/js/beatmap-discussions/main.coffee b/resources/js/beatmap-discussions/main.tsx similarity index 100% rename from resources/js/beatmap-discussions/main.coffee rename to resources/js/beatmap-discussions/main.tsx From 390874809fe5e6055730bf444adcac21008d1b91 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 1 Jun 2023 21:38:27 +0900 Subject: [PATCH 002/203] convert to typescript wip --- resources/js/beatmap-discussions/main.tsx | 1145 +++++++++++---------- 1 file changed, 620 insertions(+), 525 deletions(-) diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index 83f0f88249a..8a52891979b 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -1,525 +1,620 @@ -# 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 { DiscussionsContext } from 'beatmap-discussions/discussions-context' -import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context' -import NewReview from 'beatmap-discussions/new-review' -import { ReviewEditorConfigContext } from 'beatmap-discussions/review-editor-config-context' -import BackToTop from 'components/back-to-top' -import { route } from 'laroute' -import { deletedUserJson } from 'models/user' -import core from 'osu-core-singleton' -import * as React from 'react' -import { div } from 'react-dom-factories' -import * as BeatmapHelper from 'utils/beatmap-helper' -import { defaultFilter, defaultMode, makeUrl, parseUrl, stateFromDiscussion } from 'utils/beatmapset-discussion-helper' -import { nextVal } from 'utils/seq' -import { currentUrl } from 'utils/turbolinks' -import { Discussions } from './discussions' -import { Events } from './events' -import { Header } from './header' -import { ModeSwitcher } from './mode-switcher' -import { NewDiscussion } from './new-discussion' - -el = React.createElement - -export class Main extends React.PureComponent - constructor: (props) -> - super props - - @eventId = "beatmap-discussions-#{nextVal()}" - @modeSwitcherRef = React.createRef() - @newDiscussionRef = React.createRef() - - @checkNewTimeoutDefault = 10000 - @checkNewTimeoutMax = 60000 - @cache = {} - @disposers = new Set - @timeouts = {} - @xhr = {} - @state = JSON.parse(props.container.dataset.beatmapsetDiscussionState ? null) - @restoredState = @state? - - if @restoredState - @state.readPostIds = new Set(@state.readPostIdsArray) - else - beatmapset = props.initial.beatmapset - reviewsConfig = props.initial.reviews_config - showDeleted = true - readPostIds = new Set - - for discussion in beatmapset.discussions - for post in discussion?.posts ? [] - readPostIds.add(post.id) if post? - - @state = {beatmapset, currentUser, readPostIds, reviewsConfig, showDeleted} - - @state.pinnedNewDiscussion ?= false - - # Current url takes priority over saved state. - query = @queryFromLocation(@state.beatmapset.discussions) - @state.currentMode = query.mode - @state.currentFilter = query.filter - @state.currentBeatmapId = query.beatmapId if query.beatmapId? - @state.selectedUserId = query.user - # FIXME: update url handler to recognize this instead - @focusNewDiscussion = currentUrl().hash == '#new' - - - componentDidMount: => - @focusNewDiscussion = false - $.subscribe "playmode:set.#{@eventId}", @setCurrentPlaymode - - $.subscribe "beatmapsetDiscussions:update.#{@eventId}", @update - $.subscribe "beatmapDiscussion:jump.#{@eventId}", @jumpTo - $.subscribe "beatmapDiscussionPost:markRead.#{@eventId}", @markPostRead - $.subscribe "beatmapDiscussionPost:toggleShowDeleted.#{@eventId}", @toggleShowDeleted - - $(document).on "ajax:success.#{@eventId}", '.js-beatmapset-discussion-update', @ujsDiscussionUpdate - $(document).on "click.#{@eventId}", '.js-beatmap-discussion--jump', @jumpToClick - $(document).on "turbolinks:before-cache.#{@eventId}", @saveStateToContainer - - if !@restoredState - @disposers.add core.reactTurbolinks.runAfterPageLoad(@jumpToDiscussionByHash) - - @timeouts.checkNew = Timeout.set @checkNewTimeoutDefault, @checkNew - - - componentDidUpdate: (_prevProps, prevState) => - return if prevState.currentBeatmapId == @state.currentBeatmapId && - prevState.currentFilter == @state.currentFilter && - prevState.currentMode == @state.currentMode && - prevState.selectedUserId == @state.selectedUserId && - prevState.showDeleted == @state.showDeleted - - Turbolinks.controller.advanceHistory @urlFromState() - - - componentWillUnmount: => - $.unsubscribe ".#{@eventId}" - $(document).off ".#{@eventId}" - - Timeout.clear(timeout) for _name, timeout of @timeouts - xhr?.abort() for _name, xhr of @xhr - @disposers.forEach (disposer) => disposer?() - - - render: => - @cache = {} - - el React.Fragment, null, - el Header, - beatmaps: @groupedBeatmaps() - beatmapset: @state.beatmapset - currentBeatmap: @currentBeatmap() - currentDiscussions: @currentDiscussions() - currentFilter: @state.currentFilter - currentUser: @state.currentUser - discussions: @discussions() - discussionStarters: @discussionStarters() - events: @state.beatmapset.events - mode: @state.currentMode - selectedUserId: @state.selectedUserId - users: @users() - - el ModeSwitcher, - innerRef: @modeSwitcherRef - mode: @state.currentMode - beatmapset: @state.beatmapset - currentBeatmap: @currentBeatmap() - currentDiscussions: @currentDiscussions() - currentFilter: @state.currentFilter - - if @state.currentMode == 'events' - el Events, - events: @state.beatmapset.events - users: @users() - discussions: @discussions() - - else - el DiscussionsContext.Provider, - value: @discussions() - el BeatmapsContext.Provider, - value: @beatmaps() - el ReviewEditorConfigContext.Provider, - value: @state.reviewsConfig - - if @state.currentMode == 'reviews' - el NewReview, - beatmapset: @state.beatmapset - beatmaps: @beatmaps() - currentBeatmap: @currentBeatmap() - currentUser: @state.currentUser - innerRef: @newDiscussionRef - pinned: @state.pinnedNewDiscussion - setPinned: @setPinnedNewDiscussion - stickTo: @modeSwitcherRef - else - el NewDiscussion, - beatmapset: @state.beatmapset - currentUser: @state.currentUser - currentBeatmap: @currentBeatmap() - currentDiscussions: @currentDiscussions() - innerRef: @newDiscussionRef - mode: @state.currentMode - pinned: @state.pinnedNewDiscussion - setPinned: @setPinnedNewDiscussion - stickTo: @modeSwitcherRef - autoFocus: @focusNewDiscussion - - el Discussions, - beatmapset: @state.beatmapset - currentBeatmap: @currentBeatmap() - currentDiscussions: @currentDiscussions() - currentFilter: @state.currentFilter - currentUser: @state.currentUser - mode: @state.currentMode - readPostIds: @state.readPostIds - showDeleted: @state.showDeleted - users: @users() - - el BackToTop - - - beatmaps: => - return @cache.beatmaps if @cache.beatmaps? - - hasDiscussion = {} - for discussion in @state.beatmapset.discussions - hasDiscussion[discussion.beatmap_id] = true if discussion? - - @cache.beatmaps ?= - _(@state.beatmapset.beatmaps) - .filter (beatmap) -> - !_.isEmpty(beatmap) && (!beatmap.deleted_at? || hasDiscussion[beatmap.id]?) - .keyBy 'id' - .value() - - - checkNew: => - @nextTimeout ?= @checkNewTimeoutDefault - - Timeout.clear @timeouts.checkNew - @xhr.checkNew?.abort() - - @xhr.checkNew = $.get route('beatmapsets.discussion', beatmapset: @state.beatmapset.id), - format: 'json' - last_updated: @lastUpdate()?.unix() - .done (data, _textStatus, xhr) => - if xhr.status == 304 - @nextTimeout *= 2 - return - - @nextTimeout = @checkNewTimeoutDefault - - @update null, beatmapset: data.beatmapset - - .always => - @nextTimeout = Math.min @nextTimeout, @checkNewTimeoutMax - - @timeouts.checkNew = Timeout.set @nextTimeout, @checkNew - - - currentBeatmap: => - @beatmaps()[@state.currentBeatmapId] ? BeatmapHelper.findDefault(group: @groupedBeatmaps()) - - - currentDiscussions: => - return @cache.currentDiscussions if @cache.currentDiscussions? - - countsByBeatmap = {} - countsByPlaymode = {} - totalHype = 0 - unresolvedIssues = 0 - byMode = - timeline: [] - general: [] - generalAll: [] - reviews: [] - byFilter = - deleted: {} - hype: {} - mapperNotes: {} - mine: {} - pending: {} - praises: {} - resolved: {} - total: {} - timelineAllUsers = [] - - for own mode, _items of byMode - for own _filter, modes of byFilter - modes[mode] = {} - - for own _id, d of @discussions() - if !d.deleted_at? - totalHype++ if d.message_type == 'hype' - - if d.can_be_resolved && !d.resolved - beatmap = @beatmaps()[d.beatmap_id] - - if !d.beatmap_id? || (beatmap? && !beatmap.deleted_at?) - unresolvedIssues++ - - if beatmap? - countsByBeatmap[beatmap.id] ?= 0 - countsByBeatmap[beatmap.id]++ - - if !beatmap.deleted_at? - countsByPlaymode[beatmap.mode] ?= 0 - countsByPlaymode[beatmap.mode]++ - - if d.message_type == 'review' - mode = 'reviews' - else - if d.beatmap_id? - if d.beatmap_id == @currentBeatmap().id - if d.timestamp? - mode = 'timeline' - timelineAllUsers.push d - else - mode = 'general' - else - mode = null - else - mode = 'generalAll' - - # belongs to different beatmap, excluded - continue unless mode? - - # skip if filtering users - continue if @state.selectedUserId? && d.user_id != @state.selectedUserId - - filters = total: true - - if d.deleted_at? - filters.deleted = true - else if d.message_type == 'hype' - filters.hype = true - filters.praises = true - else if d.message_type == 'praise' - filters.praises = true - else if d.can_be_resolved - if d.resolved - filters.resolved = true - else - filters.pending = true - - if d.user_id == @state.currentUser.id - filters.mine = true - - if d.message_type == 'mapper_note' - filters.mapperNotes = true - - # the value should always be true - for own filter, _isSet of filters - byFilter[filter][mode][d.id] = d - - if filters.pending && d.parent_id? - parentDiscussion = @discussions()[d.parent_id] - - if parentDiscussion? && parentDiscussion.message_type == 'review' - byFilter.pending.reviews[parentDiscussion.id] = parentDiscussion - - byMode[mode].push d - - timeline = byMode.timeline - general = byMode.general - generalAll = byMode.generalAll - reviews = byMode.reviews - - @cache.currentDiscussions = {general, generalAll, timeline, reviews, timelineAllUsers, byFilter, countsByBeatmap, countsByPlaymode, totalHype, unresolvedIssues} - - - discussions: => - # skipped discussions - # - not privileged (deleted discussion) - # - deleted beatmap - @cache.discussions ?= _ @state.beatmapset.discussions - .filter (d) -> !_.isEmpty(d) - .keyBy 'id' - .value() - - - discussionStarters: => - _ @discussions() - .filter (discussion) -> discussion.message_type != 'hype' - .map 'user_id' - .uniq() - .map (user_id) => @users()[user_id] - .orderBy (user) -> user.username.toLocaleLowerCase() - .value() - - - groupedBeatmaps: (discussionSet) => - @cache.groupedBeatmaps ?= BeatmapHelper.group _.values(@beatmaps()) - - - jumpToDiscussionByHash: => - target = parseUrl(null, @state.beatmapset.discussions) - - @jumpTo(null, id: target.discussionId, postId: target.postId) if target.discussionId? - - - jumpTo: (_e, {id, postId}) => - discussion = @discussions()[id] - - return if !discussion? - - newState = stateFromDiscussion(discussion) - - newState.filter = - if @currentDiscussions().byFilter[@state.currentFilter][newState.mode][id]? - @state.currentFilter - else - defaultFilter - - if @state.selectedUserId? && @state.selectedUserId != discussion.user_id - newState.selectedUserId = null - - newState.callback = => - $.publish 'beatmapset-discussions:highlight', discussionId: discussion.id - - attribute = if postId? then "data-post-id='#{postId}'" else "data-id='#{id}'" - target = $(".js-beatmap-discussion-jump[#{attribute}]") - - return if target.length == 0 - - offsetTop = target.offset().top - @modeSwitcherRef.current.getBoundingClientRect().height - offsetTop -= @newDiscussionRef.current.getBoundingClientRect().height if @state.pinnedNewDiscussion - - $(window).stop().scrollTo core.stickyHeader.scrollOffset(offsetTop), 500 - - @update null, newState - - - jumpToClick: (e) => - url = e.currentTarget.getAttribute('href') - { discussionId, postId } = parseUrl(url, @state.beatmapset.discussions) - - return if !discussionId? - - e.preventDefault() - @jumpTo null, { id: discussionId, postId } - - - lastUpdate: => - lastUpdate = _.max [ - @state.beatmapset.last_updated - _.maxBy(@state.beatmapset.discussions, 'updated_at')?.updated_at - _.maxBy(@state.beatmapset.events, 'created_at')?.created_at - ] - - moment(lastUpdate) if lastUpdate? - - - markPostRead: (_e, {id}) => - return if @state.readPostIds.has(id) - - newSet = new Set(@state.readPostIds) - if Array.isArray(id) - newSet.add(i) for i in id - else - newSet.add(id) - - @setState readPostIds: newSet - - - queryFromLocation: (discussions = @state.beatmapsetDiscussion.beatmap_discussions) => - parseUrl(null, discussions) - - - saveStateToContainer: => - # This is only so it can be stored with JSON.stringify. - @state.readPostIdsArray = Array.from(@state.readPostIds) - @props.container.dataset.beatmapsetDiscussionState = JSON.stringify(@state) - - - setCurrentPlaymode: (e, {mode}) => - @update e, playmode: mode - - - setPinnedNewDiscussion: (pinned) => - @setState pinnedNewDiscussion: pinned - - - toggleShowDeleted: => - @setState showDeleted: !@state.showDeleted - - - update: (_e, options) => - { - callback - mode - modeIf - beatmapId - playmode - beatmapset - watching - filter - selectedUserId - } = options - newState = {} - - if beatmapset? - newState.beatmapset = beatmapset - - if watching? - newState.beatmapset ?= _.assign {}, @state.beatmapset - newState.beatmapset.current_user_attributes.is_watching = watching - - if playmode? - beatmap = BeatmapHelper.findDefault items: @groupedBeatmaps().get(playmode) - beatmapId = beatmap?.id - - if beatmapId? && beatmapId != @currentBeatmap().id - newState.currentBeatmapId = beatmapId - - if filter? - if @state.currentMode == 'events' - newState.currentMode = @lastMode ? defaultMode(newState.currentBeatmapId) - - if filter != @state.currentFilter - newState.currentFilter = filter - - if mode? && mode != @state.currentMode - if !modeIf? || modeIf == @state.currentMode - newState.currentMode = mode - - # switching to events: - # - record last filter, to be restored when setMode is called - # - record last mode, to be restored when setFilter is called - # - set filter to total - if mode == 'events' - @lastMode = @state.currentMode - @lastFilter = @state.currentFilter - newState.currentFilter = 'total' - # switching from events: - # - restore whatever last filter set or default to total - else if @state.currentMode == 'events' - newState.currentFilter = @lastFilter ? 'total' - - newState.selectedUserId = selectedUserId if selectedUserId != undefined # need to setState if null - - @setState newState, callback - - - urlFromState: => - makeUrl - beatmap: @currentBeatmap() - mode: @state.currentMode - filter: @state.currentFilter - user: @state.selectedUserId - - - users: => - if !@cache.users? - @cache.users = _.keyBy @state.beatmapset.related_users, 'id' - @cache.users[null] = @cache.users[undefined] = deletedUserJson - - @cache.users - - - ujsDiscussionUpdate: (_e, data) => - # to allow ajax:complete to be run - Timeout.set 0, => @update(null, beatmapset: data) +// 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 { DiscussionsContext } from 'beatmap-discussions/discussions-context'; +import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context'; +import NewReview from 'beatmap-discussions/new-review'; +import { ReviewEditorConfigContext } from 'beatmap-discussions/review-editor-config-context'; +import BackToTop from 'components/back-to-top'; +import { route } from 'laroute'; +import { deletedUser } from 'models/user'; +import core from 'osu-core-singleton'; +import * as React from 'react'; +import * as BeatmapHelper from 'utils/beatmap-helper'; +import { defaultFilter, defaultMode, makeUrl, parseUrl, stateFromDiscussion } from 'utils/beatmapset-discussion-helper'; +import { nextVal } from 'utils/seq'; +import { currentUrl } from 'utils/turbolinks'; +import { Discussions } from './discussions'; +import { Events } from './events'; +import { Header } from './header'; +import { ModeSwitcher } from './mode-switcher'; +import { NewDiscussion } from './new-discussion'; +import { action, computed, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; +import DiscussionMode, { DiscussionPage, discussionPages } from './discussion-mode'; +import { Filter } from './current-discussions'; +import { isEmpty, keyBy, maxBy } from 'lodash'; +import moment from 'moment'; +import { findDefault } from 'utils/beatmap-helper'; +import { group } from 'utils/beatmap-helper'; +import GameMode from 'interfaces/game-mode'; +import { switchNever } from 'utils/switch-never'; + +const checkNewTimeoutDefault = 10000; +const checkNewTimeoutMax = 60000; + +interface InitialData { + beatmapset: BeatmapsetWithDiscussionsJson; + reviews_config: { + max_blocks: number; + }; +} + +interface Props { + container: HTMLElement; + initial: InitialData; +} + +interface State { + beatmapset: BeatmapsetWithDiscussionsJson; + currentMode: DiscussionPage; + currentFilter: Filter | null; + currentBeatmapId: number | null; + focusNewDiscussion: boolean; + pinnedNewDiscussion: boolean; + readPostIds: Set; + readPostIdsArray: number[]; + selectedUserId: number | null; + showDeleted: boolean; +} + +interface UpdateOptions { + callback: () => void; + mode: DiscussionPage; + modeIf: DiscussionPage; + beatmapId: number; + playmode: GameMode; + beatmapset: BeatmapsetWithDiscussionsJson; + watching: boolean; + filter: Filter; + selectedUserId: number; +} + +type DiscussionsAlias = BeatmapsetWithDiscussionsJson['discussions']; + +export function filterDiscusionsByMode(discussions: DiscussionsAlias, mode: DiscussionMode, beatmapId: number) { + console.log(mode); + switch (mode) { + case 'general': + return discussions.filter((discussion) => discussion.beatmap_id === beatmapId); + case 'generalAll': + return discussions.filter((discussion) => discussion.beatmap_id == null); + case 'reviews': + return discussions.filter((discussion) => discussion.message_type === 'review'); + case 'timeline': + return discussions.filter((discussion) => discussion.beatmap_id === beatmapId && discussion.timestamp != null); + default: + switchNever(mode); + throw new Error('missing valid mode'); + } +} + +export function filterDiscussionsByFilter(discussions: DiscussionsAlias, filter: Filter) { + console.log(filter); + switch (filter) { + case 'deleted': + return discussions.filter((discussion) => discussion.deleted_at != null); + case 'hype': + return discussions.filter((discussion) => discussion.message_type === 'hype'); + case 'mapperNotes': + return discussions.filter((discussion) => discussion.message_type === 'mapper_note'); + case 'mine': { + const userId = core.currentUserOrFail.id; + return discussions.filter((discussion) => discussion.user_id === userId); + } + case 'pending': + // TODO: + // pending reviews + // if (discussion.parent_id != null) { + // const parentDiscussion = discussions[discussion.parent_id]; + // if (parentDiscussion != null && parentDiscussion.message_type == 'review') return true; + // } + + return discussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved); + case 'praises': + return discussions.filter((discussion) => discussion.message_type === 'praise' || discussion.message_type === 'hype'); + case 'resolved': + return discussions.filter((discussion) => discussion.can_be_resolved && discussion.resolved); + case 'total': + return discussions; + default: + switchNever(filter); + throw new Error('missing valid filter'); + } +} + + +@observer +export default class Main extends React.Component { + @observable private beatmapset = this.props.initial.beatmapset; + + @observable private currentMode: DiscussionPage = 'general'; + @observable private currentFilter: Filter | null = null; + @observable private currentBeatmapId: number | null = null; + @observable private selectedUserId: number | null = null; + + // FIXME: update url handler to recognize this instead + private focusNewDiscussion = currentUrl().hash === '#new'; + + private reviewsConfig = this.props.initial.reviews_config; + + private jumpToDiscussion = false; + private nextTimeout; + + private readonly eventId = `beatmap-discussions-${nextVal()}`; + private readonly modeSwitcherRef = React.createRef() + private readonly newDiscussionRef = React.createRef() + @observable private pinnedNewDiscussion = false; + + @observable private readPostIds = new Set(); + @observable private showDeleted = true; + + private readonly disposers = new Set<((() => void) | undefined)>(); + + private xhrCheckNew?: JQuery.jqXHR; + private readonly timeouts: Record = {}; + + @computed + private get beatmaps() { + const hasDiscussion = new Set(); + for (const discussion of this.state.beatmapset.discussions) { + if (discussion?.beatmap_id != null) { + hasDiscussion.add(discussion.beatmap_id); + } + } + + return keyBy( + this.state.beatmapset.beatmaps.filter((beatmap) => !isEmpty(beatmap) && (beatmap.deleted_at == null || hasDiscussion.has(beatmap.id))), + 'id', + ); + } + + @computed + private get currentBeatmap() { + return this.beatmaps[this.state.currentBeatmapId] ?? findDefault({ group: this.groupedBeatmaps }); + } + + @computed + private get discussions() { + // skipped discussions + // - not privileged (deleted discussion) + // - deleted beatmap + return keyBy(this.state.beatmapset.discussions.filter((discussion) => !isEmpty(discussion)), 'id'); + } + + @computed + get nonNullDiscussions() { + console.log('nonNullDiscussions'); + return Object.values(this.discussions).filter((discussion) => discussion != null); + } + + @computed + private get presentDiscussions() { + return Object.values(this.discussions).filter((discussion) => discussion.deleted_at == null); + } + + @computed + get totalHype() { + return this.presentDiscussions + .reduce((sum, discussion) => discussion.message_type === 'hype' + ? sum++ + : sum, + 0); + } + + @computed + get unresolvedIssues() { + return this.presentDiscussions + .reduce((sum, discussion) => { + if (discussion.can_be_resolved && !discussion.resolved) { + if (discussion.beatmap_id == null) return sum++; + + const beatmap = this.beatmaps[discussion.beatmap_id]; + if (beatmap != null && beatmap.deleted_at == null) return sum++; + } + + return sum; + }, 0); + } + + @computed + private get unresolvedDiscussions() { + return this.presentDiscussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved) + } + + @computed + private get discussionStarters() { + const userIds = new Set(Object.values(this.discussions) + .filter((discussion) => discussion.message_type !== 'hype') + .map((discussion) => discussion.user_id)); + + // TODO: sort user.username.toLocaleLowerCase() + return [...userIds.values()].map((userId) => this.users[userId]).sort(); + } + + private get groupedBeatmaps() { + return group(Object.values(this.beatmaps)); + } + + @computed + private get lastUpdate() { + const maxLastUpdate = Math.max( + +this.state.beatmapset.last_updated, + +(maxBy(this.state.beatmapset.discussions, 'updated_at')?.updated_at ?? 0), + +(maxBy(this.state.beatmapset.events, 'created_at')?.created_at ?? 0), + ); + + return maxLastUpdate != null ? moment(maxLastUpdate).unix() : null; + } + + private get urlFromState() { + return makeUrl({ + beatmap: this.currentBeatmap ?? undefined, + filter: this.state.currentFilter ?? undefined, + mode: this.state.currentMode, + user: this.state.selectedUserId ?? undefined, + }); + } + + @computed + private get users() { + const value = keyBy(this.state.beatmapset.related_users, 'id'); + // eslint-disable-next-line id-blacklist + value.null = value.undefined = deletedUser.toJson(); + + return value; + } + + constructor(props: Props) { + super(props); + + this.state = JSON.parse(props.container.dataset.beatmapsetDiscussionState ?? null) as (BeatmapsetWithDiscussionsJson | null); // TODO: probably wrong + if (this.state != null) { + this.state.readPostIds = new Set(this.state.readPostIdsArray); + this.pinnedNewDiscussion = this.state.pinnedNewDiscussion; + } else { + this.jumpToDiscussion = true; + for (const discussion of props.initial.beatmapset.discussions) { + if (discussion.posts != null) { + for (const post of discussion.posts) { + this.state.readPostIds.add(post.id); + } + } + } + } + + // Current url takes priority over saved state. + const query = parseUrl(null, props.initial.beatmapset.discussions); + if (query != null) { + // TODO: maybe die instead? + this.currentMode = query.mode; + this.currentFilter = query.filter; + this.currentBeatmapId = query.beatmapId ?? null; // TODO check if it's supposed to assign on null or skip and use existing value + this.selectedUserId = query.user ?? null + } + + makeObservable(this); + } + + componentDidMount() { + $.subscribe(`playmode:set.${this.eventId}`, this.setCurrentPlaymode); + + $.subscribe(`beatmapsetDiscussions:update.${this.eventId}`, this.update); + $.subscribe(`beatmapDiscussion:jump.${this.eventId}`, this.jumpTo); + $.subscribe(`beatmapDiscussionPost:markRead.${this.eventId}`, this.markPostRead); + $.subscribe(`beatmapDiscussionPost:toggleShowDeleted.${this.eventId}`, this.toggleShowDeleted); + + $(document).on(`ajax:success.${this.eventId}`, '.js-beatmapset-discussion-update', this.ujsDiscussionUpdate); + $(document).on(`click.${this.eventId}`, '.js-beatmap-discussion--jump', this.jumpToClick); + $(document).on(`turbolinks:before-cache.${this.eventId}`, this.saveStateToContainer); + + if (this.jumpToDiscussion) { + this.disposers.add(core.reactTurbolinks.runAfterPageLoad(this.jumpToDiscussionByHash)); + } + + this.timeouts.checkNew = window.setTimeout(this.checkNew, checkNewTimeoutDefault); + } + + + componentDidUpdate(_prevProps, prevState) { + if (prevState.currentBeatmapId == this.state.currentBeatmapId + && prevState.currentFilter == this.state.currentFilter + && prevState.currentMode == this.state.currentMode + && prevState.selectedUserId == this.state.selectedUserId + && prevState.showDeleted == this.state.showDeleted) { + return; + } + + Turbolinks.controller.advanceHistory(this.urlFromState()); + } + + componentWillUnmount() { + $.unsubscribe(`.${this.eventId}`); + $(document).off(`.${this.eventId}`); + + Object.values(this.timeouts).forEach(window.clearTimeout); + + this.xhrCheckNew?.abort(); + this.disposers.forEach((disposer) => disposer?.()); + } + + render() { + return ( + <> +
+ + {this.state.currentMode === 'events' ? ( + + ) : ( + + + + {this.state.currentMode === 'reviews' ? ( + + ) : ( + + )} + + + + + )} + + + ); + } + + private readonly checkNew = () => { + this.nextTimeout ??= checkNewTimeoutDefault; + + window.clearTimeout(this.timeouts.checkNew); + this.xhrCheckNew?.abort(); + + this.xhrCheckNew = $.get(route('beatmapsets.discussion', { beatmapset: this.state.beatmapset.id }), { + format: 'json', + last_updated: this.lastUpdate, + }); + + this.xhrCheckNew.done((data, _textStatus, xhr) => { + if (xhr.status === 304) { + this.nextTimeout *= 2; + return; + } + + this.nextTimeout = checkNewTimeoutDefault; + this.update(null, { beatmapset: data.beatmapset }); + }).always(() => { + this.nextTimeout = Math.min(this.nextTimeout, checkNewTimeoutMax); + + this.timeouts.checkNew = window.setTimeout(this.checkNew, this.nextTimeout); + }); + }; + + private discussionsByBeatmap(beatmapId: number) { + return computed(() => this.presentDiscussions.filter((discussion) => (discussion.beatmap_id == null || discussion.beatmap_id === beatmapId))); + } + + private discussionsByFilter(filter: Filter, mode: DiscussionMode, beatmapId: number) { + return computed(() => filterDiscussionsByFilter(this.discussionsByMode(mode, beatmapId), filter)).get(); + } + + private discussionsByMode(mode: DiscussionMode, beatmapId: number) { + return computed(() => filterDiscusionsByMode(this.nonNullDiscussions, mode, beatmapId)).get(); + } + + private readonly jumpTo = (_e: unknown, { id, postId }: { id: number, postId?: number }) => { + const discussion = this.discussions[id]; + + if (discussion == null) return; + + const newState = stateFromDiscussion(discussion) + + newState.filter = this.currentDiscussions().byFilter[this.state.currentFilter][newState.mode][id] != null + ? this.state.currentFilter + : defaultFilter + + if (this.state.selectedUserId != null && this.state.selectedUserId !== discussion.user_id) { + newState.selectedUserId = null; // unsets userid + } + + newState.callback = () => { + $.publish('beatmapset-discussions:highlight', { discussionId: discussion.id }); + + const attribute = postId != null ? `data-post-id='${postId}'` : `data-id='${id}'`; + const target = $(`.js-beatmap-discussion-jump[${attribute}]`); + + if (target.length === 0) return; + + let offsetTop = target.offset().top - this.modeSwitcherRef.current.getBoundingClientRect().height; + if (this.state.pinnedNewDiscussion) { + offsetTop -= this.newDiscussionRef.current.getBoundingClientRect().height + } + + $(window).stop().scrollTo(core.stickyHeader.scrollOffset(offsetTop), 500); + } + + this.update(null, newState); + }; + + private readonly jumpToClick = (e: React.SyntheticEvent) => { + const url = e.currentTarget.getAttribute('href'); + const parsedUrl = parseUrl(url, this.state.beatmapset.discussions); + + if (parsedUrl == null) return; + + const { discussionId, postId } = parsedUrl; + + if (discussionId == null) return; + + e.preventDefault(); + this.jumpTo(null, { id: discussionId, postId }); + }; + + private readonly jumpToDiscussionByHash = () => { + const target = parseUrl(null, this.state.beatmapset.discussions) + + if (target.discussionId != null) { + this.jumpTo(null, { id: target.discussionId, postId: target.postId }); + } + }; + + @action + private readonly markPostRead = (_event: unknown, { id }: { id: number | number[] }) => { + if (Array.isArray(id)) { + id.forEach(this.state.readPostIds.add); + } else { + this.state.readPostIds.add(id); + } + + // setState + }; + + private readonly saveStateToContainer = () => { + // This is only so it can be stored with JSON.stringify. + this.state.readPostIdsArray = Array.from(this.state.readPostIds) + this.props.container.dataset.beatmapsetDiscussionState = JSON.stringify(this.state) + }; + + private readonly setCurrentPlaymode = (e, { mode }) => { + this.update(e, { playmode: mode }); + }; + + @action + private readonly setPinnedNewDiscussion = (pinned: boolean) => { + this.pinnedNewDiscussion = pinned + }; + + @action + private readonly toggleShowDeleted = () => { + this.showDeleted = !this.showDeleted; + }; + + @action + private readonly update = (_e: unknown, options: Partial) => { + const { + beatmapId, + beatmapset, + callback, + filter, + mode, + modeIf, + playmode, + selectedUserId, + watching, + } = options; + + const newState: Partial = {} + + if (beatmapset != null) { + newState.beatmapset = beatmapset; + } + + if (watching != null) { + newState.beatmapset ??= Object.assign({}, this.state.beatmapset); + newState.beatmapset.current_user_attributes.is_watching = watching; + } + + if (playmode != null) { + const beatmap = BeatmapHelper.findDefault({ items: this.groupedBeatmaps.get(playmode) }); + beatmapId = beatmap?.id; + } + + if (beatmapId != null && beatmapId != this.currentBeatmap.id) { + newState.currentBeatmapId = beatmapId; + } + + if (filter != null) { + if (this.state.currentMode === 'events') { + newState.currentMode = this.lastMode ?? defaultMode(newState.currentBeatmapId); + } + + if (filter !== this.state.currentFilter) { + newState.currentFilter = filter; + } + } + + if (mode != null && mode !== this.state.currentMode) { + if (modeIf == null || modeIf === this.state.currentMode) { + newState.currentMode = mode; + } + + // switching to events: + // - record last filter, to be restored when setMode is called + // - record last mode, to be restored when setFilter is called + // - set filter to total + if (mode === 'events') { + this.lastMode = this.state.currentMode; + this.lastFilter = this.state.currentFilter; + newState.currentFilter = 'total'; + } else if (this.state.currentMode === 'events') { + // switching from events: + // - restore whatever last filter set or default to total + newState.currentFilter = this.lastFilter ?? 'total' + } + } + + // need to setState if null + if (selectedUserId !== undefined) { + newState.selectedUserId = selectedUserId + } + + this.setState(newState, callback); + }; + + private readonly ujsDiscussionUpdate = (_e: unknown, beatmapset: BeatmapsetWithDiscussionsJson) => { + // to allow ajax:complete to be run + window.setTimeout(() => this.update(null, { beatmapset }, 0)); + }; +} From a67ca1c7ab2c4c9dd60effce48661a634c1dd6ad Mon Sep 17 00:00:00 2001 From: bakaneko Date: Tue, 13 Jun 2023 21:45:06 +0900 Subject: [PATCH 003/203] convert entrypoint to typescript (rename) --- .../{beatmap-discussions.coffee => beatmap-discussions.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename resources/js/entrypoints/{beatmap-discussions.coffee => beatmap-discussions.tsx} (100%) diff --git a/resources/js/entrypoints/beatmap-discussions.coffee b/resources/js/entrypoints/beatmap-discussions.tsx similarity index 100% rename from resources/js/entrypoints/beatmap-discussions.coffee rename to resources/js/entrypoints/beatmap-discussions.tsx From d8e8696d97b4ea212b1cc9587ebe82ecf60147b8 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Tue, 13 Jun 2023 21:52:42 +0900 Subject: [PATCH 004/203] convert entrypoint to typescript --- .../js/entrypoints/beatmap-discussions.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/resources/js/entrypoints/beatmap-discussions.tsx b/resources/js/entrypoints/beatmap-discussions.tsx index fddd6fa3527..dc5d53fe7ec 100644 --- a/resources/js/entrypoints/beatmap-discussions.tsx +++ b/resources/js/entrypoints/beatmap-discussions.tsx @@ -1,12 +1,14 @@ -# 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. +// 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 core from 'osu-core-singleton' -import { createElement } from 'react' -import { parseJson } from 'utils/json' -import { Main } from 'beatmap-discussions/main' +import Main from 'beatmap-discussions/main'; +import core from 'osu-core-singleton'; +import React from 'react'; +import { parseJson } from 'utils/json'; -core.reactTurbolinks.register 'beatmap-discussions', (container) -> - createElement Main, - initial: parseJson 'json-beatmapset-discussion' - container: container +core.reactTurbolinks.register('beatmap-discussions', (container: HTMLElement) => ( +
+)); From be8af65573ff8af2d7c7c49106eb4a2b3400342e Mon Sep 17 00:00:00 2001 From: bakaneko Date: Tue, 6 Jun 2023 19:51:48 +0900 Subject: [PATCH 005/203] move state to DiscussionsState --- .../beatmap-discussions/discussions-state.ts | 208 ++++++++- resources/js/beatmap-discussions/main.tsx | 412 +++++------------- 2 files changed, 318 insertions(+), 302 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 26f3420ea58..4319de48637 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -1,14 +1,218 @@ // 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 { makeObservable, observable } from 'mobx'; +import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; +import { computed, makeObservable, observable } from 'mobx'; +import core from 'osu-core-singleton'; +import { Filter } from './current-discussions'; +import DiscussionMode, { DiscussionPage } from './discussion-mode'; +import { isEmpty, keyBy, maxBy } from 'lodash'; +import { findDefault, group } from 'utils/beatmap-helper'; +import moment from 'moment'; +import { parseUrl } from 'utils/beatmapset-discussion-helper'; +import { switchNever } from 'utils/switch-never'; +import { deletedUser } from 'models/user'; +import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; + +type DiscussionsAlias = BeatmapsetWithDiscussionsJson['discussions']; + + +export function filterDiscusionsByMode(discussions: DiscussionsAlias, mode: DiscussionMode, beatmapId: number) { + console.log(mode); + switch (mode) { + case 'general': + return discussions.filter((discussion) => discussion.beatmap_id === beatmapId); + case 'generalAll': + return discussions.filter((discussion) => discussion.beatmap_id == null); + case 'reviews': + return discussions.filter((discussion) => discussion.message_type === 'review'); + case 'timeline': + return discussions.filter((discussion) => discussion.beatmap_id === beatmapId && discussion.timestamp != null); + default: + switchNever(mode); + throw new Error('missing valid mode'); + } +} + +export function filterDiscussionsByFilter(discussions: DiscussionsAlias, filter: Filter) { + console.log(filter); + switch (filter) { + case 'deleted': + return discussions.filter((discussion) => discussion.deleted_at != null); + case 'hype': + return discussions.filter((discussion) => discussion.message_type === 'hype'); + case 'mapperNotes': + return discussions.filter((discussion) => discussion.message_type === 'mapper_note'); + case 'mine': { + const userId = core.currentUserOrFail.id; + return discussions.filter((discussion) => discussion.user_id === userId); + } + case 'pending': + // TODO: + // pending reviews + // if (discussion.parent_id != null) { + // const parentDiscussion = discussions[discussion.parent_id]; + // if (parentDiscussion != null && parentDiscussion.message_type == 'review') return true; + // } + return discussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved); + case 'praises': + return discussions.filter((discussion) => discussion.message_type === 'praise' || discussion.message_type === 'hype'); + case 'resolved': + return discussions.filter((discussion) => discussion.can_be_resolved && discussion.resolved); + case 'total': + return discussions; + default: + switchNever(filter); + throw new Error('missing valid filter'); + } +} export default class DiscussionsState { + @observable currentBeatmapId: number; + @observable currentFilter: Filter = 'total'; + @observable currentMode: DiscussionPage = 'general'; @observable discussionCollapsed = new Map(); @observable discussionDefaultCollapsed = false; @observable highlightedDiscussionId: number | null = null; - constructor() { + @observable pinnedNewDiscussion = false; + + @observable readPostIds = new Set(); + @observable selectedUserId: number | null = null; + @observable showDeleted = true; + + private jumpToDiscussion = false; + + @computed + get beatmaps() { + const hasDiscussion = new Set(); + for (const discussion of this.beatmapset.discussions) { + if (discussion?.beatmap_id != null) { + hasDiscussion.add(discussion.beatmap_id); + } + } + + return keyBy( + this.beatmapset.beatmaps.filter((beatmap) => !isEmpty(beatmap) && (beatmap.deleted_at == null || hasDiscussion.has(beatmap.id))), + 'id', + ); + } + + @computed + get currentBeatmap() { + return this.beatmaps[this.currentBeatmapId]; + } + + @computed + get discussions() { + // skipped discussions + // - not privileged (deleted discussion) + // - deleted beatmap + return keyBy(this.beatmapset.discussions.filter((discussion) => !isEmpty(discussion)), 'id') as Partial>; + } + + @computed + get discussionStarters() { + const userIds = new Set(Object.values(this.nonNullDiscussions) + .filter((discussion) => discussion.message_type !== 'hype') + .map((discussion) => discussion.user_id)); + + // TODO: sort user.username.toLocaleLowerCase() + return [...userIds.values()].map((userId) => this.users[userId]).sort(); + } + + get groupedBeatmaps() { + return group(Object.values(this.beatmaps)); + } + + @computed + get lastUpdate() { + const maxLastUpdate = Math.max( + +this.beatmapset.last_updated, + +(maxBy(this.beatmapset.discussions, 'updated_at')?.updated_at ?? 0), + +(maxBy(this.beatmapset.events, 'created_at')?.created_at ?? 0), + ); + + return maxLastUpdate != null ? moment(maxLastUpdate).unix() : null; + } + + @computed + get users() { + const value = keyBy(this.beatmapset.related_users, 'id'); + // eslint-disable-next-line id-blacklist + value.null = value.undefined = deletedUser.toJson(); + + return value; + } + + + @computed + get nonNullDiscussions() { + console.log('nonNullDiscussions'); + return Object.values(this.discussions).filter((discussion) => discussion != null) as BeatmapsetDiscussionJson[]; + } + + @computed + get presentDiscussions() { + return this.nonNullDiscussions.filter((discussion) => discussion.deleted_at == null); + } + + @computed + get totalHype() { + return this.presentDiscussions + .reduce((sum, discussion) => discussion.message_type === 'hype' + ? sum++ + : sum, + 0); + } + + @computed + get unresolvedIssues() { + return this.presentDiscussions + .reduce((sum, discussion) => { + if (discussion.can_be_resolved && !discussion.resolved) { + if (discussion.beatmap_id == null) return sum++; + + const beatmap = this.beatmaps[discussion.beatmap_id]; + if (beatmap != null && beatmap.deleted_at == null) return sum++; + } + + return sum; + }, 0); + } + + @computed + get unresolvedDiscussions() { + return this.presentDiscussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved); + } + + constructor(public beatmapset: BeatmapsetWithDiscussionsJson) { + this.currentBeatmapId = (findDefault({ group: this.groupedBeatmaps }) ?? this.beatmaps[0]).id; + + // Current url takes priority over saved state. + const query = parseUrl(null, beatmapset.discussions); + if (query != null) { + // TODO: maybe die instead? + this.currentMode = query.mode; + this.currentFilter = query.filter; + if (query.beatmapId != null) { + this.currentBeatmapId = query.beatmapId; + } + this.selectedUserId = query.user ?? null; + } + makeObservable(this); } + + discussionsByBeatmap(beatmapId: number) { + return computed(() => this.presentDiscussions.filter((discussion) => (discussion.beatmap_id == null || discussion.beatmap_id === beatmapId))); + } + + discussionsByFilter(filter: Filter, mode: DiscussionMode, beatmapId: number) { + return computed(() => filterDiscussionsByFilter(this.discussionsByMode(mode, beatmapId), filter)).get(); + } + + discussionsByMode(mode: DiscussionMode, beatmapId: number) { + return computed(() => filterDiscusionsByMode(this.nonNullDiscussions, mode, beatmapId)).get(); + } } diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index 8a52891979b..664fa42662f 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -30,6 +30,7 @@ import { findDefault } from 'utils/beatmap-helper'; import { group } from 'utils/beatmap-helper'; import GameMode from 'interfaces/game-mode'; import { switchNever } from 'utils/switch-never'; +import DiscussionsState from './discussions-state'; const checkNewTimeoutDefault = 10000; const checkNewTimeoutMax = 60000; @@ -71,229 +72,43 @@ interface UpdateOptions { selectedUserId: number; } -type DiscussionsAlias = BeatmapsetWithDiscussionsJson['discussions']; - -export function filterDiscusionsByMode(discussions: DiscussionsAlias, mode: DiscussionMode, beatmapId: number) { - console.log(mode); - switch (mode) { - case 'general': - return discussions.filter((discussion) => discussion.beatmap_id === beatmapId); - case 'generalAll': - return discussions.filter((discussion) => discussion.beatmap_id == null); - case 'reviews': - return discussions.filter((discussion) => discussion.message_type === 'review'); - case 'timeline': - return discussions.filter((discussion) => discussion.beatmap_id === beatmapId && discussion.timestamp != null); - default: - switchNever(mode); - throw new Error('missing valid mode'); - } -} - -export function filterDiscussionsByFilter(discussions: DiscussionsAlias, filter: Filter) { - console.log(filter); - switch (filter) { - case 'deleted': - return discussions.filter((discussion) => discussion.deleted_at != null); - case 'hype': - return discussions.filter((discussion) => discussion.message_type === 'hype'); - case 'mapperNotes': - return discussions.filter((discussion) => discussion.message_type === 'mapper_note'); - case 'mine': { - const userId = core.currentUserOrFail.id; - return discussions.filter((discussion) => discussion.user_id === userId); - } - case 'pending': - // TODO: - // pending reviews - // if (discussion.parent_id != null) { - // const parentDiscussion = discussions[discussion.parent_id]; - // if (parentDiscussion != null && parentDiscussion.message_type == 'review') return true; - // } - - return discussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved); - case 'praises': - return discussions.filter((discussion) => discussion.message_type === 'praise' || discussion.message_type === 'hype'); - case 'resolved': - return discussions.filter((discussion) => discussion.can_be_resolved && discussion.resolved); - case 'total': - return discussions; - default: - switchNever(filter); - throw new Error('missing valid filter'); - } -} - - @observer export default class Main extends React.Component { - @observable private beatmapset = this.props.initial.beatmapset; - - @observable private currentMode: DiscussionPage = 'general'; - @observable private currentFilter: Filter | null = null; - @observable private currentBeatmapId: number | null = null; - @observable private selectedUserId: number | null = null; - + private readonly discussionsState: DiscussionsState; + private readonly disposers = new Set<((() => void) | undefined)>(); + private readonly eventId = `beatmap-discussions-${nextVal()}`; // FIXME: update url handler to recognize this instead private focusNewDiscussion = currentUrl().hash === '#new'; - + private readonly modeSwitcherRef = React.createRef(); + private readonly newDiscussionRef = React.createRef(); + private nextTimeout = checkNewTimeoutDefault; private reviewsConfig = this.props.initial.reviews_config; - - private jumpToDiscussion = false; - private nextTimeout; - - private readonly eventId = `beatmap-discussions-${nextVal()}`; - private readonly modeSwitcherRef = React.createRef() - private readonly newDiscussionRef = React.createRef() - @observable private pinnedNewDiscussion = false; - - @observable private readPostIds = new Set(); - @observable private showDeleted = true; - - private readonly disposers = new Set<((() => void) | undefined)>(); - - private xhrCheckNew?: JQuery.jqXHR; private readonly timeouts: Record = {}; + private xhrCheckNew?: JQuery.jqXHR; - @computed - private get beatmaps() { - const hasDiscussion = new Set(); - for (const discussion of this.state.beatmapset.discussions) { - if (discussion?.beatmap_id != null) { - hasDiscussion.add(discussion.beatmap_id); - } - } - - return keyBy( - this.state.beatmapset.beatmaps.filter((beatmap) => !isEmpty(beatmap) && (beatmap.deleted_at == null || hasDiscussion.has(beatmap.id))), - 'id', - ); - } - - @computed - private get currentBeatmap() { - return this.beatmaps[this.state.currentBeatmapId] ?? findDefault({ group: this.groupedBeatmaps }); - } - - @computed - private get discussions() { - // skipped discussions - // - not privileged (deleted discussion) - // - deleted beatmap - return keyBy(this.state.beatmapset.discussions.filter((discussion) => !isEmpty(discussion)), 'id'); - } - - @computed - get nonNullDiscussions() { - console.log('nonNullDiscussions'); - return Object.values(this.discussions).filter((discussion) => discussion != null); - } - - @computed - private get presentDiscussions() { - return Object.values(this.discussions).filter((discussion) => discussion.deleted_at == null); - } - - @computed - get totalHype() { - return this.presentDiscussions - .reduce((sum, discussion) => discussion.message_type === 'hype' - ? sum++ - : sum, - 0); - } - - @computed - get unresolvedIssues() { - return this.presentDiscussions - .reduce((sum, discussion) => { - if (discussion.can_be_resolved && !discussion.resolved) { - if (discussion.beatmap_id == null) return sum++; - - const beatmap = this.beatmaps[discussion.beatmap_id]; - if (beatmap != null && beatmap.deleted_at == null) return sum++; - } - - return sum; - }, 0); - } - - @computed - private get unresolvedDiscussions() { - return this.presentDiscussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved) - } - - @computed - private get discussionStarters() { - const userIds = new Set(Object.values(this.discussions) - .filter((discussion) => discussion.message_type !== 'hype') - .map((discussion) => discussion.user_id)); - - // TODO: sort user.username.toLocaleLowerCase() - return [...userIds.values()].map((userId) => this.users[userId]).sort(); - } - - private get groupedBeatmaps() { - return group(Object.values(this.beatmaps)); - } - - @computed - private get lastUpdate() { - const maxLastUpdate = Math.max( - +this.state.beatmapset.last_updated, - +(maxBy(this.state.beatmapset.discussions, 'updated_at')?.updated_at ?? 0), - +(maxBy(this.state.beatmapset.events, 'created_at')?.created_at ?? 0), - ); - - return maxLastUpdate != null ? moment(maxLastUpdate).unix() : null; - } - - private get urlFromState() { - return makeUrl({ - beatmap: this.currentBeatmap ?? undefined, - filter: this.state.currentFilter ?? undefined, - mode: this.state.currentMode, - user: this.state.selectedUserId ?? undefined, - }); - } + constructor(props: Props) { + super(props); - @computed - private get users() { - const value = keyBy(this.state.beatmapset.related_users, 'id'); - // eslint-disable-next-line id-blacklist - value.null = value.undefined = deletedUser.toJson(); + const existingState = JSON.parse(props.container.dataset.beatmapsetDiscussionState ?? null) as (BeatmapsetWithDiscussionsJson | null); // TODO: probably wrong - return value; - } - constructor(props: Props) { - super(props); + this.discussionsState = new DiscussionsState(props.initial.beatmapset); - this.state = JSON.parse(props.container.dataset.beatmapsetDiscussionState ?? null) as (BeatmapsetWithDiscussionsJson | null); // TODO: probably wrong - if (this.state != null) { - this.state.readPostIds = new Set(this.state.readPostIdsArray); - this.pinnedNewDiscussion = this.state.pinnedNewDiscussion; + this.discussionsState = JSON.parse(props.container.dataset.beatmapsetDiscussionState ?? null) as (BeatmapsetWithDiscussionsJson | null); // TODO: probably wrong + if (this.discussionsState != null) { + this.discussionsState.readPostIds = new Set(this.discussionsState.readPostIdsArray); + this.pinnedNewDiscussion = this.discussionsState.pinnedNewDiscussion; } else { this.jumpToDiscussion = true; for (const discussion of props.initial.beatmapset.discussions) { if (discussion.posts != null) { for (const post of discussion.posts) { - this.state.readPostIds.add(post.id); + this.discussionsState.readPostIds.add(post.id); } } } } - // Current url takes priority over saved state. - const query = parseUrl(null, props.initial.beatmapset.discussions); - if (query != null) { - // TODO: maybe die instead? - this.currentMode = query.mode; - this.currentFilter = query.filter; - this.currentBeatmapId = query.beatmapId ?? null; // TODO check if it's supposed to assign on null or skip and use existing value - this.selectedUserId = query.user ?? null - } - makeObservable(this); } @@ -318,17 +133,27 @@ export default class Main extends React.Component { componentDidUpdate(_prevProps, prevState) { - if (prevState.currentBeatmapId == this.state.currentBeatmapId - && prevState.currentFilter == this.state.currentFilter - && prevState.currentMode == this.state.currentMode - && prevState.selectedUserId == this.state.selectedUserId - && prevState.showDeleted == this.state.showDeleted) { - return; - } - - Turbolinks.controller.advanceHistory(this.urlFromState()); + // TODO: autorun + // if (prevState.currentBeatmapId == this.discussionsState.currentBeatmapId + // && prevState.currentFilter == this.discussionsState.currentFilter + // && prevState.currentMode == this.discussionsState.currentMode + // && prevState.selectedUserId == this.discussionsState.selectedUserId + // && prevState.showDeleted == this.discussionsState.showDeleted) { + // return; + // } + + // Turbolinks.controller.advanceHistory(this.urlFromState()); } + // private get urlFromState() { + // return makeUrl({ + // beatmap: this.currentBeatmap ?? undefined, + // filter: this.currentFilter ?? undefined, + // mode: this.currentMode, + // user: this.selectedUserId ?? undefined, + // }); + // } + componentWillUnmount() { $.unsubscribe(`.${this.eventId}`); $(document).off(`.${this.eventId}`); @@ -343,69 +168,70 @@ export default class Main extends React.Component { return ( <>
- {this.state.currentMode === 'events' ? ( + {this.discussionsState.currentMode === 'events' ? ( ) : ( - - + + - {this.state.currentMode === 'reviews' ? ( + {this.discussionsState.currentMode === 'reviews' ? ( ) : ( )} @@ -417,14 +243,12 @@ export default class Main extends React.Component { } private readonly checkNew = () => { - this.nextTimeout ??= checkNewTimeoutDefault; - window.clearTimeout(this.timeouts.checkNew); this.xhrCheckNew?.abort(); - this.xhrCheckNew = $.get(route('beatmapsets.discussion', { beatmapset: this.state.beatmapset.id }), { + this.xhrCheckNew = $.get(route('beatmapsets.discussion', { beatmapset: this.discussionsState.beatmapset.id }), { format: 'json', - last_updated: this.lastUpdate, + last_updated: this.discussionsState.lastUpdate, }); this.xhrCheckNew.done((data, _textStatus, xhr) => { @@ -442,30 +266,18 @@ export default class Main extends React.Component { }); }; - private discussionsByBeatmap(beatmapId: number) { - return computed(() => this.presentDiscussions.filter((discussion) => (discussion.beatmap_id == null || discussion.beatmap_id === beatmapId))); - } - - private discussionsByFilter(filter: Filter, mode: DiscussionMode, beatmapId: number) { - return computed(() => filterDiscussionsByFilter(this.discussionsByMode(mode, beatmapId), filter)).get(); - } - - private discussionsByMode(mode: DiscussionMode, beatmapId: number) { - return computed(() => filterDiscusionsByMode(this.nonNullDiscussions, mode, beatmapId)).get(); - } - - private readonly jumpTo = (_e: unknown, { id, postId }: { id: number, postId?: number }) => { - const discussion = this.discussions[id]; + private readonly jumpTo = (_e: unknown, { id, postId }: { id: number; postId?: number }) => { + const discussion = this.discussionsState.discussions[id]; if (discussion == null) return; - const newState = stateFromDiscussion(discussion) + const newState = stateFromDiscussion(discussion); - newState.filter = this.currentDiscussions().byFilter[this.state.currentFilter][newState.mode][id] != null - ? this.state.currentFilter + newState.filter = this.currentDiscussions().byFilter[this.discussionsState.currentFilter][newState.mode][id] != null + ? this.discussionsState.currentFilter : defaultFilter - if (this.state.selectedUserId != null && this.state.selectedUserId !== discussion.user_id) { + if (this.discussionsState.selectedUserId != null && this.discussionsState.selectedUserId !== discussion.user_id) { newState.selectedUserId = null; // unsets userid } @@ -478,7 +290,7 @@ export default class Main extends React.Component { if (target.length === 0) return; let offsetTop = target.offset().top - this.modeSwitcherRef.current.getBoundingClientRect().height; - if (this.state.pinnedNewDiscussion) { + if (this.discussionsState.pinnedNewDiscussion) { offsetTop -= this.newDiscussionRef.current.getBoundingClientRect().height } @@ -490,7 +302,7 @@ export default class Main extends React.Component { private readonly jumpToClick = (e: React.SyntheticEvent) => { const url = e.currentTarget.getAttribute('href'); - const parsedUrl = parseUrl(url, this.state.beatmapset.discussions); + const parsedUrl = parseUrl(url, this.discussionsState.beatmapset.discussions); if (parsedUrl == null) return; @@ -503,7 +315,7 @@ export default class Main extends React.Component { }; private readonly jumpToDiscussionByHash = () => { - const target = parseUrl(null, this.state.beatmapset.discussions) + const target = parseUrl(null, this.discussionsState.beatmapset.discussions); if (target.discussionId != null) { this.jumpTo(null, { id: target.discussionId, postId: target.postId }); @@ -513,9 +325,9 @@ export default class Main extends React.Component { @action private readonly markPostRead = (_event: unknown, { id }: { id: number | number[] }) => { if (Array.isArray(id)) { - id.forEach(this.state.readPostIds.add); + id.forEach((i) => this.discussionsState.readPostIds.add(i)); } else { - this.state.readPostIds.add(id); + this.discussionsState.readPostIds.add(id); } // setState @@ -523,8 +335,8 @@ export default class Main extends React.Component { private readonly saveStateToContainer = () => { // This is only so it can be stored with JSON.stringify. - this.state.readPostIdsArray = Array.from(this.state.readPostIds) - this.props.container.dataset.beatmapsetDiscussionState = JSON.stringify(this.state) + this.discussionsState.readPostIdsArray = Array.from(this.discussionsState.readPostIds); + this.props.container.dataset.beatmapsetDiscussionState = JSON.stringify(this.discussionsState); }; private readonly setCurrentPlaymode = (e, { mode }) => { @@ -533,12 +345,17 @@ export default class Main extends React.Component { @action private readonly setPinnedNewDiscussion = (pinned: boolean) => { - this.pinnedNewDiscussion = pinned + this.discussionsState.pinnedNewDiscussion = pinned; }; @action private readonly toggleShowDeleted = () => { - this.showDeleted = !this.showDeleted; + this.discussionsState.showDeleted = !this.discussionsState.showDeleted; + }; + + private readonly ujsDiscussionUpdate = (_e: unknown, beatmapset: BeatmapsetWithDiscussionsJson) => { + // to allow ajax:complete to be run + window.setTimeout(() => this.update(null, { beatmapset }, 0)); }; @action @@ -555,38 +372,38 @@ export default class Main extends React.Component { watching, } = options; - const newState: Partial = {} + const newState: Partial = {}; if (beatmapset != null) { newState.beatmapset = beatmapset; } if (watching != null) { - newState.beatmapset ??= Object.assign({}, this.state.beatmapset); + newState.beatmapset ??= Object.assign({}, this.discussionsState.beatmapset); newState.beatmapset.current_user_attributes.is_watching = watching; } if (playmode != null) { - const beatmap = BeatmapHelper.findDefault({ items: this.groupedBeatmaps.get(playmode) }); + const beatmap = BeatmapHelper.findDefault({ items: this.discussionsState.groupedBeatmaps.get(playmode) }); beatmapId = beatmap?.id; } - if (beatmapId != null && beatmapId != this.currentBeatmap.id) { + if (beatmapId != null && beatmapId !== this.discussionsState.currentBeatmap.id) { newState.currentBeatmapId = beatmapId; } if (filter != null) { - if (this.state.currentMode === 'events') { + if (this.discussionsState.currentMode === 'events') { newState.currentMode = this.lastMode ?? defaultMode(newState.currentBeatmapId); } - if (filter !== this.state.currentFilter) { + if (filter !== this.discussionsState.currentFilter) { newState.currentFilter = filter; } } - if (mode != null && mode !== this.state.currentMode) { - if (modeIf == null || modeIf === this.state.currentMode) { + if (mode != null && mode !== this.discussionsState.currentMode) { + if (modeIf == null || modeIf === this.discussionsState.currentMode) { newState.currentMode = mode; } @@ -595,10 +412,10 @@ export default class Main extends React.Component { // - record last mode, to be restored when setFilter is called // - set filter to total if (mode === 'events') { - this.lastMode = this.state.currentMode; - this.lastFilter = this.state.currentFilter; + this.lastMode = this.discussionsState.currentMode; + this.lastFilter = this.discussionsState.currentFilter; newState.currentFilter = 'total'; - } else if (this.state.currentMode === 'events') { + } else if (this.discussionsState.currentMode === 'events') { // switching from events: // - restore whatever last filter set or default to total newState.currentFilter = this.lastFilter ?? 'total' @@ -612,9 +429,4 @@ export default class Main extends React.Component { this.setState(newState, callback); }; - - private readonly ujsDiscussionUpdate = (_e: unknown, beatmapset: BeatmapsetWithDiscussionsJson) => { - // to allow ajax:complete to be run - window.setTimeout(() => this.update(null, { beatmapset }, 0)); - }; } From a66df3359950703e54dabe7df897c2ebc8aae937 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 7 Jun 2023 17:43:00 +0900 Subject: [PATCH 006/203] serialize --- .../beatmap-discussions/discussions-state.ts | 63 +++++++++++++++---- resources/js/beatmap-discussions/main.tsx | 31 ++------- .../js/beatmap-discussions/new-review.tsx | 2 +- 3 files changed, 56 insertions(+), 40 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 4319de48637..360daa7bd43 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -1,22 +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 BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; -import { computed, makeObservable, observable } from 'mobx'; -import core from 'osu-core-singleton'; -import { Filter } from './current-discussions'; -import DiscussionMode, { DiscussionPage } from './discussion-mode'; import { isEmpty, keyBy, maxBy } from 'lodash'; -import { findDefault, group } from 'utils/beatmap-helper'; +import { computed, makeObservable, observable, toJS } from 'mobx'; +import { deletedUser } from 'models/user'; import moment from 'moment'; +import core from 'osu-core-singleton'; +import { findDefault, group } from 'utils/beatmap-helper'; import { parseUrl } from 'utils/beatmapset-discussion-helper'; import { switchNever } from 'utils/switch-never'; -import { deletedUser } from 'models/user'; -import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; +import { Filter } from './current-discussions'; +import DiscussionMode, { DiscussionPage } from './discussion-mode'; type DiscussionsAlias = BeatmapsetWithDiscussionsJson['discussions']; - export function filterDiscusionsByMode(discussions: DiscussionsAlias, mode: DiscussionMode, beatmapId: number) { console.log(mode); switch (mode) { @@ -74,15 +73,13 @@ export default class DiscussionsState { @observable discussionCollapsed = new Map(); @observable discussionDefaultCollapsed = false; @observable highlightedDiscussionId: number | null = null; - + @observable jumpToDiscussion = false; @observable pinnedNewDiscussion = false; @observable readPostIds = new Set(); @observable selectedUserId: number | null = null; @observable showDeleted = true; - private jumpToDiscussion = false; - @computed get beatmaps() { const hasDiscussion = new Set(); @@ -145,7 +142,6 @@ export default class DiscussionsState { return value; } - @computed get nonNullDiscussions() { console.log('nonNullDiscussions'); @@ -186,7 +182,36 @@ export default class DiscussionsState { return this.presentDiscussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved); } - constructor(public beatmapset: BeatmapsetWithDiscussionsJson) { + constructor(public beatmapset: BeatmapsetWithDiscussionsJson, state?: string) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const existingState = state == null ? null : JSON.parse(state, (key, value) => { + if (Array.isArray(value)) { + if (key === 'discussionCollapsed') { + return new Map(value); + } + + if (key === 'readPostIds') { + return new Set(value); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value; + }); + + if (existingState != null) { + Object.apply(state, existingState); + this.jumpToDiscussion = true; + } else { + for (const discussion of this.beatmapset.discussions) { + if (discussion.posts != null) { + for (const post of discussion.posts) { + this.readPostIds.add(post.id); + } + } + } + } + this.currentBeatmapId = (findDefault({ group: this.groupedBeatmaps }) ?? this.beatmaps[0]).id; // Current url takes priority over saved state. @@ -215,4 +240,16 @@ export default class DiscussionsState { discussionsByMode(mode: DiscussionMode, beatmapId: number) { return computed(() => filterDiscusionsByMode(this.nonNullDiscussions, mode, beatmapId)).get(); } + + toJsonString() { + return JSON.stringify(toJS(this), (_key, value) => { + if (value instanceof Set || value instanceof Map) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Array.from(value); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value; + }); + } } diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index 664fa42662f..10937b84bf9 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -74,7 +74,7 @@ interface UpdateOptions { @observer export default class Main extends React.Component { - private readonly discussionsState: DiscussionsState; + @observable private readonly discussionsState: DiscussionsState; private readonly disposers = new Set<((() => void) | undefined)>(); private readonly eventId = `beatmap-discussions-${nextVal()}`; // FIXME: update url handler to recognize this instead @@ -89,25 +89,7 @@ export default class Main extends React.Component { constructor(props: Props) { super(props); - const existingState = JSON.parse(props.container.dataset.beatmapsetDiscussionState ?? null) as (BeatmapsetWithDiscussionsJson | null); // TODO: probably wrong - - - this.discussionsState = new DiscussionsState(props.initial.beatmapset); - - this.discussionsState = JSON.parse(props.container.dataset.beatmapsetDiscussionState ?? null) as (BeatmapsetWithDiscussionsJson | null); // TODO: probably wrong - if (this.discussionsState != null) { - this.discussionsState.readPostIds = new Set(this.discussionsState.readPostIdsArray); - this.pinnedNewDiscussion = this.discussionsState.pinnedNewDiscussion; - } else { - this.jumpToDiscussion = true; - for (const discussion of props.initial.beatmapset.discussions) { - if (discussion.posts != null) { - for (const post of discussion.posts) { - this.discussionsState.readPostIds.add(post.id); - } - } - } - } + this.discussionsState = new DiscussionsState(props.initial.beatmapset, props.container.dataset.beatmapsetDiscussionState); makeObservable(this); } @@ -124,14 +106,13 @@ export default class Main extends React.Component { $(document).on(`click.${this.eventId}`, '.js-beatmap-discussion--jump', this.jumpToClick); $(document).on(`turbolinks:before-cache.${this.eventId}`, this.saveStateToContainer); - if (this.jumpToDiscussion) { + if (this.discussionsState.jumpToDiscussion) { this.disposers.add(core.reactTurbolinks.runAfterPageLoad(this.jumpToDiscussionByHash)); } this.timeouts.checkNew = window.setTimeout(this.checkNew, checkNewTimeoutDefault); } - componentDidUpdate(_prevProps, prevState) { // TODO: autorun // if (prevState.currentBeatmapId == this.discussionsState.currentBeatmapId @@ -317,7 +298,7 @@ export default class Main extends React.Component { private readonly jumpToDiscussionByHash = () => { const target = parseUrl(null, this.discussionsState.beatmapset.discussions); - if (target.discussionId != null) { + if (target?.discussionId != null) { this.jumpTo(null, { id: target.discussionId, postId: target.postId }); } }; @@ -334,9 +315,7 @@ export default class Main extends React.Component { }; private readonly saveStateToContainer = () => { - // This is only so it can be stored with JSON.stringify. - this.discussionsState.readPostIdsArray = Array.from(this.discussionsState.readPostIds); - this.props.container.dataset.beatmapsetDiscussionState = JSON.stringify(this.discussionsState); + this.props.container.dataset.beatmapsetDiscussionState = this.discussionsState.toJsonString(); }; private readonly setCurrentPlaymode = (e, { mode }) => { diff --git a/resources/js/beatmap-discussions/new-review.tsx b/resources/js/beatmap-discussions/new-review.tsx index 79513b29f2e..04b43c14f10 100644 --- a/resources/js/beatmap-discussions/new-review.tsx +++ b/resources/js/beatmap-discussions/new-review.tsx @@ -14,7 +14,7 @@ import { trans } from 'utils/lang'; import Editor from './editor'; interface Props { - beatmaps: BeatmapExtendedJson[]; + beatmaps: Partial>; beatmapset: BeatmapsetExtendedJson; currentBeatmap: BeatmapExtendedJson; innerRef: React.RefObject; From 5b37da83cbfd3710337378cbfee9d82304d72be8 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 7 Jun 2023 20:20:38 +0900 Subject: [PATCH 007/203] more wips whee --- .../beatmap-discussions/discussions-state.ts | 83 ++++++++- .../js/beatmap-discussions/discussions.tsx | 14 -- resources/js/beatmap-discussions/main.tsx | 176 ++++-------------- 3 files changed, 123 insertions(+), 150 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 360daa7bd43..f1d8a9a6e1f 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -3,8 +3,9 @@ import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; +import GameMode from 'interfaces/game-mode'; import { isEmpty, keyBy, maxBy } from 'lodash'; -import { computed, makeObservable, observable, toJS } from 'mobx'; +import { action, computed, makeObservable, observable, toJS } from 'mobx'; import { deletedUser } from 'models/user'; import moment from 'moment'; import core from 'osu-core-singleton'; @@ -16,6 +17,17 @@ import DiscussionMode, { DiscussionPage } from './discussion-mode'; type DiscussionsAlias = BeatmapsetWithDiscussionsJson['discussions']; +export interface UpdateOptions { + beatmapId: number; + beatmapset: BeatmapsetWithDiscussionsJson; + filter: Filter; + mode: DiscussionPage; + modeIf: DiscussionPage; + playmode: GameMode; + selectedUserId: number; + watching: boolean; +} + export function filterDiscusionsByMode(discussions: DiscussionsAlias, mode: DiscussionMode, beatmapId: number) { console.log(mode); switch (mode) { @@ -252,4 +264,73 @@ export default class DiscussionsState { return value; }); } + + @action + update(options: Partial) { + const { + beatmapset, + filter, + mode, + modeIf, // TODO: remove? + playmode, + selectedUserId, + watching, + } = options; + + let { beatmapId } = options; + + if (beatmapset != null) { + this.beatmapset = beatmapset; + } + + if (watching != null) { + this.beatmapset.current_user_attributes.is_watching = watching; + } + + if (playmode != null) { + const beatmap = findDefault({ items: this.groupedBeatmaps.get(playmode) }); + beatmapId = beatmap?.id; + } + + if (beatmapId != null && beatmapId !== this.currentBeatmap.id) { + this.currentBeatmapId = beatmapId; + } + + if (filter != null) { + // TODO: this + // if (this.currentMode === 'events') { + // this.currentMode = this.lastMode ?? defaultMode(this.currentBeatmapId); + // } + + if (filter !== this.currentFilter) { + this.currentFilter = filter; + } + } + + if (mode != null && mode !== this.currentMode) { + // TODO: all this + // if (modeIf == null || modeIf === this.currentMode) { + // this.currentMode = mode; + // } + + // // switching to events: + // // - record last filter, to be restored when setMode is called + // // - record last mode, to be restored when setFilter is called + // // - set filter to total + // if (mode === 'events') { + // this.lastMode = this.currentMode; + // this.lastFilter = this.currentFilter; + // this.currentFilter = 'total'; + // } else if (this.currentMode === 'events') { + // // switching from events: + // // - restore whatever last filter set or default to total + // this.currentFilter = this.lastFilter ?? 'total'; + // } + } + + // need to setState if null + if (selectedUserId !== undefined) { + this.selectedUserId = selectedUserId; + } + } } diff --git a/resources/js/beatmap-discussions/discussions.tsx b/resources/js/beatmap-discussions/discussions.tsx index edf76ce5dad..526b9723482 100644 --- a/resources/js/beatmap-discussions/discussions.tsx +++ b/resources/js/beatmap-discussions/discussions.tsx @@ -113,14 +113,6 @@ export class Discussions extends React.Component { makeObservable(this); } - componentDidMount() { - $.subscribe('beatmapset-discussions:highlight', this.handleSetHighlight); - } - - componentWillUnmount() { - $.unsubscribe('beatmapset-discussions:highlight', this.handleSetHighlight); - } - render() { return (
@@ -158,12 +150,6 @@ export class Discussions extends React.Component { this.discussionsState.discussionCollapsed.clear(); }; - @action - private readonly handleSetHighlight = (_event: unknown, { discussionId }: { discussionId: number }) => { - // TODO: update main to use context instead of publishing event. - this.discussionsState.highlightedDiscussionId = discussionId; - }; - private readonly renderDiscussionPage = (discussion: BeatmapsetDiscussionJsonForShow) => { const visible = this.props.currentDiscussions.byFilter[this.props.currentFilter][this.props.mode][discussion.id] != null; diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index 10937b84bf9..5eafa1b46c6 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -1,36 +1,27 @@ // 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 { DiscussionsContext } from 'beatmap-discussions/discussions-context'; import { BeatmapsContext } from 'beatmap-discussions/beatmaps-context'; +import { DiscussionsContext } from 'beatmap-discussions/discussions-context'; import NewReview from 'beatmap-discussions/new-review'; import { ReviewEditorConfigContext } from 'beatmap-discussions/review-editor-config-context'; import BackToTop from 'components/back-to-top'; +import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; +import GameMode from 'interfaces/game-mode'; import { route } from 'laroute'; -import { deletedUser } from 'models/user'; +import { action, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; import core from 'osu-core-singleton'; import * as React from 'react'; -import * as BeatmapHelper from 'utils/beatmap-helper'; -import { defaultFilter, defaultMode, makeUrl, parseUrl, stateFromDiscussion } from 'utils/beatmapset-discussion-helper'; +import { defaultFilter, parseUrl, stateFromDiscussion } from 'utils/beatmapset-discussion-helper'; import { nextVal } from 'utils/seq'; import { currentUrl } from 'utils/turbolinks'; import { Discussions } from './discussions'; +import DiscussionsState, { UpdateOptions } from './discussions-state'; import { Events } from './events'; import { Header } from './header'; import { ModeSwitcher } from './mode-switcher'; import { NewDiscussion } from './new-discussion'; -import { action, computed, makeObservable, observable } from 'mobx'; -import { observer } from 'mobx-react'; -import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; -import DiscussionMode, { DiscussionPage, discussionPages } from './discussion-mode'; -import { Filter } from './current-discussions'; -import { isEmpty, keyBy, maxBy } from 'lodash'; -import moment from 'moment'; -import { findDefault } from 'utils/beatmap-helper'; -import { group } from 'utils/beatmap-helper'; -import GameMode from 'interfaces/game-mode'; -import { switchNever } from 'utils/switch-never'; -import DiscussionsState from './discussions-state'; const checkNewTimeoutDefault = 10000; const checkNewTimeoutMax = 60000; @@ -47,33 +38,8 @@ interface Props { initial: InitialData; } -interface State { - beatmapset: BeatmapsetWithDiscussionsJson; - currentMode: DiscussionPage; - currentFilter: Filter | null; - currentBeatmapId: number | null; - focusNewDiscussion: boolean; - pinnedNewDiscussion: boolean; - readPostIds: Set; - readPostIdsArray: number[]; - selectedUserId: number | null; - showDeleted: boolean; -} - -interface UpdateOptions { - callback: () => void; - mode: DiscussionPage; - modeIf: DiscussionPage; - beatmapId: number; - playmode: GameMode; - beatmapset: BeatmapsetWithDiscussionsJson; - watching: boolean; - filter: Filter; - selectedUserId: number; -} - @observer -export default class Main extends React.Component { +export default class Main extends React.Component { @observable private readonly discussionsState: DiscussionsState; private readonly disposers = new Set<((() => void) | undefined)>(); private readonly eventId = `beatmap-discussions-${nextVal()}`; @@ -223,6 +189,7 @@ export default class Main extends React.Component { ); } + @action private readonly checkNew = () => { window.clearTimeout(this.timeouts.checkNew); this.xhrCheckNew?.abort(); @@ -239,7 +206,7 @@ export default class Main extends React.Component { } this.nextTimeout = checkNewTimeoutDefault; - this.update(null, { beatmapset: data.beatmapset }); + this.discussionsState.update({ beatmapset: data.beatmapset }); }).always(() => { this.nextTimeout = Math.min(this.nextTimeout, checkNewTimeoutMax); @@ -247,42 +214,48 @@ export default class Main extends React.Component { }); }; - private readonly jumpTo = (_e: unknown, { id, postId }: { id: number; postId?: number }) => { + @action + private readonly jumpTo = (_event: unknown, { id, postId }: { id: number; postId?: number }) => { const discussion = this.discussionsState.discussions[id]; if (discussion == null) return; - const newState = stateFromDiscussion(discussion); + const { + mode, + } = stateFromDiscussion(discussion); - newState.filter = this.currentDiscussions().byFilter[this.discussionsState.currentFilter][newState.mode][id] != null - ? this.discussionsState.currentFilter - : defaultFilter + // unset filter + if (this.discussionsState.discussionsByFilter(this.discussionsState.currentFilter, mode, this.discussionsState.currentBeatmapId).find((d) => d.id === discussion.id) == null) { + this.discussionsState.currentFilter = defaultFilter; + } + // unset user filter if new discussion would have been filtered out. if (this.discussionsState.selectedUserId != null && this.discussionsState.selectedUserId !== discussion.user_id) { - newState.selectedUserId = null; // unsets userid + this.discussionsState.selectedUserId = null; } - newState.callback = () => { - $.publish('beatmapset-discussions:highlight', { discussionId: discussion.id }); + this.discussionsState.highlightedDiscussionId = discussion.id; - const attribute = postId != null ? `data-post-id='${postId}'` : `data-id='${id}'`; - const target = $(`.js-beatmap-discussion-jump[${attribute}]`); + const attribute = postId != null ? `data-post-id='${postId}'` : `data-id='${id}'`; + const target = $(`.js-beatmap-discussion-jump[${attribute}]`); - if (target.length === 0) return; + if (target.length === 0) return; + const offset = target.offset(); - let offsetTop = target.offset().top - this.modeSwitcherRef.current.getBoundingClientRect().height; - if (this.discussionsState.pinnedNewDiscussion) { - offsetTop -= this.newDiscussionRef.current.getBoundingClientRect().height - } + if (offset == null || this.modeSwitcherRef.current == null || this.newDiscussionRef.current == null) return; - $(window).stop().scrollTo(core.stickyHeader.scrollOffset(offsetTop), 500); + let offsetTop = offset.top - this.modeSwitcherRef.current.getBoundingClientRect().height; + if (this.discussionsState.pinnedNewDiscussion) { + offsetTop -= this.newDiscussionRef.current.getBoundingClientRect().height; } - this.update(null, newState); + $(window).stop().scrollTo(core.stickyHeader.scrollOffset(offsetTop), 500); }; - private readonly jumpToClick = (e: React.SyntheticEvent) => { - const url = e.currentTarget.getAttribute('href'); + private readonly jumpToClick = (e: JQuery.TriggeredEvent) => { + if (!(e.currentTarget instanceof HTMLLinkElement)) return; + + const url = e.currentTarget.href; const parsedUrl = parseUrl(url, this.discussionsState.beatmapset.discussions); if (parsedUrl == null) return; @@ -318,8 +291,8 @@ export default class Main extends React.Component { this.props.container.dataset.beatmapsetDiscussionState = this.discussionsState.toJsonString(); }; - private readonly setCurrentPlaymode = (e, { mode }) => { - this.update(e, { playmode: mode }); + private readonly setCurrentPlaymode = (_event: unknown, { mode }: { mode: GameMode }) => { + this.discussionsState.update({ playmode: mode }); }; @action @@ -332,80 +305,13 @@ export default class Main extends React.Component { this.discussionsState.showDeleted = !this.discussionsState.showDeleted; }; - private readonly ujsDiscussionUpdate = (_e: unknown, beatmapset: BeatmapsetWithDiscussionsJson) => { + private readonly ujsDiscussionUpdate = (_event: unknown, beatmapset: BeatmapsetWithDiscussionsJson) => { // to allow ajax:complete to be run - window.setTimeout(() => this.update(null, { beatmapset }, 0)); + window.setTimeout(() => this.discussionsState.update({ beatmapset }), 0); }; @action - private readonly update = (_e: unknown, options: Partial) => { - const { - beatmapId, - beatmapset, - callback, - filter, - mode, - modeIf, - playmode, - selectedUserId, - watching, - } = options; - - const newState: Partial = {}; - - if (beatmapset != null) { - newState.beatmapset = beatmapset; - } - - if (watching != null) { - newState.beatmapset ??= Object.assign({}, this.discussionsState.beatmapset); - newState.beatmapset.current_user_attributes.is_watching = watching; - } - - if (playmode != null) { - const beatmap = BeatmapHelper.findDefault({ items: this.discussionsState.groupedBeatmaps.get(playmode) }); - beatmapId = beatmap?.id; - } - - if (beatmapId != null && beatmapId !== this.discussionsState.currentBeatmap.id) { - newState.currentBeatmapId = beatmapId; - } - - if (filter != null) { - if (this.discussionsState.currentMode === 'events') { - newState.currentMode = this.lastMode ?? defaultMode(newState.currentBeatmapId); - } - - if (filter !== this.discussionsState.currentFilter) { - newState.currentFilter = filter; - } - } - - if (mode != null && mode !== this.discussionsState.currentMode) { - if (modeIf == null || modeIf === this.discussionsState.currentMode) { - newState.currentMode = mode; - } - - // switching to events: - // - record last filter, to be restored when setMode is called - // - record last mode, to be restored when setFilter is called - // - set filter to total - if (mode === 'events') { - this.lastMode = this.discussionsState.currentMode; - this.lastFilter = this.discussionsState.currentFilter; - newState.currentFilter = 'total'; - } else if (this.discussionsState.currentMode === 'events') { - // switching from events: - // - restore whatever last filter set or default to total - newState.currentFilter = this.lastFilter ?? 'total' - } - } - - // need to setState if null - if (selectedUserId !== undefined) { - newState.selectedUserId = selectedUserId - } - - this.setState(newState, callback); + private readonly update = (_event: unknown, options: Partial) => { + this.discussionsState.update(options); }; } From 0a196455aac363be3a9d98523231b2ca7a2bdc16 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 6 Jul 2023 18:26:58 +0900 Subject: [PATCH 008/203] wip --- .../beatmap-discussions/discussions-state.ts | 300 ++++++++++++------ 1 file changed, 197 insertions(+), 103 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index f1d8a9a6e1f..b4082f8d185 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -1,7 +1,7 @@ // 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 BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; +import { BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import GameMode from 'interfaces/game-mode'; import { isEmpty, keyBy, maxBy } from 'lodash'; @@ -9,32 +9,28 @@ import { action, computed, makeObservable, observable, toJS } from 'mobx'; import { deletedUser } from 'models/user'; import moment from 'moment'; import core from 'osu-core-singleton'; -import { findDefault, group } from 'utils/beatmap-helper'; -import { parseUrl } from 'utils/beatmapset-discussion-helper'; +import { findDefault, group, sortWithMode } from 'utils/beatmap-helper'; +import { makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper'; import { switchNever } from 'utils/switch-never'; -import { Filter } from './current-discussions'; -import DiscussionMode, { DiscussionPage } from './discussion-mode'; +import { Filter, filters } from './current-discussions'; +import DiscussionMode, { DiscussionPage, isDiscussionPage } from './discussion-mode'; type DiscussionsAlias = BeatmapsetWithDiscussionsJson['discussions']; export interface UpdateOptions { - beatmapId: number; beatmapset: BeatmapsetWithDiscussionsJson; - filter: Filter; - mode: DiscussionPage; - modeIf: DiscussionPage; - playmode: GameMode; - selectedUserId: number; watching: boolean; } -export function filterDiscusionsByMode(discussions: DiscussionsAlias, mode: DiscussionMode, beatmapId: number) { - console.log(mode); +// FIXME this doesn't make it so the modes with optional beatmapId can pass a beatmapId that gets ignored. +function filterDiscusionsByMode(discussions: DiscussionsAlias, mode: 'general' | 'timeline', beatmapId: number): DiscussionsAlias; +function filterDiscusionsByMode(discussions: DiscussionsAlias, mode: 'generalAll' | 'reviews'): DiscussionsAlias; +function filterDiscusionsByMode(discussions: DiscussionsAlias, mode: DiscussionMode, beatmapId?: number) { switch (mode) { case 'general': return discussions.filter((discussion) => discussion.beatmap_id === beatmapId); case 'generalAll': - return discussions.filter((discussion) => discussion.beatmap_id == null); + return discussions.filter((discussion) => discussion.beatmap_id == null && discussion.message_type !== 'review'); case 'reviews': return discussions.filter((discussion) => discussion.message_type === 'review'); case 'timeline': @@ -45,42 +41,13 @@ export function filterDiscusionsByMode(discussions: DiscussionsAlias, mode: Disc } } -export function filterDiscussionsByFilter(discussions: DiscussionsAlias, filter: Filter) { - console.log(filter); - switch (filter) { - case 'deleted': - return discussions.filter((discussion) => discussion.deleted_at != null); - case 'hype': - return discussions.filter((discussion) => discussion.message_type === 'hype'); - case 'mapperNotes': - return discussions.filter((discussion) => discussion.message_type === 'mapper_note'); - case 'mine': { - const userId = core.currentUserOrFail.id; - return discussions.filter((discussion) => discussion.user_id === userId); - } - case 'pending': - // TODO: - // pending reviews - // if (discussion.parent_id != null) { - // const parentDiscussion = discussions[discussion.parent_id]; - // if (parentDiscussion != null && parentDiscussion.message_type == 'review') return true; - // } - return discussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved); - case 'praises': - return discussions.filter((discussion) => discussion.message_type === 'praise' || discussion.message_type === 'hype'); - case 'resolved': - return discussions.filter((discussion) => discussion.can_be_resolved && discussion.resolved); - case 'total': - return discussions; - default: - switchNever(filter); - throw new Error('missing valid filter'); - } +function isFilter(value: unknown): value is Filter { + return (filters as readonly unknown[]).includes(value); } export default class DiscussionsState { @observable currentBeatmapId: number; - @observable currentFilter: Filter = 'total'; + @observable currentFilter: Filter = 'total'; // TODO: filter should always be total when page is events (also no highlight) @observable currentMode: DiscussionPage = 'general'; @observable discussionCollapsed = new Map(); @observable discussionDefaultCollapsed = false; @@ -92,6 +59,9 @@ export default class DiscussionsState { @observable selectedUserId: number | null = null; @observable showDeleted = true; + private previousFilter: Filter = 'total'; + private previousPage: DiscussionPage = 'general'; + @computed get beatmaps() { const hasDiscussion = new Set(); @@ -112,12 +82,85 @@ export default class DiscussionsState { return this.beatmaps[this.currentBeatmapId]; } + @computed + get currentBeatmapDiscussions() { + return this.discussionsByBeatmap(this.currentBeatmapId); + } + + @computed + get currentBeatmapDiscussionsCurrentModeWithFilter() { + if (this.currentMode === 'events') return []; + return this.currentDiscussions[this.currentMode]; + } + + @computed + get currentDiscussions() { + const discussions = this.currentDiscussionsGroupedByFilter[this.currentFilter];) + + return { + general: filterDiscusionsByMode(discussions, 'general', this.currentBeatmapId), + generalAll: filterDiscusionsByMode(discussions, 'generalAll'), + reviews: filterDiscusionsByMode(discussions, 'reviews'), + timeline: filterDiscusionsByMode(discussions, 'timeline', this.currentBeatmapId), + }; + } + + @computed + get currentDiscussionsGroupedByFilter() { + const groups: Record = { + deleted: [], + hype: [], + mapperNotes: [], + mine: [], + pending: [], + praises: [], + resolved: [], + total: [], + }; + + for (const filter of filters) { + groups[filter] = this.filterDiscussionsByFilter(this.currentBeatmapDiscussions, filter); + } + + return groups; + } + @computed get discussions() { // skipped discussions // - not privileged (deleted discussion) // - deleted beatmap - return keyBy(this.beatmapset.discussions.filter((discussion) => !isEmpty(discussion)), 'id') as Partial>; + + // TODO need some typing to handle the not for show variant + // null part of the key so we can use .get(null) + const map = new Map(); + + for (const discussion of this.beatmapset.discussions) { + if (!isEmpty(discussion)) { + map.set(discussion.id, discussion); + } + } + + return map; + } + + @computed + get discussionsCountByPlaymode() { + const counts: Record = { + fruits: 0, + mania: 0, + osu: 0, + taiko: 0, + }; + + for (const discussion of this.nonNullDiscussions) { + const mode = discussion.beatmap?.mode; + if (mode != null) { + counts[mode]++; + } + } + + return counts; } @computed @@ -134,6 +177,13 @@ export default class DiscussionsState { return group(Object.values(this.beatmaps)); } + @computed + get hasCurrentUserHyped() { + const currentUser = core.currentUser; // core.currentUser check below doesn't make the inferrence that it's not nullable after the check. + const discussions = filterDiscusionsByMode(this.currentDiscussionsGroupedByFilter.hype, 'generalAll'); + return currentUser != null && discussions.some((discussion) => discussion?.user_id === currentUser.id); + } + @computed get lastUpdate() { const maxLastUpdate = Math.max( @@ -145,6 +195,17 @@ export default class DiscussionsState { return maxLastUpdate != null ? moment(maxLastUpdate).unix() : null; } + get selectedUser() { + return this.selectedUserId != null ? this.users[this.selectedUserId] : null; + } + + get sortedBeatmaps() { + // TODO + // filter to only include beatmaps from the current discussion's beatmapset (for the modding profile page) + // const beatmaps = filter(this.props.beatmaps, this.isCurrentBeatmap); + return sortWithMode(Object.values(this.beatmaps)); + } + @computed get users() { const value = keyBy(this.beatmapset.related_users, 'id'); @@ -156,13 +217,12 @@ export default class DiscussionsState { @computed get nonNullDiscussions() { - console.log('nonNullDiscussions'); - return Object.values(this.discussions).filter((discussion) => discussion != null) as BeatmapsetDiscussionJson[]; + return [...this.discussions.values()].filter((discussion) => discussion != null) as DiscussionsAlias; } @computed get presentDiscussions() { - return this.nonNullDiscussions.filter((discussion) => discussion.deleted_at == null); + return this.nonNullDiscussions.filter((discussion) => discussion.deleted_at == null) as DiscussionsAlias; } @computed @@ -241,16 +301,61 @@ export default class DiscussionsState { makeObservable(this); } - discussionsByBeatmap(beatmapId: number) { - return computed(() => this.presentDiscussions.filter((discussion) => (discussion.beatmap_id == null || discussion.beatmap_id === beatmapId))); + @action + changeDiscussionPage(page?: string) { + if (!isDiscussionPage(page)) return; + + const url = makeUrl({ + beatmap: this.currentBeatmap, + filter: this.currentFilter, + mode: page, + user: this.selectedUserId ?? undefined, + }); + + if (page === 'events') { + // record page and filter when switching to events + this.previousPage = this.currentMode; + this.previousFilter = this.currentFilter; + } else if (this.currentFilter !== this.previousFilter) { + // restore previous filter when switching away from events + this.currentFilter = this.previousFilter; + } + + this.currentMode = page; + Turbolinks.controller.advanceHistory(url); + } + + @action + changeFilter(filter: unknown) { + if (!isFilter(filter)) return; + + // restore previous page when selecting a filter. + if (this.currentMode === 'events') { + this.currentMode = this.previousPage; + } + + this.currentFilter = filter; + } + + @action + changeGameMode(mode: GameMode) { + const beatmap = findDefault({ items: this.groupedBeatmaps.get(mode) }); + if (beatmap != null) { + this.currentBeatmapId = beatmap.id; + } } - discussionsByFilter(filter: Filter, mode: DiscussionMode, beatmapId: number) { - return computed(() => filterDiscussionsByFilter(this.discussionsByMode(mode, beatmapId), filter)).get(); + discussionsByBeatmap(beatmapId: number) { + return this.presentDiscussions.filter((discussion) => (discussion.beatmap_id == null || discussion.beatmap_id === beatmapId)) as DiscussionsAlias; } - discussionsByMode(mode: DiscussionMode, beatmapId: number) { - return computed(() => filterDiscusionsByMode(this.nonNullDiscussions, mode, beatmapId)).get(); + @action + markAsRead(ids: number | number[]) { + if (Array.isArray(ids)) { + ids.forEach((id) => this.readPostIds.add(id)); + } else { + this.readPostIds.add(ids); + } } toJsonString() { @@ -269,16 +374,9 @@ export default class DiscussionsState { update(options: Partial) { const { beatmapset, - filter, - mode, - modeIf, // TODO: remove? - playmode, - selectedUserId, watching, } = options; - let { beatmapId } = options; - if (beatmapset != null) { this.beatmapset = beatmapset; } @@ -286,51 +384,47 @@ export default class DiscussionsState { if (watching != null) { this.beatmapset.current_user_attributes.is_watching = watching; } + } - if (playmode != null) { - const beatmap = findDefault({ items: this.groupedBeatmaps.get(playmode) }); - beatmapId = beatmap?.id; - } - - if (beatmapId != null && beatmapId !== this.currentBeatmap.id) { - this.currentBeatmapId = beatmapId; - } + private filterDiscussionsByFilter(discussions: DiscussionsAlias, filter: Filter) { + switch (filter) { + case 'deleted': + return discussions.filter((discussion) => discussion.deleted_at != null); + case 'hype': + return discussions.filter((discussion) => discussion.message_type === 'hype'); + case 'mapperNotes': + return discussions.filter((discussion) => discussion.message_type === 'mapper_note'); + case 'mine': { + const userId = core.currentUserOrFail.id; + return discussions.filter((discussion) => discussion.user_id === userId); + } + case 'pending': { + const reviewsWithPending = new Set(); - if (filter != null) { - // TODO: this - // if (this.currentMode === 'events') { - // this.currentMode = this.lastMode ?? defaultMode(this.currentBeatmapId); - // } + const filteredDiscussions = discussions.filter((discussion) => { + if (!discussion.can_be_resolved || discussion.resolved) return false; - if (filter !== this.currentFilter) { - this.currentFilter = filter; - } - } + if (discussion.parent_id != null) { + const parentDiscussion = this.discussions.get(discussion.parent_id); + if (parentDiscussion != null && parentDiscussion.message_type === 'review') { + reviewsWithPending.add(parentDiscussion); + } + } - if (mode != null && mode !== this.currentMode) { - // TODO: all this - // if (modeIf == null || modeIf === this.currentMode) { - // this.currentMode = mode; - // } - - // // switching to events: - // // - record last filter, to be restored when setMode is called - // // - record last mode, to be restored when setFilter is called - // // - set filter to total - // if (mode === 'events') { - // this.lastMode = this.currentMode; - // this.lastFilter = this.currentFilter; - // this.currentFilter = 'total'; - // } else if (this.currentMode === 'events') { - // // switching from events: - // // - restore whatever last filter set or default to total - // this.currentFilter = this.lastFilter ?? 'total'; - // } - } + return true; + }); - // need to setState if null - if (selectedUserId !== undefined) { - this.selectedUserId = selectedUserId; + return [...filteredDiscussions, ...reviewsWithPending.values()]; + } + case 'praises': + return discussions.filter((discussion) => discussion.message_type === 'praise' || discussion.message_type === 'hype'); + case 'resolved': + return discussions.filter((discussion) => discussion.can_be_resolved && discussion.resolved); + case 'total': + return discussions; + default: + switchNever(filter); + throw new Error('missing valid filter'); } } } From a132ae4d71f1e3d86c2a0a55ae7c4d0d9e94fd90 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 6 Jul 2023 19:40:53 +0900 Subject: [PATCH 009/203] more functionality into discussions state --- resources/js/beatmap-discussions/discussion-mode.ts | 6 ++++++ resources/js/beatmap-discussions/discussions-state.ts | 2 +- resources/js/interfaces/beatmapset-with-discussions-json.ts | 5 +++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/resources/js/beatmap-discussions/discussion-mode.ts b/resources/js/beatmap-discussions/discussion-mode.ts index 797ae2a12c7..4ae6f5cc45a 100644 --- a/resources/js/beatmap-discussions/discussion-mode.ts +++ b/resources/js/beatmap-discussions/discussion-mode.ts @@ -5,6 +5,12 @@ export const discussionPages = ['reviews', 'generalAll', 'general', 'timeline', 'events'] as const; export type DiscussionPage = (typeof discussionPages)[number]; +const discussionPageSet = new Set(discussionPages); + +export function isDiscussionPage(value: unknown): value is DiscussionPage{ + return discussionPageSet.has(value); +} + type DiscussionMode = Exclude; export default DiscussionMode; diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index b4082f8d185..59d8163001c 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -95,7 +95,7 @@ export default class DiscussionsState { @computed get currentDiscussions() { - const discussions = this.currentDiscussionsGroupedByFilter[this.currentFilter];) + const discussions = this.currentDiscussionsGroupedByFilter[this.currentFilter]; return { general: filterDiscusionsByMode(discussions, 'general', this.currentBeatmapId), diff --git a/resources/js/interfaces/beatmapset-with-discussions-json.ts b/resources/js/interfaces/beatmapset-with-discussions-json.ts index 96e76d3b886..ccb40270cfb 100644 --- a/resources/js/interfaces/beatmapset-with-discussions-json.ts +++ b/resources/js/interfaces/beatmapset-with-discussions-json.ts @@ -1,9 +1,10 @@ // 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 { BeatmapsetDiscussionJsonForShow } from './beatmapset-discussion-json'; import BeatmapsetExtendedJson from './beatmapset-extended-json'; -type DiscussionsRequiredAttributes = 'beatmaps' | 'current_user_attributes' | 'discussions' | 'events' | 'nominations' | 'related_users'; -type BeatmapsetWithDiscussionsJson = BeatmapsetExtendedJson & Required>; +type DiscussionsRequiredAttributes = 'beatmaps' | 'current_user_attributes' | 'events' | 'nominations' | 'related_users'; +type BeatmapsetWithDiscussionsJson = BeatmapsetExtendedJson & Required> & Omit & { discussions: BeatmapsetDiscussionJsonForShow[] }; export default BeatmapsetWithDiscussionsJson; From 82bc4e719b630381fbebedead6cc367798400425 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 6 Jul 2023 19:44:14 +0900 Subject: [PATCH 010/203] use plain array of discussions in chart --- resources/js/beatmap-discussions/chart.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/beatmap-discussions/chart.tsx b/resources/js/beatmap-discussions/chart.tsx index 6dc72192d4b..c48caed3006 100644 --- a/resources/js/beatmap-discussions/chart.tsx +++ b/resources/js/beatmap-discussions/chart.tsx @@ -7,7 +7,7 @@ import { formatTimestamp, makeUrl } from 'utils/beatmapset-discussion-helper'; import { classWithModifiers } from 'utils/css'; interface Props { - discussions: Partial>; + discussions: BeatmapsetDiscussionJson[]; duration: number; } @@ -22,7 +22,7 @@ export default function Chart(props: Props) { const items: React.ReactNode[] = []; if (props.duration !== 0) { - Object.values(props.discussions).forEach((discussion) => { + props.discussions.forEach((discussion) => { if (discussion == null || discussion.timestamp == null) return; let className = classWithModifiers('beatmapset-discussions-chart__item', [ From 8b5b21f7550f1f15fb7f4fa94194cc75c002c239 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 6 Jul 2023 19:47:55 +0900 Subject: [PATCH 011/203] use shorthand --- resources/js/beatmap-discussions/beatmap-owner-editor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/beatmap-discussions/beatmap-owner-editor.tsx b/resources/js/beatmap-discussions/beatmap-owner-editor.tsx index e8a9c0a3dae..f30b7e27849 100644 --- a/resources/js/beatmap-discussions/beatmap-owner-editor.tsx +++ b/resources/js/beatmap-discussions/beatmap-owner-editor.tsx @@ -245,8 +245,8 @@ export default class BeatmapOwnerEditor extends React.Component { data: { beatmap: { user_id: userId } }, method: 'PUT', }); - this.xhr.updateOwner.done((data) => runInAction(() => { - $.publish('beatmapsetDiscussions:update', { beatmapset: data }); + this.xhr.updateOwner.done((beatmapset) => runInAction(() => { + $.publish('beatmapsetDiscussions:update', { beatmapset }); this.editing = false; })).fail(onErrorWithCallback(() => { this.updateOwner(userId); From a2414c10636f1aa423660039484196a2adc70afa Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 6 Jul 2023 19:48:35 +0900 Subject: [PATCH 012/203] remove the context class --- .../beatmap-discussions/discussions-state-context.ts | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 resources/js/beatmap-discussions/discussions-state-context.ts diff --git a/resources/js/beatmap-discussions/discussions-state-context.ts b/resources/js/beatmap-discussions/discussions-state-context.ts deleted file mode 100644 index 2c2ced47750..00000000000 --- a/resources/js/beatmap-discussions/discussions-state-context.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 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 { createContext } from 'react'; -import DiscussionsState from './discussions-state'; - -// TODO: combine with DiscussionsContext, BeatmapsetContext, etc into a store with properties. -const DiscussionsStateContext = createContext(new DiscussionsState()); - -export default DiscussionsStateContext; From 7d48fee474cba28c6068a32759a9f9fa59cd750c Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 6 Jul 2023 19:53:38 +0900 Subject: [PATCH 013/203] update editor to use discussions state --- .../editor-discussion-component.tsx | 20 ++--- resources/js/beatmap-discussions/editor.tsx | 77 ++++++++----------- .../js/beatmap-discussions/review-document.ts | 4 +- 3 files changed, 45 insertions(+), 56 deletions(-) diff --git a/resources/js/beatmap-discussions/editor-discussion-component.tsx b/resources/js/beatmap-discussions/editor-discussion-component.tsx index 5ea14c126db..ec1b7a2925b 100644 --- a/resources/js/beatmap-discussions/editor-discussion-component.tsx +++ b/resources/js/beatmap-discussions/editor-discussion-component.tsx @@ -2,10 +2,7 @@ // See the LICENCE file in the repository root for full licence text. import { EmbedElement } from 'editor'; -import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; -import BeatmapsetJson from 'interfaces/beatmapset-json'; -import { filter } from 'lodash'; import { Observer } from 'mobx-react'; import * as React from 'react'; import { Transforms } from 'slate'; @@ -15,6 +12,7 @@ import { formatTimestamp, makeUrl, nearbyDiscussions, parseTimestamp, timestampR import { classWithModifiers } from 'utils/css'; import { trans, transArray } from 'utils/lang'; import { linkHtml } from 'utils/url'; +import DiscussionsState from './discussions-state'; import { DraftsContext } from './drafts-context'; import EditorBeatmapSelector from './editor-beatmap-selector'; import EditorIssueTypeSelector from './editor-issue-type-selector'; @@ -32,9 +30,7 @@ interface Cache { } interface Props extends RenderElementProps { - beatmaps: BeatmapExtendedJson[]; - beatmapset: BeatmapsetJson; - discussions: Partial>; + discussionsState: DiscussionsState; editMode?: boolean; element: EmbedElement; readOnly?: boolean; @@ -48,6 +44,10 @@ export default class EditorDiscussionComponent extends React.Component { tooltipContent = React.createRef(); tooltipEl?: HTMLElement; + get discussions() { + return this.props.discussionsState.discussions; + } + componentDidMount = () => { // reset timestamp to null on clone if (this.editable()) { @@ -169,7 +169,7 @@ export default class EditorDiscussionComponent extends React.Component { if (this.cache.nearbyDiscussions == null || this.cache.nearbyDiscussions.timestamp !== timestamp || this.cache.nearbyDiscussions.beatmap_id !== beatmapId) { - const relevantDiscussions = filter(this.props.discussions, this.isRelevantDiscussion); + const relevantDiscussions = [...this.discussions.values()].filter(this.isRelevantDiscussion); this.cache.nearbyDiscussions = { beatmap_id: beatmapId, discussions: nearbyDiscussions(relevantDiscussions, timestamp), @@ -303,7 +303,7 @@ export default class EditorDiscussionComponent extends React.Component { const disabled = this.props.readOnly || !canEdit; - const discussion = this.props.element.discussionId != null ? this.props.discussions[this.props.element.discussionId] : null; + const discussion = this.discussions.get(this.props.element.discussionId); const embedMofidiers = discussion != null ? postEmbedModifiers(discussion) : this.discussionType() === 'praise' ? 'praise' : null; @@ -321,8 +321,8 @@ export default class EditorDiscussionComponent extends React.Component { className={`${bn}__selectors`} contentEditable={false} // workaround for slatejs 'Cannot resolve a Slate point from DOM point' nonsense > - - + +
>; - beatmapset: BeatmapsetJson; - currentBeatmap: BeatmapExtendedJson | null; discussion?: BeatmapsetDiscussionJson; - discussions: Partial>; // passed in via context at parent + discussionsState: DiscussionsState; document?: string; editing: boolean; onChange?: () => void; @@ -73,6 +69,7 @@ function isDraftEmbed(block: SlateElement): block is EmbedElement { return block.type === 'embed' && block.discussionId == null; } +@observer export default class Editor extends React.Component { static contextType = ReviewEditorConfigContext; static defaultProps = { @@ -88,7 +85,19 @@ export default class Editor extends React.Component { slateEditor: SlateEditor; toolbarRef: React.RefObject; private readonly initialValue: SlateElement[] = emptyDocTemplate; - private xhr?: JQueryXHR | null; + private xhr: JQuery.jqXHR | null = null; + + private get beatmaps() { + return this.props.discussionsState.beatmaps; + } + + private get beatmapset() { + return this.props.discussionsState.beatmapset; + } + + private get discussions() { + return this.props.discussionsState.discussions; + } private get editMode() { return this.props.document != null; @@ -103,7 +112,7 @@ export default class Editor extends React.Component { this.scrollContainerRef = React.createRef(); this.toolbarRef = React.createRef(); this.insertMenuRef = React.createRef(); - this.localStorageKey = `newDiscussion-${this.props.beatmapset.id}`; + this.localStorageKey = `newDiscussion-${this.beatmapset.id}`; if (this.editMode) { this.initialValue = this.valueFromProps(); @@ -185,16 +194,6 @@ export default class Editor extends React.Component { return ranges; }; - /** - * Type guard for checking if the beatmap is part of currently selected beatmapset - * - * @param beatmap - * @returns boolean - */ - isCurrentBeatmap = (beatmap?: BeatmapExtendedJson): beatmap is BeatmapExtendedJson => ( - beatmap != null && beatmap.beatmapset_id === this.props.beatmapset.id - ); - onChange = (value: SlateElement[]) => { // Anything that triggers this needs to be fixed! // Slate.value is only used for initial value. @@ -260,12 +259,14 @@ export default class Editor extends React.Component { if (this.showConfirmationIfRequired()) { this.setState({ posting: true }, () => { - this.xhr = $.ajax(route('beatmapsets.discussion.review', { beatmapset: this.props.beatmapset.id }), { + this.xhr = $.ajax(route('beatmapsets.discussion.review', { beatmapset: this.beatmapset.id }), { data: { document: this.serialize() }, method: 'POST', - }) - .done((data) => { - $.publish('beatmapsetDiscussions:update', { beatmapset: data }); + }); + + this.xhr + .done((beatmapset) => { + $.publish('beatmapsetDiscussions:update', { beatmapset }); this.resetInput(); }) .fail(onError) @@ -298,7 +299,7 @@ export default class Editor extends React.Component { >
- + { const { element, ...otherProps } = props; // spreading ..props doesn't use the narrower type. el = ( { showConfirmationIfRequired = () => { const docContainsProblem = slateDocumentContainsNewProblem(this.state.value); const canDisqualify = core.currentUser != null && (core.currentUser.is_admin || core.currentUser.is_moderator || core.currentUser.is_full_bn); - const willDisqualify = this.props.beatmapset.status === 'qualified' && docContainsProblem; + const willDisqualify = this.beatmapset.status === 'qualified' && docContainsProblem; const canReset = core.currentUser != null && (core.currentUser.is_admin || core.currentUser.is_nat || core.currentUser.is_bng); const willReset = - this.props.beatmapset.status === 'pending' && - this.props.beatmapset.nominations && nominationsCount(this.props.beatmapset.nominations, 'current') > 0 && + this.beatmapset.status === 'pending' && + this.beatmapset.nominations && nominationsCount(this.beatmapset.nominations, 'current') > 0 && docContainsProblem; if (canDisqualify && willDisqualify) { @@ -451,16 +450,6 @@ export default class Editor extends React.Component { return true; }; - sortedBeatmaps = () => { - if (this.cache.sortedBeatmaps == null) { - // filter to only include beatmaps from the current discussion's beatmapset (for the modding profile page) - const beatmaps = filter(this.props.beatmaps, this.isCurrentBeatmap); - this.cache.sortedBeatmaps = sortWithMode(beatmaps); - } - - return this.cache.sortedBeatmaps; - }; - updateDrafts = () => { this.cache.draftEmbeds = this.state.value.filter(isDraftEmbed); }; @@ -500,7 +489,7 @@ export default class Editor extends React.Component { } if (node.beatmapId != null) { - const beatmap = this.props.beatmaps[node.beatmapId]; + const beatmap = this.beatmaps[node.beatmapId]; if (beatmap == null || beatmap.deleted_at != null) { Transforms.setNodes(editor, { beatmapId: undefined }, { at: path }); } @@ -523,10 +512,10 @@ export default class Editor extends React.Component { } private valueFromProps() { - if (!this.props.editing || this.props.document == null || this.props.discussions == null) { + if (!this.props.editing || this.props.document == null) { return []; } - return parseFromJson(this.props.document, this.props.discussions); + return parseFromJson(this.props.document, this.discussions); } } diff --git a/resources/js/beatmap-discussions/review-document.ts b/resources/js/beatmap-discussions/review-document.ts index dd1a9a2ecd8..67b1770f884 100644 --- a/resources/js/beatmap-discussions/review-document.ts +++ b/resources/js/beatmap-discussions/review-document.ts @@ -30,7 +30,7 @@ function isText(node: UnistNode): node is TextNode { return node.type === 'text'; } -export function parseFromJson(json: string, discussions: Partial>) { +export function parseFromJson(json: string, discussions: Map) { let srcDoc: BeatmapDiscussionReview; try { @@ -87,7 +87,7 @@ export function parseFromJson(json: string, discussions: Partial Date: Fri, 7 Jul 2023 17:27:56 +0900 Subject: [PATCH 014/203] use DiscussionsState in NewReply --- resources/js/beatmap-discussions/new-reply.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/resources/js/beatmap-discussions/new-reply.tsx b/resources/js/beatmap-discussions/new-reply.tsx index f76579b165e..9ef2ba5df05 100644 --- a/resources/js/beatmap-discussions/new-reply.tsx +++ b/resources/js/beatmap-discussions/new-reply.tsx @@ -3,10 +3,8 @@ import BigButton from 'components/big-button'; import UserAvatar from 'components/user-avatar'; -import BeatmapJson from 'interfaces/beatmap-json'; import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; import { BeatmapsetDiscussionPostStoreResponseJson } from 'interfaces/beatmapset-discussion-post-responses'; -import BeatmapsetJson from 'interfaces/beatmapset-json'; import { route } from 'laroute'; import { action, makeObservable, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; @@ -20,13 +18,13 @@ import { trans } from 'utils/lang'; import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay'; import { present } from 'utils/string'; import DiscussionMessageLengthCounter from './discussion-message-length-counter'; +import DiscussionsState from './discussions-state'; const bn = 'beatmap-discussion-post'; interface Props { - beatmapset: BeatmapsetJson; - currentBeatmap: BeatmapJson | null; discussion: BeatmapsetDiscussionJson; + discussionsState: DiscussionsState; } const actionIcons = { @@ -161,8 +159,8 @@ export class NewReply extends React.Component { .done((json) => runInAction(() => { this.editing = false; this.setMessage(''); - $.publish('beatmapDiscussionPost:markRead', { id: json.beatmap_discussion_post_ids }); - $.publish('beatmapsetDiscussions:update', { beatmapset: json.beatmapset }); + this.props.discussionsState.markAsRead(json.beatmap_discussion_post_ids); + this.props.discussionsState.update({ beatmapset: json.beatmapset }); })) .fail(onError) .always(action(() => { From b3712b04f62ce7ba11362870745cd4de38c9cd04 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 7 Jul 2023 17:28:19 +0900 Subject: [PATCH 015/203] update NewReview to use DiscussionsState --- .../js/beatmap-discussions/new-review.tsx | 47 +++++++++---------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/resources/js/beatmap-discussions/new-review.tsx b/resources/js/beatmap-discussions/new-review.tsx index 04b43c14f10..420a397d583 100644 --- a/resources/js/beatmap-discussions/new-review.tsx +++ b/resources/js/beatmap-discussions/new-review.tsx @@ -1,9 +1,6 @@ // 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 { DiscussionsContext } from 'beatmap-discussions/discussions-context'; -import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; -import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import core from 'osu-core-singleton'; @@ -11,15 +8,12 @@ import * as React from 'react'; import { downloadLimited } from 'utils/beatmapset-helper'; import { classWithModifiers } from 'utils/css'; import { trans } from 'utils/lang'; +import DiscussionsState from './discussions-state'; import Editor from './editor'; interface Props { - beatmaps: Partial>; - beatmapset: BeatmapsetExtendedJson; - currentBeatmap: BeatmapExtendedJson; + discussionsState: DiscussionsState; innerRef: React.RefObject; - pinned?: boolean; - setPinned?: (sticky: boolean) => void; stickTo?: React.RefObject; } @@ -29,15 +23,23 @@ export default class NewReview extends React.Component { @observable private mounted = false; @observable private stickToHeight: number | undefined; + private get beatmapset() { + return this.props.discussionsState.beatmapset; + } + @computed private get cssTop() { - if (this.mounted && this.props.pinned && this.stickToHeight != null) { + if (this.mounted && this.pinned && this.stickToHeight != null) { return core.stickyHeader.headerHeight + this.stickToHeight; } } + private get pinned() { + return this.props.discussionsState.pinnedNewDiscussion; + } + private get noPermissionText() { - if (downloadLimited(this.props.beatmapset)) { + if (downloadLimited(this.beatmapset)) { return trans('beatmaps.discussions.message_placeholder_locked'); } @@ -71,7 +73,7 @@ export default class NewReview extends React.Component { const placeholder = this.noPermissionText; return ( -
+
@@ -80,26 +82,19 @@ export default class NewReview extends React.Component { {trans('beatmaps.discussions.review.new')}
{placeholder == null ? ( - - { - (discussions) => () - } - + ) :
{placeholder}
}
@@ -113,12 +108,12 @@ export default class NewReview extends React.Component { @action private setSticky(sticky: boolean) { - this.props.setPinned?.(sticky); + this.props.discussionsState.pinnedNewDiscussion = sticky; this.updateStickToHeight(); } private readonly toggleSticky = () => { - this.setSticky(!this.props.pinned); + this.setSticky(!this.pinned); }; @action From 874ce77a5fa125c763018f961fe1af835a0948cb Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 7 Jul 2023 17:28:39 +0900 Subject: [PATCH 016/203] update NewDiscussion to use DiscussionsState --- .../js/beatmap-discussions/new-discussion.tsx | 131 ++++++++++-------- 1 file changed, 74 insertions(+), 57 deletions(-) diff --git a/resources/js/beatmap-discussions/new-discussion.tsx b/resources/js/beatmap-discussions/new-discussion.tsx index 7b26cd456cc..033e253dad1 100644 --- a/resources/js/beatmap-discussions/new-discussion.tsx +++ b/resources/js/beatmap-discussions/new-discussion.tsx @@ -9,11 +9,9 @@ import UserAvatar from 'components/user-avatar'; import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; import { BeatmapsetDiscussionPostStoreResponseJson } from 'interfaces/beatmapset-discussion-post-responses'; -import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; -import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import { route } from 'laroute'; -import { action, computed, makeObservable, observable, runInAction } from 'mobx'; -import { observer } from 'mobx-react'; +import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; +import { disposeOnUnmount, observer } from 'mobx-react'; import core from 'osu-core-singleton'; import * as React from 'react'; import TextareaAutosize from 'react-autosize-textarea'; @@ -25,9 +23,8 @@ import { InputEventType, makeTextAreaHandler } from 'utils/input-handler'; import { joinComponents, trans } from 'utils/lang'; import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay'; import { present } from 'utils/string'; -import CurrentDiscussions from './current-discussions'; import DiscussionMessageLengthCounter from './discussion-message-length-counter'; -import DiscussionMode from './discussion-mode'; +import DiscussionsState from './discussions-state'; import { hypeExplanationClass } from './nominations'; const bn = 'beatmap-discussion-new'; @@ -40,13 +37,8 @@ interface DiscussionsCache { interface Props { autoFocus: boolean; - beatmapset: BeatmapsetExtendedJson & BeatmapsetWithDiscussionsJson; - currentBeatmap: BeatmapExtendedJson; - currentDiscussions: CurrentDiscussions; + discussionsState: DiscussionsState; innerRef: React.RefObject; - mode: DiscussionMode; - pinned: boolean; - setPinned: (flag: boolean) => void; stickTo: React.RefObject; } @@ -63,34 +55,46 @@ export class NewDiscussion extends React.Component { @observable private stickToHeight: number | undefined; @observable private timestampConfirmed = false; + private get beatmapset() { + return this.props.discussionsState.beatmapset; + } + + private get currentBeatmap() { + return this.props.discussionsState.currentBeatmap; + } + + private get currentMode() { + return this.props.discussionsState.currentMode; + } + private get canPost() { if (core.currentUser == null) return false; - if (downloadLimited(this.props.beatmapset)) return false; + if (downloadLimited(this.beatmapset)) return false; return !core.currentUser.is_silenced - && (!this.props.beatmapset.discussion_locked || canModeratePosts()) - && (this.props.currentBeatmap.deleted_at == null || this.props.mode === 'generalAll'); + && (!this.beatmapset.discussion_locked || canModeratePosts()) + && (this.currentBeatmap.deleted_at == null || this.currentMode === 'generalAll'); } @computed private get cssTop() { - if (this.mounted && this.props.pinned && this.stickToHeight != null) { + if (this.mounted && this.pinned && this.stickToHeight != null) { return core.stickyHeader.headerHeight + this.stickToHeight; } } private get isTimeline() { - return this.props.mode === 'timeline'; + return this.currentMode === 'timeline'; } private get nearbyDiscussions() { const timestamp = this.timestamp; if (timestamp == null) return []; - if (this.nearbyDiscussionsCache == null || (this.nearbyDiscussionsCache.beatmap !== this.props.currentBeatmap || this.nearbyDiscussionsCache.timestamp !== this.timestamp)) { + if (this.nearbyDiscussionsCache == null || (this.nearbyDiscussionsCache.beatmap !== this.currentBeatmap || this.nearbyDiscussionsCache.timestamp !== this.timestamp)) { this.nearbyDiscussionsCache = { - beatmap: this.props.currentBeatmap, - discussions: nearbyDiscussions(this.props.currentDiscussions.timelineAllUsers, timestamp), + beatmap: this.currentBeatmap, + discussions: nearbyDiscussions(this.props.discussionsState.currentBeatmapDiscussions, timestamp), timestamp: this.timestamp, }; } @@ -98,8 +102,12 @@ export class NewDiscussion extends React.Component { return this.nearbyDiscussionsCache?.discussions ?? []; } + private get pinned() { + return this.props.discussionsState.pinnedNewDiscussion; + } + private get storageKey() { - return `beatmapset-discussion:store:${this.props.beatmapset.id}:message`; + return `beatmapset-discussion:store:${this.beatmapset.id}:message`; } private get storedMessage() { @@ -110,12 +118,12 @@ export class NewDiscussion extends React.Component { if (core.currentUser == null) return; if (this.canPost) { - return trans(`beatmaps.discussions.message_placeholder.${this.props.mode}`, { version: this.props.currentBeatmap.version }); + return trans(`beatmaps.discussions.message_placeholder.${this.currentMode}`, { version: this.currentBeatmap.version }); } if (core.currentUser.is_silenced) { return trans('beatmaps.discussions.message_placeholder_silenced'); - } else if (this.props.beatmapset.discussion_locked || downloadLimited(this.props.beatmapset)) { + } else if (this.beatmapset.discussion_locked || downloadLimited(this.beatmapset)) { return trans('beatmaps.discussions.message_placeholder_locked'); } else { return trans('beatmaps.discussions.message_placeholder_deleted_beatmap'); @@ -124,7 +132,7 @@ export class NewDiscussion extends React.Component { @computed private get timestamp() { - return this.props.mode === 'timeline' + return this.currentMode === 'timeline' ? parseTimestamp(this.message) : null; } @@ -133,6 +141,21 @@ export class NewDiscussion extends React.Component { super(props); makeObservable(this); this.handleKeyDown = makeTextAreaHandler(this.handleKeyDownCallback); + + disposeOnUnmount(this, reaction(() => this.message, (current, prev) => { + if (prev !== current) { + this.storeMessage(); + } + })); + + disposeOnUnmount(this, reaction(() => this.props.discussionsState.beatmapset, (current, prev) => { + // TODO: check if this is still needed. + if (prev.id !== current.id) { + runInAction(() => { + this.message = this.storedMessage; + }); + } + })); } componentDidMount() { @@ -145,14 +168,6 @@ export class NewDiscussion extends React.Component { } } - componentDidUpdate(prevProps: Readonly) { - if (prevProps.beatmapset.id !== this.props.beatmapset.id) { - this.message = this.storedMessage; - return; - } - this.storeMessage(); - } - componentWillUnmount() { $(window).off('resize', this.updateStickToHeight); this.postXhr?.abort(); @@ -160,7 +175,7 @@ export class NewDiscussion extends React.Component { } render() { - const cssClasses = classWithModifiers('beatmap-discussion-new-float', { pinned: this.props.pinned }); + const cssClasses = classWithModifiers('beatmap-discussion-new-float', { pinned: this.pinned }); return (
{ } if (type === 'hype') { - if (!confirm(trans('beatmaps.hype.confirm', { n: this.props.beatmapset.current_user_attributes.remaining_hype }))) return; + if (!confirm(trans('beatmaps.hype.confirm', { n: this.beatmapset.current_user_attributes.remaining_hype }))) return; } showLoadingOverlay(); @@ -209,14 +224,14 @@ export class NewDiscussion extends React.Component { const data = { beatmap_discussion: { - beatmap_id: this.props.mode === 'generalAll' ? undefined : this.props.currentBeatmap.id, + beatmap_id: this.currentMode === 'generalAll' ? undefined : this.currentBeatmap.id, message_type: type, timestamp: this.timestamp, }, beatmap_discussion_post: { message: this.message, }, - beatmapset_id: this.props.currentBeatmap.beatmapset_id, + beatmapset_id: this.currentBeatmap.beatmapset_id, }; this.postXhr = $.ajax(route('beatmapsets.discussions.posts.store'), { @@ -228,8 +243,10 @@ export class NewDiscussion extends React.Component { .done((json) => runInAction(() => { this.message = ''; this.timestampConfirmed = false; - $.publish('beatmapDiscussionPost:markRead', { id: json.beatmap_discussion_post_ids }); - $.publish('beatmapsetDiscussions:update', { beatmapset: json.beatmapset }); + for (const postId of json.beatmap_discussion_post_ids) { + this.props.discussionsState.readPostIds.add(postId); + } + this.props.discussionsState.beatmapset = json.beatmapset; })) .fail(onError) .always(action(() => { @@ -241,13 +258,13 @@ export class NewDiscussion extends React.Component { private problemType() { const canDisqualify = core.currentUser?.is_admin || core.currentUser?.is_moderator || core.currentUser?.is_full_bn; - const willDisqualify = this.props.beatmapset.status === 'qualified'; + const willDisqualify = this.beatmapset.status === 'qualified'; if (canDisqualify && willDisqualify) return 'disqualify'; const canReset = core.currentUser?.is_admin || core.currentUser?.is_nat || core.currentUser?.is_bng; - const currentNominations = nominationsCount(this.props.beatmapset.nominations, 'current'); - const willReset = this.props.beatmapset.status === 'pending' && currentNominations > 0; + const currentNominations = nominationsCount(this.beatmapset.nominations, 'current'); + const willReset = this.beatmapset.status === 'pending' && currentNominations > 0; if (canReset && willReset) return 'nomination_reset'; if (willDisqualify) return 'problem_warning'; @@ -256,17 +273,17 @@ export class NewDiscussion extends React.Component { } private renderBox() { - const canHype = this.props.beatmapset.current_user_attributes?.can_hype - && this.props.beatmapset.can_be_hyped - && this.props.mode === 'generalAll'; + const canHype = this.beatmapset.current_user_attributes?.can_hype + && this.beatmapset.can_be_hyped + && this.currentMode === 'generalAll'; const canPostNote = core.currentUser != null - && (core.currentUser.id === this.props.beatmapset.user_id - || (core.currentUser.id === this.props.currentBeatmap.user_id && ['general', 'timeline'].includes(this.props.mode)) + && (core.currentUser.id === this.beatmapset.user_id + || (core.currentUser.id === this.currentBeatmap.user_id && ['general', 'timeline'].includes(this.currentMode)) || core.currentUser.is_bng || canModeratePosts()); - const buttonCssClasses = classWithModifiers('btn-circle', { activated: this.props.pinned }); + const buttonCssClasses = classWithModifiers('btn-circle', { activated: this.pinned }); return (
@@ -278,7 +295,7 @@ export class NewDiscussion extends React.Component { @@ -313,7 +330,7 @@ export class NewDiscussion extends React.Component { } private renderHype() { - if (!(this.props.mode === 'generalAll' && this.props.beatmapset.can_be_hyped)) return null; + if (!(this.currentMode === 'generalAll' && this.beatmapset.can_be_hyped)) return null; return (
@@ -323,17 +340,17 @@ export class NewDiscussion extends React.Component {
{core.currentUser != null ? ( - {this.props.beatmapset.current_user_attributes.can_hype ? trans('beatmaps.hype.explanation') : this.props.beatmapset.current_user_attributes.can_hype_reason} - {(this.props.beatmapset.current_user_attributes.can_hype || this.props.beatmapset.current_user_attributes.remaining_hype <= 0) && ( + {this.beatmapset.current_user_attributes.can_hype ? trans('beatmaps.hype.explanation') : this.beatmapset.current_user_attributes.can_hype_reason} + {(this.beatmapset.current_user_attributes.can_hype || this.beatmapset.current_user_attributes.remaining_hype <= 0) && ( <> - {this.props.beatmapset.current_user_attributes.new_hype_time != null && ( + {this.beatmapset.current_user_attributes.new_hype_time != null && ( , + new_time: , }} pattern={` ${trans('beatmaps.hype.new_time')}`} /> @@ -410,7 +427,7 @@ export class NewDiscussion extends React.Component { } private renderTimestamp() { - if (this.props.mode !== 'timeline') return null; + if (this.currentMode !== 'timeline') return null; const timestamp = this.timestamp != null ? formatTimestamp(this.timestamp) : trans('beatmaps.discussions.new.timestamp_missing'); @@ -433,7 +450,7 @@ export class NewDiscussion extends React.Component { @action private readonly setSticky = (sticky: boolean) => { - this.props.setPinned(sticky); + this.props.discussionsState.pinnedNewDiscussion = sticky this.updateStickToHeight(); }; @@ -463,7 +480,7 @@ export class NewDiscussion extends React.Component { } private readonly toggleSticky = () => { - this.setSticky(!this.props.pinned); + this.setSticky(!this.pinned); }; @action From 08c203fc1e7d9a1db6452dea6d634f6904c8a65f Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 7 Jul 2023 17:29:46 +0900 Subject: [PATCH 017/203] Nominator use DiscussionsState --- .../js/beatmap-discussions/nominator.tsx | 73 +++++++++++-------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/resources/js/beatmap-discussions/nominator.tsx b/resources/js/beatmap-discussions/nominator.tsx index 35d6a9df44c..aa2d91f7e63 100644 --- a/resources/js/beatmap-discussions/nominator.tsx +++ b/resources/js/beatmap-discussions/nominator.tsx @@ -6,10 +6,9 @@ import Modal from 'components/modal'; import BeatmapsetEventJson from 'interfaces/beatmapset-event-json'; import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import GameMode from 'interfaces/game-mode'; -import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; import { forEachRight, map, uniq } from 'lodash'; -import { action, computed, makeObservable, observable } from 'mobx'; +import { action, computed, makeObservable, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import core from 'osu-core-singleton'; import * as React from 'react'; @@ -17,12 +16,10 @@ import { onError } from 'utils/ajax'; import { isUserFullNominator } from 'utils/beatmapset-discussion-helper'; import { classWithModifiers } from 'utils/css'; import { trans } from 'utils/lang'; +import DiscussionsState from './discussions-state'; interface Props { - beatmapset: BeatmapsetWithDiscussionsJson; - currentHype: number; - unresolvedIssues: number; - users: Partial>; + discussionsState: DiscussionsState; } const bn = 'nomination-dialog'; @@ -35,18 +32,26 @@ export class Nominator extends React.Component { @observable private visible = false; private xhr?: JQuery.jqXHR; + private get beatmapset() { + return this.props.discussionsState.beatmapset; + } + + private get currentHype() { + return this.props.discussionsState.totalHype; + } + private get mapCanBeNominated() { - if (this.props.beatmapset.hype == null) { + if (this.beatmapset.hype == null) { return false; } - return this.props.beatmapset.status === 'pending' && this.props.currentHype >= this.props.beatmapset.hype.required; + return this.beatmapset.status === 'pending' && this.currentHype >= this.beatmapset.hype.required; } private get nominationEvents() { const nominations: BeatmapsetEventJson[] = []; - forEachRight(this.props.beatmapset.events, (event) => { + forEachRight(this.beatmapset.events, (event) => { if (event.type === 'nomination_reset') { return false; } @@ -61,9 +66,17 @@ export class Nominator extends React.Component { @computed private get playmodes() { - return this.props.beatmapset.nominations.legacy_mode + return this.beatmapset.nominations.legacy_mode ? null - : Object.keys(this.props.beatmapset.nominations.required) as GameMode[]; + : Object.keys(this.beatmapset.nominations.required) as GameMode[]; + } + + private get unresolvedIssues() { + return this.props.discussionsState.unresolvedIssues; + } + + private get users() { + return this.props.discussionsState.users; } private get userCanNominate() { @@ -71,7 +84,7 @@ export class Nominator extends React.Component { return false; } - const nominationModes = this.playmodes ?? uniq(this.props.beatmapset.beatmaps.map((bm) => bm.mode)); + const nominationModes = this.playmodes ?? uniq(this.beatmapset.beatmaps.map((bm) => bm.mode)); return nominationModes.some((mode) => this.userCanNominateMode(mode)); } @@ -84,8 +97,8 @@ export class Nominator extends React.Component { private get userIsOwner() { const userId = core.currentUserOrFail.id; - return userId === this.props.beatmapset.user_id - || this.props.beatmapset.beatmaps.some((beatmap) => beatmap.deleted_at == null && userId === beatmap.user_id); + return userId === this.beatmapset.user_id + || this.beatmapset.beatmaps.some((beatmap) => beatmap.deleted_at == null && userId === beatmap.user_id); } private get userNominatableModes() { @@ -93,7 +106,7 @@ export class Nominator extends React.Component { return {}; } - return this.props.beatmapset.current_user_attributes.nomination_modes ?? {}; + return this.beatmapset.current_user_attributes.nomination_modes ?? {}; } constructor(props: Props) { @@ -119,7 +132,7 @@ export class Nominator extends React.Component { private hasFullNomination(mode: GameMode) { return this.nominationEvents.some((event) => { - const user = event.user_id != null ? this.props.users[event.user_id] : null; + const user = event.user_id != null ? this.users[event.user_id] : null; return event.type === 'nominate' && event.comment != null ? event.comment.modes.includes(mode) && isUserFullNominator(user, mode) @@ -138,7 +151,7 @@ export class Nominator extends React.Component { this.loading = true; - const url = route('beatmapsets.nominate', { beatmapset: this.props.beatmapset.id }); + const url = route('beatmapsets.nominate', { beatmapset: this.beatmapset.id }); const params = { data: { playmodes: this.playmodes != null && this.playmodes.length === 1 ? this.playmodes : this.selectedModes, @@ -147,21 +160,21 @@ export class Nominator extends React.Component { }; this.xhr = $.ajax(url, params); - this.xhr.done((response) => { - $.publish('beatmapsetDiscussions:update', { beatmapset: response }); + this.xhr.done((beatmapset) => runInAction(() => { + this.props.discussionsState.beatmapset = beatmapset; this.hideNominationModal(); - }) + })) .fail(onError) .always(action(() => this.loading = false)); }; private nominationCountMet(mode: GameMode) { - if (this.props.beatmapset.nominations.legacy_mode || this.props.beatmapset.nominations.required[mode] === 0) { + if (this.beatmapset.nominations.legacy_mode || this.beatmapset.nominations.required[mode] === 0) { return false; } - const req = this.props.beatmapset.nominations.required[mode]; - const curr = this.props.beatmapset.nominations.current[mode] ?? 0; + const req = this.beatmapset.nominations.required[mode]; + const curr = this.beatmapset.nominations.current[mode] ?? 0; if (!req) { return false; @@ -176,9 +189,9 @@ export class Nominator extends React.Component { } let tooltipText: string | undefined; - if (this.props.unresolvedIssues > 0) { + if (this.unresolvedIssues > 0) { tooltipText = trans('beatmaps.nominations.unresolved_issues'); - } else if (this.props.beatmapset.nominations.nominated) { + } else if (this.beatmapset.nominations.nominated) { tooltipText = trans('beatmaps.nominations.already_nominated'); } else if (!this.userCanNominate) { tooltipText = trans('beatmaps.nominations.cannot_nominate'); @@ -276,12 +289,12 @@ export class Nominator extends React.Component { let req: number; let curr: number; - if (this.props.beatmapset.nominations.legacy_mode) { - req = this.props.beatmapset.nominations.required; - curr = this.props.beatmapset.nominations.current; + if (this.beatmapset.nominations.legacy_mode) { + req = this.beatmapset.nominations.required; + curr = this.beatmapset.nominations.current; } else { - req = this.props.beatmapset.nominations.required[mode] ?? 0; - curr = this.props.beatmapset.nominations.current[mode] ?? 0; + req = this.beatmapset.nominations.required[mode] ?? 0; + curr = this.beatmapset.nominations.current[mode] ?? 0; } return (curr === req - 1) && !this.hasFullNomination(mode); From 4a4b35bcec511a4cc1348410e274ebceac9cc36d Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 7 Jul 2023 17:30:05 +0900 Subject: [PATCH 018/203] Nominations use DiscussionsState --- .../js/beatmap-discussions/nominations.tsx | 167 +++++++++--------- 1 file changed, 85 insertions(+), 82 deletions(-) diff --git a/resources/js/beatmap-discussions/nominations.tsx b/resources/js/beatmap-discussions/nominations.tsx index 97e80048bd5..70eafd7b2ec 100644 --- a/resources/js/beatmap-discussions/nominations.tsx +++ b/resources/js/beatmap-discussions/nominations.tsx @@ -11,14 +11,13 @@ import Modal from 'components/modal'; import StringWithComponent from 'components/string-with-component'; import TimeWithTooltip from 'components/time-with-tooltip'; import UserLink from 'components/user-link'; -import { BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; import BeatmapsetEventJson from 'interfaces/beatmapset-event-json'; import { BeatmapsetNominationsInterface } from 'interfaces/beatmapset-json'; import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import GameMode from 'interfaces/game-mode'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; -import { action, makeObservable, observable } from 'mobx'; +import { action, makeObservable, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import { deletedUser } from 'models/user'; import moment from 'moment'; @@ -32,7 +31,7 @@ import { formatNumber } from 'utils/html'; import { joinComponents, trans, transExists } from 'utils/lang'; import { presence } from 'utils/string'; import { wikiUrl } from 'utils/url'; -import CurrentDiscussions from './current-discussions'; +import DiscussionsState from './discussions-state'; const bn = 'beatmap-discussion-nomination'; const flashClass = 'js-flash-border--on'; @@ -40,11 +39,7 @@ export const hypeExplanationClass = 'js-hype--explanation'; const nominatorsVisibleBeatmapStatuses = Object.freeze(new Set(['wip', 'pending', 'ranked', 'qualified'])); interface Props { - beatmapset: BeatmapsetWithDiscussionsJson; - currentDiscussions: CurrentDiscussions; - discussions: Partial>; - events: BeatmapsetEventJson[]; - users: Partial>; + discussionsState: DiscussionsState; } type XhrType = 'delete' | 'discussionLock' | 'removeFromLoved'; @@ -62,14 +57,26 @@ function formatDate(date: string | null) { } @observer -export class Nominations extends React.PureComponent { +export class Nominations extends React.Component { private hypeFocusTimeout: number | undefined; @observable private showBeatmapsOwnerEditor = false; @observable private showLoveBeatmapDialog = false; @observable private readonly xhr: Partial>> = {}; + private get beatmapset() { + return this.props.discussionsState.beatmapset; + } + + private get discussions() { + return this.props.discussionsState.discussions; + } + + private get events() { + return this.beatmapset.events; + } + private get isQualified() { - return this.props.beatmapset.status === 'qualified'; + return this.beatmapset.status === 'qualified'; } private get userCanDisqualify() { @@ -77,7 +84,11 @@ export class Nominations extends React.PureComponent { } private get userIsOwner() { - return core.currentUser != null && (core.currentUser.id === this.props.beatmapset.user_id); + return core.currentUser != null && (core.currentUser.id === this.beatmapset.user_id); + } + + private get users() { + return this.props.discussionsState.users; } constructor(props: Props) { @@ -112,10 +123,7 @@ export class Nominations extends React.PureComponent {
{this.renderDisqualifyButton()}
@@ -142,10 +150,10 @@ export class Nominations extends React.PureComponent { if (!confirm(message)) return; this.xhr.delete = $.ajax( - route('beatmapsets.destroy', { beatmapset: this.props.beatmapset.id }), + route('beatmapsets.destroy', { beatmapset: this.beatmapset.id }), { method: 'DELETE' }, ) - .done(() => Turbolinks.visit(route('users.show', { user: this.props.beatmapset.user_id }))) + .done(() => Turbolinks.visit(route('users.show', { user: this.beatmapset.user_id }))) .fail(onError) .always(action(() => { this.xhr.delete = undefined; @@ -161,14 +169,14 @@ export class Nominations extends React.PureComponent { if (reason == null) return; this.xhr.discussionLock = $.ajax( - route('beatmapsets.discussion-lock', { beatmapset: this.props.beatmapset.id }), + route('beatmapsets.discussion-lock', { beatmapset: this.beatmapset.id }), { data: { reason }, method: 'POST' }, ); this.xhr.discussionLock - .done((beatmapset) => { - $.publish('beatmapsetDiscussions:update', { beatmapset }); - }) + .done((beatmapset) => runInAction(() => { + this.props.discussionsState.beatmapset = beatmapset; + })) .fail(onError) .always(action(() => { this.xhr.discussionLock = undefined; @@ -182,26 +190,25 @@ export class Nominations extends React.PureComponent { if (!confirm(trans('beatmaps.discussions.lock.prompt.unlock'))) return; this.xhr.discussionLock = $.ajax( - route('beatmapsets.discussion-unlock', { beatmapset: this.props.beatmapset.id }), + route('beatmapsets.discussion-unlock', { beatmapset: this.beatmapset.id }), { method: 'POST' }, ); this.xhr.discussionLock - .done((beatmapset) => { - $.publish('beatmapsetDiscussions:update', { beatmapset }); - }) + .done((beatmapset) => runInAction(() => { + this.props.discussionsState.beatmapset = beatmapset; + })) .fail(onError) .always(action(() => { this.xhr.discussionLock = undefined; })); }; + @action private readonly focusHypeInput = () => { // switch to generalAll tab, set current filter to praises - $.publish('beatmapsetDiscussions:update', { - filter: 'praises', - mode: 'generalAll', - }); + this.props.discussionsState.changeFilter('praises'); + this.props.discussionsState.changeDiscussionPage('generalAll'); this.hypeFocusTimeout = window.setTimeout(() => { this.focusNewDiscussion(() => { @@ -215,7 +222,7 @@ export class Nominations extends React.PureComponent { }, 0); }; - private focusNewDiscussion(this: void, callback: () => void) { + private focusNewDiscussion(this: void, callback?: () => void) { const inputBox = $('.js-hype--input'); inputBox.trigger('focus'); @@ -227,14 +234,14 @@ export class Nominations extends React.PureComponent { }); } + @action private focusNewDiscussionWithModeSwitch = () => { // Switch to generalAll tab just in case currently in event tab // and thus new discussion box isn't visible. - $.publish('beatmapsetDiscussions:update', { - callback: this.focusNewDiscussion, - mode: 'generalAll', - modeIf: 'events', - }); + if (this.props.discussionsState.currentMode === 'events') { + this.props.discussionsState.changeDiscussionPage('generalAll'); + this.focusNewDiscussion(); + } }; @action @@ -248,9 +255,9 @@ export class Nominations extends React.PureComponent { }; private parseEventData(event: BeatmapsetEventJson) { - const user = event.user_id != null ? this.props.users[event.user_id] : null; + const user = event.user_id != null ? this.users[event.user_id] : null; const discussionId = discussionIdFromEvent(event); - const discussion = discussionId != null ? this.props.discussions[discussionId] : null; + const discussion = this.discussions.get(discussionId); const post = discussion?.posts[0]; let link: React.ReactNode; @@ -277,14 +284,14 @@ export class Nominations extends React.PureComponent { if (reason == null) return; this.xhr.removeFromLoved = $.ajax( - route('beatmapsets.remove-from-loved', { beatmapset: this.props.beatmapset.id }), + route('beatmapsets.remove-from-loved', { beatmapset: this.beatmapset.id }), { data: { reason }, method: 'DELETE' }, ); this.xhr.removeFromLoved - .done((beatmapset) => - $.publish('beatmapsetDiscussions:update', { beatmapset }), - ) + .done((beatmapset) => runInAction(() => { + this.props.discussionsState.beatmapset = beatmapset; + })) .fail(onError) .always(action(() => { this.xhr.removeFromLoved = undefined; @@ -297,16 +304,16 @@ export class Nominations extends React.PureComponent { return ( ); } private renderBeatmapsOwnerEditorButton() { - if (!this.props.beatmapset.current_user_attributes.can_beatmap_update_owner) return; + if (!this.beatmapset.current_user_attributes.can_beatmap_update_owner) return; return ( { } private renderDeleteButton() { - if (!this.props.beatmapset.current_user_attributes.can_delete) return; + if (!this.beatmapset.current_user_attributes.can_delete) return; return ( { private renderDiscussionLockButton() { if (!canModeratePosts()) return; - const { buttonProps, lockAction } = this.props.beatmapset.discussion_locked + const { buttonProps, lockAction } = this.beatmapset.discussion_locked ? { buttonProps: { icon: 'fas fa-unlock', @@ -368,10 +375,10 @@ export class Nominations extends React.PureComponent { } private renderDiscussionLockMessage() { - if (!this.props.beatmapset.discussion_locked) return; + if (!this.beatmapset.discussion_locked) return; - for (let i = this.props.events.length - 1; i >= 0; i--) { - const event = this.props.events[i]; + for (let i = this.events.length - 1; i >= 0; i--) { + const event = this.events[i]; if (event.type === 'discussion_lock') { return trans('beatmapset_events.event.discussion_lock', { text: event.comment.reason }); } @@ -379,8 +386,8 @@ export class Nominations extends React.PureComponent { } private renderDisqualificationMessage() { - const showHype = this.props.beatmapset.can_be_hyped; - const disqualification = this.props.beatmapset.nominations.disqualification; + const showHype = this.beatmapset.can_be_hyped; + const disqualification = this.beatmapset.nominations.disqualification; if (!showHype || this.isQualified || disqualification == null) return; @@ -403,7 +410,7 @@ export class Nominations extends React.PureComponent { } private renderFeedbackButton() { - if (core.currentUser == null || this.userIsOwner || this.props.beatmapset.can_be_hyped || this.props.beatmapset.discussion_locked) { + if (core.currentUser == null || this.userIsOwner || this.beatmapset.can_be_hyped || this.beatmapset.discussion_locked) { return null; } @@ -419,10 +426,10 @@ export class Nominations extends React.PureComponent { } private renderHypeBar() { - if (!this.props.beatmapset.can_be_hyped || this.props.beatmapset.hype == null) return; + if (!this.beatmapset.can_be_hyped || this.beatmapset.hype == null) return; - const requiredHype = this.props.beatmapset.hype.required; - const hype = this.props.currentDiscussions.totalHype; + const requiredHype = this.beatmapset.hype.required; + const hype = this.props.discussionsState.totalHype; return (
@@ -438,21 +445,17 @@ export class Nominations extends React.PureComponent { } private renderHypeButton() { - if (!this.props.beatmapset.can_be_hyped || core.currentUser == null || this.userIsOwner) return; - - const currentUser = core.currentUser; // core.currentUser check below doesn't make the inferrence that it's not nullable after the check. - const discussions = Object.values(this.props.currentDiscussions.byFilter.hype.generalAll); - const userAlreadyHyped = currentUser != null && discussions.some((discussion) => discussion?.user_id === currentUser.id); + if (!this.beatmapset.can_be_hyped || core.currentUser == null || this.userIsOwner) return; return ( ); } @@ -460,7 +463,7 @@ export class Nominations extends React.PureComponent { private renderLightsForNominations(nominations?: BeatmapsetNominationsInterface) { if (nominations == null) return; - const hybrid = Object.keys(this.props.beatmapset.nominations.required).length > 1; + const hybrid = Object.keys(this.beatmapset.nominations.required).length > 1; return (
@@ -485,7 +488,7 @@ export class Nominations extends React.PureComponent { return ( @@ -493,7 +496,7 @@ export class Nominations extends React.PureComponent { } private renderLoveButton() { - if (!this.props.beatmapset.current_user_attributes.can_love) return; + if (!this.beatmapset.current_user_attributes.can_love) return; return ( { } private renderNominationBar() { - const requiredHype = this.props.beatmapset.hype?.required ?? 0; // TODO: skip if null? - const hypeRaw = this.props.currentDiscussions.totalHype; - const mapCanBeNominated = this.props.beatmapset.status === 'pending' && hypeRaw >= requiredHype; + const requiredHype = this.beatmapset.hype?.required ?? 0; // TODO: skip if null? + const hypeRaw = this.props.discussionsState.totalHype; + const mapCanBeNominated = this.beatmapset.status === 'pending' && hypeRaw >= requiredHype; if (!(mapCanBeNominated || this.isQualified)) return; - const nominations = this.props.beatmapset.nominations; + const nominations = this.beatmapset.nominations; return (
@@ -528,25 +531,25 @@ export class Nominations extends React.PureComponent { } private renderNominationResetMessage() { - const nominationReset = this.props.beatmapset.nominations.nomination_reset; + const nominationReset = this.beatmapset.nominations.nomination_reset; - if (!this.props.beatmapset.can_be_hyped || this.isQualified || nominationReset == null) return; + if (!this.beatmapset.can_be_hyped || this.isQualified || nominationReset == null) return; return
{this.renderResetReason(nominationReset)}
; } private renderNominatorsList() { - if (!nominatorsVisibleBeatmapStatuses.has(this.props.beatmapset.status)) return; + if (!nominatorsVisibleBeatmapStatuses.has(this.beatmapset.status)) return; const nominators: UserJson[] = []; - for (let i = this.props.events.length - 1; i >= 0; i--) { - const event = this.props.events[i]; + for (let i = this.events.length - 1; i >= 0; i--) { + const event = this.events[i]; if (event.type === 'disqualify' || event.type === 'nomination_reset') { break; } if (event.type === 'nominate' && event.user_id != null) { - const user = this.props.users[event.user_id]; // for typing + const user = this.users[event.user_id]; // for typing if (user != null) { nominators.unshift(user); } @@ -568,7 +571,7 @@ export class Nominations extends React.PureComponent { } private renderRemoveFromLovedButton() { - if (!this.props.beatmapset.current_user_attributes.can_remove_from_loved) return; + if (!this.beatmapset.current_user_attributes.can_remove_from_loved) return; return ( { } private renderStatusMessage() { - switch (this.props.beatmapset.status) { + switch (this.beatmapset.status) { case 'approved': case 'loved': case 'ranked': - return trans(`beatmaps.discussions.status-messages.${this.props.beatmapset.status}`, { date: formatDate(this.props.beatmapset.ranked_date) }); + return trans(`beatmaps.discussions.status-messages.${this.beatmapset.status}`, { date: formatDate(this.beatmapset.ranked_date) }); case 'graveyard': - return trans('beatmaps.discussions.status-messages.graveyard', { date: formatDate(this.props.beatmapset.last_updated) }); + return trans('beatmaps.discussions.status-messages.graveyard', { date: formatDate(this.beatmapset.last_updated) }); case 'wip': return trans('beatmaps.discussions.status-messages.wip'); case 'qualified': { - const rankingEta = this.props.beatmapset.nominations.ranking_eta; + const rankingEta = this.beatmapset.nominations.ranking_eta; const date = rankingEta != null // TODO: remove after translations are updated ? transExists('beatmaps.nominations.rank_estimate.on') @@ -639,7 +642,7 @@ export class Nominations extends React.PureComponent { mappings={{ date, // TODO: ranking_queue_position should not be nullable when status is qualified. - position: formatNumber(this.props.beatmapset.nominations.ranking_queue_position ?? 0), + position: formatNumber(this.beatmapset.nominations.ranking_queue_position ?? 0), queue: ( Date: Fri, 7 Jul 2023 17:30:22 +0900 Subject: [PATCH 019/203] ReviewPost and ReviewPostEmbed use DiscussionsState --- .../js/beatmap-discussions/review-post-embed.tsx | 16 ++++++++-------- resources/js/beatmap-discussions/review-post.tsx | 4 +++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/resources/js/beatmap-discussions/review-post-embed.tsx b/resources/js/beatmap-discussions/review-post-embed.tsx index fd298fdb443..7f147e35935 100644 --- a/resources/js/beatmap-discussions/review-post-embed.tsx +++ b/resources/js/beatmap-discussions/review-post-embed.tsx @@ -8,14 +8,14 @@ import * as React from 'react'; import { formatTimestamp, makeUrl, startingPost } from 'utils/beatmapset-discussion-helper'; import { classWithModifiers } from 'utils/css'; import { trans } from 'utils/lang'; -import { BeatmapsContext } from './beatmaps-context'; import DiscussionMessage from './discussion-message'; -import { DiscussionsContext } from './discussions-context'; +import DiscussionsState from './discussions-state'; interface Props { data: { discussion_id: number; }; + discussionsState: DiscussionsState; } export function postEmbedModifiers(discussion: BeatmapsetDiscussionJson) { @@ -27,13 +27,13 @@ export function postEmbedModifiers(discussion: BeatmapsetDiscussionJson) { }; } -export const ReviewPostEmbed = ({ data }: Props) => { - const bn = 'beatmap-discussion-review-post-embed-preview'; - const discussions = React.useContext(DiscussionsContext); - const beatmaps = React.useContext(BeatmapsContext); - const discussion = discussions[data.discussion_id]; +const bn = 'beatmap-discussion-review-post-embed-preview'; - if (!discussion) { +export const ReviewPostEmbed = ({ data, discussionsState }: Props) => { + const beatmaps = discussionsState.beatmaps; + const discussion = discussionsState.discussions.get(data.discussion_id); + + if (discussion == null) { // if a discussion has been deleted or is otherwise missing return (
diff --git a/resources/js/beatmap-discussions/review-post.tsx b/resources/js/beatmap-discussions/review-post.tsx index 5335fd94ffa..c5280718d61 100644 --- a/resources/js/beatmap-discussions/review-post.tsx +++ b/resources/js/beatmap-discussions/review-post.tsx @@ -5,9 +5,11 @@ import { PersistedBeatmapDiscussionReview } from 'interfaces/beatmap-discussion- import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json'; import * as React from 'react'; import DiscussionMessage from './discussion-message'; +import DiscussionsState from './discussions-state'; import { ReviewPostEmbed } from './review-post-embed'; interface Props { + discussionsState: DiscussionsState; post: BeatmapsetDiscussionMessagePostJson; } @@ -27,7 +29,7 @@ export class ReviewPost extends React.Component { } case 'embed': if (block.discussion_id) { - docBlocks.push(); + docBlocks.push(); } break; } From 14f82a316fcc6f4b6b3ec386447f199ea5fe3884 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 7 Jul 2023 17:30:44 +0900 Subject: [PATCH 020/203] UserFilter use DiscussionsState --- .../js/beatmap-discussions/user-filter.tsx | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/resources/js/beatmap-discussions/user-filter.tsx b/resources/js/beatmap-discussions/user-filter.tsx index 8922d35b5ca..4be21c15e2a 100644 --- a/resources/js/beatmap-discussions/user-filter.tsx +++ b/resources/js/beatmap-discussions/user-filter.tsx @@ -4,10 +4,13 @@ import mapperGroup from 'beatmap-discussions/mapper-group'; import SelectOptions, { OptionRenderProps } from 'components/select-options'; import UserJson from 'interfaces/user-json'; +import { action } from 'mobx'; +import { observer } from 'mobx-react'; import * as React from 'react'; import { makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper'; import { groupColour } from 'utils/css'; import { trans } from 'utils/lang'; +import DiscussionsState from './discussions-state'; const allUsers = Object.freeze({ id: null, @@ -26,9 +29,7 @@ interface Option { } interface Props { - ownerId: number; - selectedUser?: UserJson | null; - users: UserJson[]; + discussionsState: DiscussionsState; } function mapUserProperties(user: UserJson): Option { @@ -39,15 +40,20 @@ function mapUserProperties(user: UserJson): Option { }; } +@observer export class UserFilter extends React.Component { + private get ownerId() { + return this.props.discussionsState.beatmapset.user_id; + } + private get selected() { - return this.props.selectedUser != null - ? mapUserProperties(this.props.selectedUser) + return this.props.discussionsState.selectedUser != null + ? mapUserProperties(this.props.discussionsState.selectedUser) : noSelection; } private get options() { - return [allUsers, ...this.props.users.map(mapUserProperties)]; + return [allUsers, ...Object.values(this.props.discussionsState.users).map(mapUserProperties)]; } render() { @@ -62,15 +68,19 @@ export class UserFilter extends React.Component { ); } + @action private readonly handleChange = (option: Option) => { - $.publish('beatmapsetDiscussions:update', { selectedUserId: option.id }); + this.props.discussionsState.selectedUserId = option.id; }; private isOwner(user?: Option) { - return user != null && user.id === this.props.ownerId; + return user != null && user.id === this.ownerId; } private readonly renderOption = ({ cssClasses, children, onClick, option }: OptionRenderProps Date: Fri, 7 Jul 2023 17:33:28 +0900 Subject: [PATCH 021/203] ModeSwitcher uses DiscussionsState --- .../js/beatmap-discussions/mode-switcher.tsx | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/resources/js/beatmap-discussions/mode-switcher.tsx b/resources/js/beatmap-discussions/mode-switcher.tsx index 206c5db1fa8..dbb1af3dc18 100644 --- a/resources/js/beatmap-discussions/mode-switcher.tsx +++ b/resources/js/beatmap-discussions/mode-switcher.tsx @@ -2,30 +2,39 @@ // See the LICENCE file in the repository root for full licence text. import StringWithComponent from 'components/string-with-component'; -import BeatmapJson from 'interfaces/beatmap-json'; -import BeatmapsetJson from 'interfaces/beatmapset-json'; -import { snakeCase, size } from 'lodash'; +import { snakeCase } from 'lodash'; +import { action } from 'mobx'; +import { observer } from 'mobx-react'; import * as React from 'react'; import { makeUrl } from 'utils/beatmapset-discussion-helper'; import { classWithModifiers } from 'utils/css'; import { trans } from 'utils/lang'; -import CurrentDiscussions, { Filter } from './current-discussions'; import { DiscussionPage, discussionPages } from './discussion-mode'; +import DiscussionsState from './discussions-state'; interface Props { - beatmapset: BeatmapsetJson; - currentBeatmap: BeatmapJson; - currentDiscussions: CurrentDiscussions; - currentFilter: Filter; + discussionsState: DiscussionsState; innerRef: React.RefObject; - mode: DiscussionPage; } const selectedClassName = 'page-mode-link--is-active'; -export class ModeSwitcher extends React.PureComponent { +@observer +export class ModeSwitcher extends React.Component { private scrollerRef = React.createRef(); + private get beatmapset() { + return this.props.discussionsState.beatmapset; + } + + private get currentBeatmap() { + return this.props.discussionsState.currentBeatmap; + } + + private get currentMode() { + return this.props.discussionsState.currentMode; + } + componentDidMount() { this.scrollModeSwitcher(); } @@ -52,11 +61,11 @@ export class ModeSwitcher extends React.PureComponent { private renderMode = (mode: DiscussionPage) => (
  • { {this.renderModeText(mode)} {mode !== 'events' && ( - {size(this.props.currentDiscussions.byFilter[this.props.currentFilter][mode])} + {this.props.discussionsState.currentDiscussions[mode].length} )} @@ -77,7 +86,7 @@ export class ModeSwitcher extends React.PureComponent { private renderModeText(mode: DiscussionPage) { if (mode === 'general' || mode === 'generalAll') { const text = mode === 'general' - ? this.props.currentBeatmap.version + ? this.currentBeatmap.version : trans('beatmaps.discussions.mode.scopes.generalAll'); return ( @@ -100,9 +109,10 @@ export class ModeSwitcher extends React.PureComponent { $(this.scrollerRef.current).scrollTo(`.${selectedClassName}`, 0, { over: { left: -1 } }); } + @action private readonly switch = (e: React.SyntheticEvent) => { e.preventDefault(); - $.publish('beatmapsetDiscussions:update', { mode: e.currentTarget.dataset.mode }); + this.props.discussionsState.changeDiscussionPage(e.currentTarget.dataset.mode); }; } From 264227af0c2c8fa048fdd253c7871a7f019847e8 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 7 Jul 2023 17:35:19 +0900 Subject: [PATCH 022/203] Header uses DiscussionsState --- resources/js/beatmap-discussions/header.tsx | 160 +++++++++++--------- 1 file changed, 89 insertions(+), 71 deletions(-) diff --git a/resources/js/beatmap-discussions/header.tsx b/resources/js/beatmap-discussions/header.tsx index fa8d84190c9..dae478a8fc1 100644 --- a/resources/js/beatmap-discussions/header.tsx +++ b/resources/js/beatmap-discussions/header.tsx @@ -13,13 +13,11 @@ import StringWithComponent from 'components/string-with-component'; import UserLink from 'components/user-link'; import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; import BeatmapJson from 'interfaces/beatmap-json'; -import { BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; -import BeatmapsetEventJson from 'interfaces/beatmapset-event-json'; -import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import GameMode, { gameModes } from 'interfaces/game-mode'; -import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; -import { kebabCase, size, snakeCase } from 'lodash'; +import { kebabCase, snakeCase } from 'lodash'; +import { action, computed, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; import { deletedUser } from 'models/user'; import core from 'osu-core-singleton'; import * as React from 'react'; @@ -29,40 +27,71 @@ import { classWithModifiers } from 'utils/css'; import { trans } from 'utils/lang'; import BeatmapList from './beatmap-list'; import Chart from './chart'; -import CurrentDiscussions, { Filter } from './current-discussions'; -import { DiscussionPage } from './discussion-mode'; +import { Filter } from './current-discussions'; +import DiscussionsState from './discussions-state'; import { Nominations } from './nominations'; import { Subscribe } from './subscribe'; import { UserFilter } from './user-filter'; interface Props { - beatmaps: Map; - beatmapset: BeatmapsetWithDiscussionsJson; - currentBeatmap: BeatmapExtendedJson; - currentDiscussions: CurrentDiscussions; - currentFilter: Filter; - discussions: Partial>; - discussionStarters: UserJson[]; - events: BeatmapsetEventJson[]; - mode: DiscussionPage; - selectedUserId: number | null; - users: Partial>; + discussionsState: DiscussionsState; } const statTypes: Filter[] = ['mine', 'mapperNotes', 'resolved', 'pending', 'praises', 'deleted', 'total']; -export class Header extends React.PureComponent { +@observer +export class Header extends React.Component { + private get beatmaps() { + return this.discussionsState.groupedBeatmaps; + } + + private get beatmapset() { + return this.discussionsState.beatmapset; + } + + private get currentBeatmap() { + return this.discussionsState.currentBeatmap; + } + + @computed + private get discussionCounts() { + const counts: Partial> = observable({}); + for (const type of statTypes) { + counts[type] = this.discussionsState.currentDiscussionsGroupedByFilter[type].length; + } + + return counts; + } + + private get discussionsState() { + return this.props.discussionsState; + } + + @computed + private get timelineDiscussions() { + return this.discussionsState.currentDiscussions.timeline; + } + + private get users() { + return this.discussionsState.users; + } + + constructor(props: Props) { + super(props); + makeObservable(this); + } + render() { return ( <> ({ - count: this.props.currentDiscussions.countsByPlaymode[mode], - disabled: (this.props.beatmaps.get(mode)?.length ?? 0) === 0, + count: this.discussionsState.discussionsCountByPlaymode[mode], + disabled: (this.discussionsState.groupedBeatmaps.get(mode)?.length ?? 0) === 0, mode, }))} modifiers='beatmapset' @@ -79,21 +108,19 @@ export class Header extends React.PureComponent { private readonly createLink = (beatmap: BeatmapJson) => makeUrl({ beatmap }); - private readonly getCount = (beatmap: BeatmapExtendedJson) => - beatmap.deleted_at == null - ? this.props.currentDiscussions.countsByBeatmap[beatmap.id] - : undefined; + // TODO: does it need to be computed? + private readonly getCount = (beatmap: BeatmapExtendedJson) => beatmap.deleted_at == null ? this.discussionsState.discussionsByBeatmap(beatmap.id).length : undefined; - private readonly onClickMode = (event: React.MouseEvent, mode: GameMode) => { + @action + private onClickMode = (event: React.MouseEvent, mode: GameMode) => { event.preventDefault(); - $.publish('playmode:set', [{ mode }]); + this.discussionsState.changeGameMode(mode); }; - private readonly onSelectBeatmap = (beatmapId: number) => { - $.publish('beatmapsetDiscussions:update', { - beatmapId, - mode: 'timeline', - }); + @action + private onSelectBeatmap = (beatmapId: number) => { + this.discussionsState.currentBeatmapId = beatmapId; + this.discussionsState.changeDiscussionPage('timeline'); }; private renderHeaderBottom() { @@ -104,16 +131,16 @@ export class Header extends React.PureComponent {
    - +
    {
    @@ -142,7 +165,7 @@ export class Header extends React.PureComponent {
    {statTypes.map(this.renderType)} @@ -188,23 +209,23 @@ export class Header extends React.PureComponent {
    - {this.props.currentBeatmap.user_id !== this.props.beatmapset.user_id && ( + {this.currentBeatmap.user_id !== this.beatmapset.user_id && ( , + user: , }} pattern={trans('beatmaps.discussions.guest')} /> )}
    - +
    @@ -220,23 +241,20 @@ export class Header extends React.PureComponent { const bn = 'counter-box'; let topClasses = classWithModifiers(bn, 'beatmap-discussions', kebabCase(type)); - if (this.props.mode !== 'events' && this.props.currentFilter === type) { + if (this.discussionsState.currentMode !== 'events' && this.discussionsState.currentFilter === type) { topClasses += ' js-active'; } - const discussionsByFilter = this.props.currentDiscussions.byFilter[type]; - const total = Object.values(discussionsByFilter).reduce((acc, discussions) => acc + size(discussions), 0); - return ( @@ -245,7 +263,7 @@ export class Header extends React.PureComponent { {trans(`beatmaps.discussions.stats.${snakeCase(type)}`)}
    - {total} + {this.discussionCounts[type]}
    @@ -255,6 +273,6 @@ export class Header extends React.PureComponent { private readonly setFilter = (event: React.SyntheticEvent) => { event.preventDefault(); - $.publish('beatmapsetDiscussions:update', { filter: event.currentTarget.dataset.type }); + this.discussionsState.changeFilter(event.currentTarget.dataset.type); }; } From 326d55dcf2da7e544d29eed9a29bde16aa770d80 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 7 Jul 2023 17:35:35 +0900 Subject: [PATCH 023/203] Post uses DiscussionsState --- resources/js/beatmap-discussions/post.tsx | 69 +++++++++++------------ 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/resources/js/beatmap-discussions/post.tsx b/resources/js/beatmap-discussions/post.tsx index 12cfc65ddf6..1a7a6957888 100644 --- a/resources/js/beatmap-discussions/post.tsx +++ b/resources/js/beatmap-discussions/post.tsx @@ -1,8 +1,6 @@ // 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 { BeatmapsContext } from 'beatmap-discussions/beatmaps-context'; -import { DiscussionsContext } from 'beatmap-discussions/discussions-context'; import Editor from 'beatmap-discussions/editor'; import { ReviewPost } from 'beatmap-discussions/review-post'; import BigButton from 'components/big-button'; @@ -11,11 +9,8 @@ import { ReportReportable } from 'components/report-reportable'; import StringWithComponent from 'components/string-with-component'; import TimeWithTooltip from 'components/time-with-tooltip'; import UserLink from 'components/user-link'; -import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json'; -import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; -import BeatmapsetJson from 'interfaces/beatmapset-json'; import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; @@ -34,21 +29,20 @@ import { InputEventType, makeTextAreaHandler } from 'utils/input-handler'; import { trans } from 'utils/lang'; import DiscussionMessage from './discussion-message'; import DiscussionMessageLengthCounter from './discussion-message-length-counter'; +import DiscussionsState from './discussions-state'; import { UserCard } from './user-card'; const bn = 'beatmap-discussion-post'; interface Props { - beatmap: BeatmapExtendedJson | null; - beatmapset: BeatmapsetJson | BeatmapsetExtendedJson; discussion: BeatmapsetDiscussionJson; + discussionsState: DiscussionsState; post: BeatmapsetDiscussionMessagePostJson; read: boolean; readonly: boolean; resolvedSystemPostId: number; type: string; user: UserJson; - users: Partial>; } @observer @@ -63,18 +57,30 @@ export default class Post extends React.Component { private readonly textareaRef = React.createRef(); @observable private xhr: JQuery.jqXHR | null = null; + private get beatmap() { + return this.props.discussionsState.currentBeatmap; + } + + private get beatmapset() { + return this.props.discussionsState.beatmapset; + } + + private get users() { + return this.props.discussionsState.users; + } + @computed private get canEdit() { // no information available (non-discussion pages), return false. - if (!('discussion_locked' in this.props.beatmapset)) { + if (!('discussion_locked' in this.beatmapset)) { return false; } return this.isAdmin - || (!downloadLimited(this.props.beatmapset) + || (!downloadLimited(this.beatmapset) && this.isOwn && this.props.post.id > this.props.resolvedSystemPostId - && !this.props.beatmapset.discussion_locked + && !this.beatmapset.discussion_locked ); } @@ -157,8 +163,8 @@ export default class Post extends React.Component { {this.props.type === 'reply' && ( { }; private readonly handleMarkRead = () => { - $.publish('beatmapDiscussionPost:markRead', { id: this.props.post.id }); + this.props.discussionsState.markAsRead(this.props.post.id); }; @action @@ -219,7 +225,7 @@ export default class Post extends React.Component { if (this.deleteModel.deleted_at == null) return null; const user = ( this.deleteModel.deleted_by_id != null - ? this.props.users[this.deleteModel.deleted_by_id] + ? this.users[this.deleteModel.deleted_by_id] : null ) ?? deletedUser; @@ -247,7 +253,7 @@ export default class Post extends React.Component { return null; } - const lastEditor = this.props.users[this.props.post.last_editor_id] ?? deletedUserJson; + const lastEditor = this.users[this.props.post.last_editor_id] ?? deletedUserJson; return ( @@ -285,25 +291,14 @@ export default class Post extends React.Component { return (
    {this.isReview ? ( - - {(discussions) => ( - - {(beatmaps) => ( - - )} - - )} - + ) : ( <> {
    {this.isReview ? (
    - +
    ) : (
    @@ -481,7 +476,7 @@ export default class Post extends React.Component { this.xhr.done((beatmapset) => runInAction(() => { this.editing = false; - $.publish('beatmapsetDiscussions:update', { beatmapset }); + this.props.discussionsState.update({ beatmapset }); })) .fail(onError) .always(action(() => this.xhr = null)); From 14ce2d96c1603855a1f2f3e54cea674eca4dc927 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 7 Jul 2023 17:35:57 +0900 Subject: [PATCH 024/203] Discussion uses DiscussionsState --- .../js/beatmap-discussions/discussion.tsx | 80 +++++++++++-------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/resources/js/beatmap-discussions/discussion.tsx b/resources/js/beatmap-discussions/discussion.tsx index 20e83aade0a..e2456e399c2 100644 --- a/resources/js/beatmap-discussions/discussion.tsx +++ b/resources/js/beatmap-discussions/discussion.tsx @@ -1,11 +1,8 @@ // 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 BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; import BeatmapsetDiscussionJson, { BeatmapsetDiscussionJsonForBundle, BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; import BeatmapsetDiscussionPostJson from 'interfaces/beatmapset-discussion-post-json'; -import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; -import UserJson from 'interfaces/user-json'; import { findLast } from 'lodash'; import { action, computed, makeObservable } from 'mobx'; import { observer } from 'mobx-react'; @@ -18,7 +15,7 @@ import { classWithModifiers, groupColour } from 'utils/css'; import { trans } from 'utils/lang'; import { DiscussionType, discussionTypeIcons } from './discussion-type'; import DiscussionVoteButtons from './discussion-vote-buttons'; -import DiscussionsStateContext from './discussions-state-context'; +import DiscussionsState from './discussions-state'; import { NewReply } from './new-reply'; import Post from './post'; import SystemPost from './system-post'; @@ -27,14 +24,10 @@ import { UserCard } from './user-card'; const bn = 'beatmap-discussion'; interface PropsBase { - beatmapset: BeatmapsetExtendedJson; - currentBeatmap: BeatmapExtendedJson | null; + discussionsState: DiscussionsState; isTimelineVisible: boolean; parentDiscussion?: BeatmapsetDiscussionJson | null; readonly: boolean; - readPostIds?: Set; - showDeleted: boolean; - users: Partial>; } // preview version is used on pages other than the main discussions page. @@ -64,35 +57,44 @@ function DiscussionTypeIcon({ type }: { type: DiscussionType | 'resolved' }) { @observer export class Discussion extends React.Component { - static contextType = DiscussionsStateContext; static defaultProps = { preview: false, readonly: false, }; - declare context: React.ContextType; private lastResolvedState = false; - constructor(props: Props) { - super(props); - makeObservable(this); + private get beatmapset() { + return this.props.discussionsState.beatmapset; + } + + private get currentBeatmap() { + return this.props.discussionsState.currentBeatmap; } @computed private get canBeRepliedTo() { - return !downloadLimited(this.props.beatmapset) - && (!this.props.beatmapset.discussion_locked || canModeratePosts()) - && (this.props.discussion.beatmap_id == null || this.props.currentBeatmap?.deleted_at == null); + return !downloadLimited(this.beatmapset) + && (!this.beatmapset.discussion_locked || canModeratePosts()) + && (this.props.discussion.beatmap_id == null || this.currentBeatmap?.deleted_at == null); } @computed private get collapsed() { - return this.context.discussionCollapsed.get(this.props.discussion.id) ?? this.context.discussionDefaultCollapsed; + return this.discussionsState.discussionCollapsed.get(this.props.discussion.id) ?? this.discussionsState.discussionDefaultCollapsed; + } + + private get discussionsState() { + return this.props.discussionsState; } @computed private get highlighted() { - return this.context.highlightedDiscussionId === this.props.discussion.id; + return this.discussionsState.highlightedDiscussionId === this.props.discussion.id; + } + + private get readPostIds() { + return this.props.discussionsState.readPostIds; } @computed @@ -104,6 +106,19 @@ export class Discussion extends React.Component { return systemPost?.id ?? -1; } + private get showDeleted() { + return this.props.discussionsState.showDeleted; + } + + private get users() { + return this.discussionsState.users; + } + + constructor(props: Props) { + super(props); + makeObservable(this); + } + render() { if (!this.isVisible(this.props.discussion)) return null; const firstPost = startingPost(this.props.discussion); @@ -114,10 +129,10 @@ export class Discussion extends React.Component { this.lastResolvedState = false; - const user = this.props.users[this.props.discussion.user_id] ?? deletedUserJson; + const user = this.users[this.props.discussion.user_id] ?? deletedUserJson; const group = badgeGroup({ - beatmapset: this.props.beatmapset, - currentBeatmap: this.props.currentBeatmap, + beatmapset: this.beatmapset, + currentBeatmap: this.currentBeatmap, discussion: this.props.discussion, user, }); @@ -167,13 +182,13 @@ export class Discussion extends React.Component { @action private readonly handleCollapseClick = () => { - this.context.discussionCollapsed.set(this.props.discussion.id, !this.collapsed); + this.discussionsState.discussionCollapsed.set(this.props.discussion.id, !this.collapsed); }; @action private readonly handleSetHighlight = (e: React.MouseEvent) => { if (e.defaultPrevented) return; - this.context.highlightedDiscussionId = this.props.discussion.id; + this.discussionsState.highlightedDiscussionId = this.props.discussion.id; }; private isOwner(object: { user_id: number }) { @@ -181,11 +196,11 @@ export class Discussion extends React.Component { } private isRead(post: BeatmapsetDiscussionPostJson) { - return this.props.readPostIds?.has(post.id) || this.isOwner(post) || this.props.preview; + return this.readPostIds?.has(post.id) || this.isOwner(post) || this.props.preview; } private isVisible(object: BeatmapsetDiscussionJson | BeatmapsetDiscussionPostJson) { - return object != null && (this.props.showDeleted || object.deleted_at == null); + return object != null && (this.showDeleted || object.deleted_at == null); } private postFooter() { @@ -203,9 +218,8 @@ export class Discussion extends React.Component {
    {this.canBeRepliedTo && ( )}
    @@ -213,7 +227,7 @@ export class Discussion extends React.Component { } private renderPost(post: BeatmapsetDiscussionPostJson, type: 'discussion' | 'reply') { - const user = this.props.users[post.user_id] ?? deletedUserJson; + const user = this.users[post.user_id] ?? deletedUserJson; if (post.system) { return ( @@ -224,16 +238,14 @@ export class Discussion extends React.Component { return ( ); } @@ -241,7 +253,7 @@ export class Discussion extends React.Component { private renderPostButtons() { if (this.props.preview) return null; - const user = this.props.users[this.props.discussion.user_id]; + const user = this.users[this.props.discussion.user_id]; return (
    @@ -261,7 +273,7 @@ export class Discussion extends React.Component { - )} - - {this.deleteModel.deleted_at == null && this.canDelete && ( - - {trans('beatmaps.discussions.delete')} - - )} - - {this.deleteModel.deleted_at != null && this.canModerate && ( - - {trans('beatmaps.discussions.restore')} - - )} - - {this.props.type === 'discussion' && this.props.discussion.current_user_attributes?.can_moderate_kudosu && ( - this.props.discussion.can_grant_kudosu - ? this.renderKudosuAction('deny') - : this.props.discussion.kudosu_denied && this.renderKudosuAction('allow') - )} - - )} + {this.renderMessageViewerEditingActions()} {this.canReport && ( { ); } + private renderMessageViewerEditingActions() { + if (this.props.readonly || this.props.discussionsState == null) return; + + return ( + <> + {this.canEdit && ( + + )} + + {this.deleteModel.deleted_at == null && this.canDelete && ( + + {trans('beatmaps.discussions.delete')} + + )} + + {this.deleteModel.deleted_at != null && this.canModerate && ( + + {trans('beatmaps.discussions.restore')} + + )} + + {this.props.type === 'discussion' && this.props.discussion.current_user_attributes?.can_moderate_kudosu && ( + this.props.discussion.can_grant_kudosu + ? this.renderKudosuAction('deny') + : this.props.discussion.kudosu_denied && this.renderKudosuAction('allow') + )} + + ); + } @action private readonly updatePost = () => { From 14a1e87dd5708b89ca044f59f3cf87c2089f4f4b Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 21 Jul 2023 18:41:45 +0900 Subject: [PATCH 041/203] more separation of store and discussionsState --- .../beatmap-discussions/discussions-state.ts | 14 ++++---- resources/js/beatmap-discussions/main.tsx | 34 +++++++++++++------ .../js/beatmap-discussions/mode-switcher.tsx | 2 -- .../js/beatmap-discussions/nominations.tsx | 1 + .../js/beatmap-discussions/nominator.tsx | 4 ++- 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index ea6375b25bb..bc66e891892 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -7,7 +7,6 @@ import GameMode from 'interfaces/game-mode'; import { maxBy } from 'lodash'; import { action, computed, makeObservable, observable, toJS } from 'mobx'; import BeatmapsetDiscussions from 'models/beatmapset-discussions'; -import BeatmapsetDiscussionsStore from 'models/beatmapset-discussions-store'; import moment from 'moment'; import core from 'osu-core-singleton'; import { findDefault, group, sortWithMode } from 'utils/beatmap-helper'; @@ -76,8 +75,6 @@ export default class DiscussionsState { @observable selectedUserId: number | null = null; @observable showDeleted = true; - @observable store: BeatmapsetDiscussions; - private previousFilter: Filter = 'total'; private previousPage: DiscussionPage = 'general'; @@ -242,7 +239,7 @@ export default class DiscussionsState { return this.presentDiscussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved); } - constructor(public beatmapset: BeatmapsetWithDiscussionsJson, state?: string) { + constructor(public beatmapset: BeatmapsetWithDiscussionsJson, private store: BeatmapsetDiscussions, state?: string) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const existingState = state == null ? null : parseState(state); @@ -259,8 +256,6 @@ export default class DiscussionsState { } } - this.store = new BeatmapsetDiscussionsStore(beatmapset); - this.currentBeatmapId = (findDefault({ group: this.groupedBeatmaps }) ?? this.firstBeatmap).id; // Current url takes priority over saved state. @@ -336,7 +331,12 @@ export default class DiscussionsState { } toJsonString() { - return JSON.stringify(toJS(this), (_key, value) => { + return JSON.stringify(toJS(this), (key, value) => { + // don't serialize constructor dependencies, they'll be handled separately. + if (key === 'beatmapset' || key === 'store') { + return undefined; + } + if (value instanceof Set || value instanceof Map) { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return Array.from(value); diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index eb668c51559..fc063cf0148 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -8,6 +8,7 @@ import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussion import { route } from 'laroute'; import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; +import BeatmapsetDiscussionsStore from 'models/beatmapset-discussions-store'; import core from 'osu-core-singleton'; import * as React from 'react'; import { defaultFilter, parseUrl, stateFromDiscussion } from 'utils/beatmapset-discussion-helper'; @@ -35,6 +36,11 @@ interface Props { initial: InitialData; } +function parseJson(json?: string) { + if (json == null) return; + return JSON.parse(json) as T; +} + @observer export default class Main extends React.Component { @observable private readonly discussionsState: DiscussionsState; @@ -46,18 +52,26 @@ export default class Main extends React.Component { private readonly newDiscussionRef = React.createRef(); private nextTimeout = checkNewTimeoutDefault; private reviewsConfig = this.props.initial.reviews_config; + @observable private store; private readonly timeouts: Record = {}; private xhrCheckNew?: JQuery.jqXHR; @computed get discussions() { - return [...this.discussionsState.store.discussions.values()]; + return [...this.store.discussions.values()]; } constructor(props: Props) { super(props); - this.discussionsState = new DiscussionsState(props.initial.beatmapset, props.container.dataset.beatmapsetDiscussionState); + if (this.props.container.dataset.beatmapset != null) { + JSON.parse(this.props.container.dataset.beatmapset); + } + + // using DiscussionsState['beatmapset'] as type cast to force errors if it doesn't match with props since the beatmapset is from discussionsState. + const existingBeatmapset = parseJson(props.container.dataset.beatmapset); + this.store = new BeatmapsetDiscussionsStore(existingBeatmapset ?? this.props.initial.beatmapset); + this.discussionsState = new DiscussionsState(props.initial.beatmapset, this.store, props.container.dataset.discussionsState); makeObservable(this); } @@ -93,18 +107,17 @@ export default class Main extends React.Component { <>
    {this.discussionsState.currentMode === 'events' ? ( ) : ( @@ -113,7 +126,7 @@ export default class Main extends React.Component { discussionsState={this.discussionsState} innerRef={this.newDiscussionRef} stickTo={this.modeSwitcherRef} - store={this.discussionsState.store} + store={this.store} /> ) : ( { )} )} @@ -162,7 +175,7 @@ export default class Main extends React.Component { @action private readonly jumpTo = (_event: unknown, { id, postId }: { id: number; postId?: number }) => { - const discussion = this.discussionsState.store.discussions.get(id); + const discussion = this.store.discussions.get(id); if (discussion == null) return; @@ -224,7 +237,8 @@ export default class Main extends React.Component { }; private readonly saveStateToContainer = () => { - this.props.container.dataset.beatmapsetDiscussionState = this.discussionsState.toJsonString(); + this.props.container.dataset.beatmapset = JSON.stringify(this.discussionsState.beatmapset); + this.props.container.dataset.discussionsState = this.discussionsState.toJsonString(); }; @action diff --git a/resources/js/beatmap-discussions/mode-switcher.tsx b/resources/js/beatmap-discussions/mode-switcher.tsx index 108951d14a8..dbb1af3dc18 100644 --- a/resources/js/beatmap-discussions/mode-switcher.tsx +++ b/resources/js/beatmap-discussions/mode-switcher.tsx @@ -5,7 +5,6 @@ import StringWithComponent from 'components/string-with-component'; import { snakeCase } from 'lodash'; import { action } from 'mobx'; import { observer } from 'mobx-react'; -import BeatmapsetDiscussions from 'models/beatmapset-discussions'; import * as React from 'react'; import { makeUrl } from 'utils/beatmapset-discussion-helper'; import { classWithModifiers } from 'utils/css'; @@ -16,7 +15,6 @@ import DiscussionsState from './discussions-state'; interface Props { discussionsState: DiscussionsState; innerRef: React.RefObject; - store: BeatmapsetDiscussions; } const selectedClassName = 'page-mode-link--is-active'; diff --git a/resources/js/beatmap-discussions/nominations.tsx b/resources/js/beatmap-discussions/nominations.tsx index a69ed167e03..71e1e6c5245 100644 --- a/resources/js/beatmap-discussions/nominations.tsx +++ b/resources/js/beatmap-discussions/nominations.tsx @@ -126,6 +126,7 @@ export class Nominations extends React.Component {
    diff --git a/resources/js/beatmap-discussions/nominator.tsx b/resources/js/beatmap-discussions/nominator.tsx index 5000b8d0ab5..9596463b839 100644 --- a/resources/js/beatmap-discussions/nominator.tsx +++ b/resources/js/beatmap-discussions/nominator.tsx @@ -10,6 +10,7 @@ import { route } from 'laroute'; import { forEachRight, map, uniq } from 'lodash'; import { action, computed, makeObservable, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; +import BeatmapsetDiscussions from 'models/beatmapset-discussions'; import core from 'osu-core-singleton'; import * as React from 'react'; import { onError } from 'utils/ajax'; @@ -20,6 +21,7 @@ import DiscussionsState from './discussions-state'; interface Props { discussionsState: DiscussionsState; + store: BeatmapsetDiscussions; } const bn = 'nomination-dialog'; @@ -76,7 +78,7 @@ export class Nominator extends React.Component { } private get users() { - return this.props.discussionsState.store.users; + return this.props.store.users; } private get userCanNominate() { From 29a3e1395072ee9db379ba42603922b2b536e9f5 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Tue, 25 Jul 2023 16:23:14 +0900 Subject: [PATCH 042/203] missing computeds --- resources/js/beatmap-discussions/discussions-state.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index bc66e891892..26a246d8fa9 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -188,10 +188,12 @@ export default class DiscussionsState { return maxLastUpdate != null ? moment(maxLastUpdate).unix() : null; } + @computed get selectedUser() { return this.store.users.get(this.selectedUserId); } + @computed get sortedBeatmaps() { // TODO // filter to only include beatmaps from the current discussion's beatmapset (for the modding profile page) From cf8a2889ebfac9e68c15bba93338835b0ea6c6ad Mon Sep 17 00:00:00 2001 From: bakaneko Date: Tue, 25 Jul 2023 17:46:20 +0900 Subject: [PATCH 043/203] rename property --- .../beatmap-discussions/discussions-state.ts | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 26a246d8fa9..28a8bdd5391 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -99,18 +99,9 @@ export default class DiscussionsState { return this.currentDiscussions[this.currentMode]; } - @computed - get currentDiscussions() { - const discussions = this.currentDiscussionsGroupedByFilter[this.currentFilter]; - - return { - general: filterDiscusionsByMode(discussions, 'general', this.currentBeatmapId), - generalAll: filterDiscusionsByMode(discussions, 'generalAll'), - reviews: filterDiscusionsByMode(discussions, 'reviews'), - timeline: filterDiscusionsByMode(discussions, 'timeline', this.currentBeatmapId), - }; - } - + /** + * Discussions for the current beatmap grouped by filters + */ @computed get currentDiscussionsGroupedByFilter() { const groups: Record = { @@ -131,6 +122,21 @@ export default class DiscussionsState { return groups; } + /** + * Discussions for the currently selected beatmap and filter grouped by mode. + */ + @computed + get currentDiscussionsGroupedByMode() { + const discussions = this.currentDiscussionsGroupedByFilter[this.currentFilter]; + + return { + general: filterDiscusionsByMode(discussions, 'general', this.currentBeatmapId), + generalAll: filterDiscusionsByMode(discussions, 'generalAll'), + reviews: filterDiscusionsByMode(discussions, 'reviews'), + timeline: filterDiscusionsByMode(discussions, 'timeline', this.currentBeatmapId), + }; + } + @computed get discussionsCountByPlaymode() { const counts: Record = { From b8410d466ab0fa79ad5637eeda3f3584da691730 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Tue, 25 Jul 2023 19:02:18 +0900 Subject: [PATCH 044/203] renaming and fixing some non-functioning things --- .../js/beatmap-discussions/discussion-mode.ts | 1 + .../beatmap-discussions/discussions-state.ts | 48 ++++++++++++------- .../js/beatmap-discussions/discussions.tsx | 18 +++---- resources/js/beatmap-discussions/header.tsx | 4 +- resources/js/beatmap-discussions/main.tsx | 2 +- .../js/beatmap-discussions/mode-switcher.tsx | 2 +- .../js/beatmap-discussions/new-discussion.tsx | 2 +- 7 files changed, 44 insertions(+), 33 deletions(-) diff --git a/resources/js/beatmap-discussions/discussion-mode.ts b/resources/js/beatmap-discussions/discussion-mode.ts index 4ae6f5cc45a..d78b46b10a2 100644 --- a/resources/js/beatmap-discussions/discussion-mode.ts +++ b/resources/js/beatmap-discussions/discussion-mode.ts @@ -12,5 +12,6 @@ export function isDiscussionPage(value: unknown): value is DiscussionPage{ } type DiscussionMode = Exclude; +export const discussionModes: Readonly = ['reviews', 'generalAll', 'general', 'timeline'] as const; export default DiscussionMode; diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 28a8bdd5391..14c33d24220 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -13,7 +13,7 @@ import { findDefault, group, sortWithMode } from 'utils/beatmap-helper'; import { makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper'; import { switchNever } from 'utils/switch-never'; import { Filter, filters } from './current-discussions'; -import DiscussionMode, { DiscussionPage, isDiscussionPage } from './discussion-mode'; +import DiscussionMode, { DiscussionPage, discussionModes, isDiscussionPage } from './discussion-mode'; export interface UpdateOptions { beatmapset: BeatmapsetWithDiscussionsJson; @@ -88,22 +88,11 @@ export default class DiscussionsState { return beatmap; } - @computed - get currentBeatmapDiscussions() { - return this.discussionsByBeatmap(this.currentBeatmapId); - } - - @computed - get currentBeatmapDiscussionsCurrentModeWithFilter() { - if (this.currentMode === 'events') return []; - return this.currentDiscussions[this.currentMode]; - } - /** * Discussions for the current beatmap grouped by filters */ @computed - get currentDiscussionsGroupedByFilter() { + get discussionsByFilter() { const groups: Record = { deleted: [], hype: [], @@ -116,7 +105,7 @@ export default class DiscussionsState { }; for (const filter of filters) { - groups[filter] = this.filterDiscussionsByFilter(this.currentBeatmapDiscussions, filter); + groups[filter] = this.filterDiscussionsByFilter(this.discussionForSelectedBeatmap, filter); } return groups; @@ -126,8 +115,8 @@ export default class DiscussionsState { * Discussions for the currently selected beatmap and filter grouped by mode. */ @computed - get currentDiscussionsGroupedByMode() { - const discussions = this.currentDiscussionsGroupedByFilter[this.currentFilter]; + get discussionsByMode() { + const discussions = this.discussionsByFilter[this.currentFilter]; return { general: filterDiscusionsByMode(discussions, 'general', this.currentBeatmapId), @@ -156,6 +145,11 @@ export default class DiscussionsState { return counts; } + @computed + get discussionForSelectedBeatmap() { + return this.discussionsByBeatmap(this.currentBeatmapId); + } + @computed get discussionStarters() { const userIds = new Set(this.nonNullDiscussions @@ -166,6 +160,26 @@ export default class DiscussionsState { return [...userIds].map((userId) => this.store.users.get(userId)).sort(); } + + get discussionsForSelectedUserByMode() { + if (this.selectedUser == null) { + return this.discussionsByMode; + } + + const value: Record = { + general: [], + generalAll: [], + reviews: [], + timeline: [], + }; + + for (const mode of discussionModes) { + value[mode] = this.discussionsByMode[mode].filter((discussion) => discussion.user_id === this.selectedUserId); + } + + return value; + } + @computed get firstBeatmap() { return [...this.store.beatmaps.values()][0]; @@ -179,7 +193,7 @@ export default class DiscussionsState { @computed get hasCurrentUserHyped() { const currentUser = core.currentUser; // core.currentUser check below doesn't make the inferrence that it's not nullable after the check. - const discussions = filterDiscusionsByMode(this.currentDiscussionsGroupedByFilter.hype, 'generalAll'); + const discussions = filterDiscusionsByMode(this.discussionsByFilter.hype, 'generalAll'); return currentUser != null && discussions.some((discussion) => discussion?.user_id === currentUser.id); } diff --git a/resources/js/beatmap-discussions/discussions.tsx b/resources/js/beatmap-discussions/discussions.tsx index d4ced3225e9..86a9d5eb486 100644 --- a/resources/js/beatmap-discussions/discussions.tsx +++ b/resources/js/beatmap-discussions/discussions.tsx @@ -86,7 +86,11 @@ export class Discussions extends React.Component { @computed private get sortedDiscussions() { - return this.discussionsState.currentBeatmapDiscussionsCurrentModeWithFilter.slice().sort((a: BeatmapsetDiscussionJson, b: BeatmapsetDiscussionJson) => { + if (this.discussionsState.currentMode === 'events') return []; + + const discussions = this.discussionsState.discussionsForSelectedUserByMode[this.discussionsState.currentMode]; + + return discussions.slice().sort((a: BeatmapsetDiscussionJson, b: BeatmapsetDiscussionJson) => { const mapperNoteCompare = // no sticky for timeline sort this.currentSort !== 'timeline' @@ -166,12 +170,12 @@ export class Discussions extends React.Component { }; private renderDiscussions() { - const count = this.discussionsState.currentBeatmapDiscussionsCurrentModeWithFilter.length; + const count = this.sortedDiscussions.length; if (count === 0) { return (
    - {this.discussionsState.currentDiscussionsGroupedByFilter.total.length > count + {this.discussionsState.discussionsByFilter.total.length > count ? trans('beatmaps.discussions.empty.hidden') : trans('beatmaps.discussions.empty.empty') } @@ -179,14 +183,6 @@ export class Discussions extends React.Component { ); } - if (this.discussionsState.currentBeatmapDiscussionsCurrentModeWithFilter.length === 0) { - return ( -
    - {trans('beatmaps.discussions.empty.hidden')} -
    - ); - } - return (
    {this.renderTimelineCircle()} diff --git a/resources/js/beatmap-discussions/header.tsx b/resources/js/beatmap-discussions/header.tsx index 7d7eb725cfc..b4a7346136b 100644 --- a/resources/js/beatmap-discussions/header.tsx +++ b/resources/js/beatmap-discussions/header.tsx @@ -59,7 +59,7 @@ export class Header extends React.Component { private get discussionCounts() { const counts: Partial> = observable({}); for (const type of statTypes) { - counts[type] = this.discussionsState.currentDiscussionsGroupedByFilter[type].length; + counts[type] = this.discussionsState.discussionsByFilter[type].length; } return counts; @@ -71,7 +71,7 @@ export class Header extends React.Component { @computed private get timelineDiscussions() { - return this.discussionsState.currentDiscussions.timeline; + return this.discussionsState.discussionsByMode.timeline; } private get users() { diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index fc063cf0148..c88ee829132 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -184,7 +184,7 @@ export default class Main extends React.Component { } = stateFromDiscussion(discussion); // unset filter - const currentDiscussionsByMode = this.discussionsState.currentDiscussions[mode]; + const currentDiscussionsByMode = this.discussionsState.discussionsByMode[mode]; if (currentDiscussionsByMode.find((d) => d.id === discussion.id) == null) { this.discussionsState.currentFilter = defaultFilter; } diff --git a/resources/js/beatmap-discussions/mode-switcher.tsx b/resources/js/beatmap-discussions/mode-switcher.tsx index dbb1af3dc18..2f828d79f6a 100644 --- a/resources/js/beatmap-discussions/mode-switcher.tsx +++ b/resources/js/beatmap-discussions/mode-switcher.tsx @@ -74,7 +74,7 @@ export class ModeSwitcher extends React.Component { {this.renderModeText(mode)} {mode !== 'events' && ( - {this.props.discussionsState.currentDiscussions[mode].length} + {this.props.discussionsState.discussionsForSelectedUserByMode[mode].length} )} diff --git a/resources/js/beatmap-discussions/new-discussion.tsx b/resources/js/beatmap-discussions/new-discussion.tsx index 72e396eb38a..1dd43c89231 100644 --- a/resources/js/beatmap-discussions/new-discussion.tsx +++ b/resources/js/beatmap-discussions/new-discussion.tsx @@ -94,7 +94,7 @@ export class NewDiscussion extends React.Component { if (this.nearbyDiscussionsCache == null || (this.nearbyDiscussionsCache.beatmap !== this.currentBeatmap || this.nearbyDiscussionsCache.timestamp !== this.timestamp)) { this.nearbyDiscussionsCache = { beatmap: this.currentBeatmap, - discussions: nearbyDiscussions(this.props.discussionsState.currentBeatmapDiscussions, timestamp), + discussions: nearbyDiscussions(this.props.discussionsState.discussionForSelectedBeatmap, timestamp), timestamp: this.timestamp, }; } From 3e3ca165e91fc09c947e78a4c86f1dc99d84c102 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Tue, 25 Jul 2023 19:15:53 +0900 Subject: [PATCH 045/203] empty import --- resources/js/modding-profile/main.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/modding-profile/main.tsx b/resources/js/modding-profile/main.tsx index 375f9f293ad..a3d054272f3 100644 --- a/resources/js/modding-profile/main.tsx +++ b/resources/js/modding-profile/main.tsx @@ -25,7 +25,7 @@ import Discussions from './discussions'; import Events from './events'; import { Posts } from './posts'; import Stats from './stats'; -import Votes, { } from './votes'; +import Votes from './votes'; // in display order. const moddingExtraPages = ['events', 'discussions', 'posts', 'votes', 'kudosu'] as const; From d22b4e8013ecdff61712e8c7ee546ee69967ceaa Mon Sep 17 00:00:00 2001 From: bakaneko Date: Tue, 25 Jul 2023 19:16:14 +0900 Subject: [PATCH 046/203] export default --- resources/js/modding-profile/main.tsx | 2 +- resources/js/modding-profile/posts.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/modding-profile/main.tsx b/resources/js/modding-profile/main.tsx index a3d054272f3..cb151e5b4d7 100644 --- a/resources/js/modding-profile/main.tsx +++ b/resources/js/modding-profile/main.tsx @@ -23,7 +23,7 @@ import { switchNever } from 'utils/switch-never'; import { currentUrl } from 'utils/turbolinks'; import Discussions from './discussions'; import Events from './events'; -import { Posts } from './posts'; +import Posts from './posts'; import Stats from './stats'; import Votes from './votes'; diff --git a/resources/js/modding-profile/posts.tsx b/resources/js/modding-profile/posts.tsx index db04c48da81..ddcbcb16dcd 100644 --- a/resources/js/modding-profile/posts.tsx +++ b/resources/js/modding-profile/posts.tsx @@ -19,7 +19,7 @@ interface Props { user: UserJson; } -export class Posts extends React.Component { +export default class Posts extends React.Component { render() { return (
    From 6bd6b2cf236560a1363112853f2a1bc36ecdb58f Mon Sep 17 00:00:00 2001 From: bakaneko Date: Tue, 25 Jul 2023 19:27:02 +0900 Subject: [PATCH 047/203] fix the mode counts --- resources/js/beatmap-discussions/discussions-state.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 14c33d24220..cc9d0212f60 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -136,9 +136,11 @@ export default class DiscussionsState { }; for (const discussion of this.nonNullDiscussions) { - const mode = discussion.beatmap?.mode; - if (mode != null) { - counts[mode]++; + if (discussion.beatmap_id != null) { + const mode = this.store.beatmaps.get(discussion.beatmap_id)?.mode; + if (mode != null) { + counts[mode]++; + } } } From ae0656b992aacca8f39e36ca3992f86e3eb7e0ff Mon Sep 17 00:00:00 2001 From: bakaneko Date: Tue, 25 Jul 2023 19:32:04 +0900 Subject: [PATCH 048/203] just spread the props --- resources/js/beatmap-discussions-history/main.tsx | 14 +++++--------- .../js/entrypoints/beatmap-discussions-history.tsx | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/resources/js/beatmap-discussions-history/main.tsx b/resources/js/beatmap-discussions-history/main.tsx index 947b743b777..bd368439de9 100644 --- a/resources/js/beatmap-discussions-history/main.tsx +++ b/resources/js/beatmap-discussions-history/main.tsx @@ -11,15 +11,11 @@ import * as React from 'react'; import { makeUrl } from 'utils/beatmapset-discussion-helper'; import { trans } from 'utils/lang'; -interface Props { - bundle: BeatmapsetDiscussionsBundleJson; -} - @observer -export default class Main extends React.Component { - @observable store = new BeatmapsetDiscussionsBundleStore(this.props.bundle); +export default class Main extends React.Component { + @observable store = new BeatmapsetDiscussionsBundleStore(this.props); - constructor(props: Props) { + constructor(props: BeatmapsetDiscussionsBundleJson) { super(props); makeObservable(this); @@ -28,11 +24,11 @@ export default class Main extends React.Component { render() { return (
    - {this.props.bundle.discussions.length === 0 ? ( + {this.props.discussions.length === 0 ? (
    {trans('beatmap_discussions.index.none_found')}
    - ) : (this.props.bundle.discussions.map((discussion) => { + ) : (this.props.discussions.map((discussion) => { // TODO: handle in child component? Refactored state might not have beatmapset here (and uses Map) const beatmapset = this.store.beatmapsets.get(discussion.beatmapset_id); diff --git a/resources/js/entrypoints/beatmap-discussions-history.tsx b/resources/js/entrypoints/beatmap-discussions-history.tsx index ca935aff027..1e500acc8ec 100644 --- a/resources/js/entrypoints/beatmap-discussions-history.tsx +++ b/resources/js/entrypoints/beatmap-discussions-history.tsx @@ -8,5 +8,5 @@ import React from 'react'; import { parseJson } from 'utils/json'; core.reactTurbolinks.register('beatmap-discussions-history', () => ( -
    ('json-index')} /> +
    ('json-index')} /> )); From c55a920ee5b213d5b499511829e8930d295eee55 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 26 Jul 2023 00:04:01 +0900 Subject: [PATCH 049/203] compute discussions array; discussions already non-null from store --- .../beatmap-discussions/discussions-state.ts | 18 ++++++++---------- resources/js/beatmap-discussions/main.tsx | 11 +++-------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index cc9d0212f60..5132508578c 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -135,7 +135,7 @@ export default class DiscussionsState { taiko: 0, }; - for (const discussion of this.nonNullDiscussions) { + for (const discussion of this.discussionsArray) { if (discussion.beatmap_id != null) { const mode = this.store.beatmaps.get(discussion.beatmap_id)?.mode; if (mode != null) { @@ -152,9 +152,14 @@ export default class DiscussionsState { return this.discussionsByBeatmap(this.currentBeatmapId); } + @computed + get discussionsArray() { + return [...this.store.discussions.values()]; + } + @computed get discussionStarters() { - const userIds = new Set(this.nonNullDiscussions + const userIds = new Set(this.discussionsArray .filter((discussion) => discussion.message_type !== 'hype') .map((discussion) => discussion.user_id)); @@ -162,7 +167,6 @@ export default class DiscussionsState { return [...userIds].map((userId) => this.store.users.get(userId)).sort(); } - get discussionsForSelectedUserByMode() { if (this.selectedUser == null) { return this.discussionsByMode; @@ -223,15 +227,9 @@ export default class DiscussionsState { return sortWithMode([...this.store.beatmaps.values()]); } - @computed - get nonNullDiscussions() { - // TODO: they're already non-null - return [...this.store.discussions.values()].filter((discussion) => discussion != null); - } - @computed get presentDiscussions() { - return this.nonNullDiscussions.filter((discussion) => discussion.deleted_at == null); + return this.discussionsArray.filter((discussion) => discussion.deleted_at == null); } @computed diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index c88ee829132..69362ac5b19 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -6,7 +6,7 @@ import { ReviewEditorConfigContext } from 'beatmap-discussions/review-editor-con import BackToTop from 'components/back-to-top'; import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import { route } from 'laroute'; -import { action, computed, makeObservable, observable } from 'mobx'; +import { action, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import BeatmapsetDiscussionsStore from 'models/beatmapset-discussions-store'; import core from 'osu-core-singleton'; @@ -56,11 +56,6 @@ export default class Main extends React.Component { private readonly timeouts: Record = {}; private xhrCheckNew?: JQuery.jqXHR; - @computed - get discussions() { - return [...this.store.discussions.values()]; - } - constructor(props: Props) { super(props); @@ -216,7 +211,7 @@ export default class Main extends React.Component { if (!(e.currentTarget instanceof HTMLLinkElement)) return; const url = e.currentTarget.href; - const parsedUrl = parseUrl(url, this.discussions); + const parsedUrl = parseUrl(url, this.discussionsState.discussionsArray); if (parsedUrl == null) return; @@ -229,7 +224,7 @@ export default class Main extends React.Component { }; private readonly jumpToDiscussionByHash = () => { - const target = parseUrl(null, this.discussions); + const target = parseUrl(null, this.discussionsState.discussionsArray); if (target?.discussionId != null) { this.jumpTo(null, { id: target.discussionId, postId: target.postId }); From 6d7fd556996cdf5e7d1b9c5099d012c1759caa2f Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 26 Jul 2023 13:04:54 +0900 Subject: [PATCH 050/203] fix browser test clicking --- tests/Browser/BeatmapDiscussionPostsTest.php | 24 ++++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/Browser/BeatmapDiscussionPostsTest.php b/tests/Browser/BeatmapDiscussionPostsTest.php index 8ef4c79259b..c07bca232ff 100644 --- a/tests/Browser/BeatmapDiscussionPostsTest.php +++ b/tests/Browser/BeatmapDiscussionPostsTest.php @@ -15,7 +15,14 @@ class BeatmapDiscussionPostsTest extends DuskTestCase { - private $new_reply_widget_selector = '.beatmap-discussion-post--new-reply'; + private const NEW_REPLY_SELECTOR = '.beatmap-discussion-new-reply'; + private const RESOLVE_BUTTON_SELECTOR = '.btn-osu-big[data-action=reply_resolve]'; + + private Beatmap $beatmap; + private BeatmapDiscussion $beatmapDiscussion; + private Beatmapset $beatmapset; + private User $mapper; + private User $user; public function testConcurrentPostAfterResolve() { @@ -41,8 +48,8 @@ public function testConcurrentPostAfterResolve() protected function writeReply(Browser $browser, $reply) { - $browser->with($this->new_reply_widget_selector, function ($new_reply) use ($reply) { - $new_reply->press('Respond') + $browser->with(static::NEW_REPLY_SELECTOR, function (Browser $newReply) use ($reply) { + $newReply->press(trans('beatmap_discussions.reply.open.user')) ->waitFor('textarea') ->type('textarea', $reply); }); @@ -50,13 +57,16 @@ protected function writeReply(Browser $browser, $reply) protected function postReply(Browser $browser, $action) { - $browser->with($this->new_reply_widget_selector, function ($new_reply) use ($action) { + $browser->with(static::NEW_REPLY_SELECTOR, function (Browser $newReply) use ($action) { switch ($action) { case 'resolve': - $new_reply->press('Reply and Resolve'); + // button may be covered by dev banner; + // ->element->($selector)->getLocationOnScreenOnceScrolledIntoView() uses { block: 'end', inline: 'nearest' } which isn't enough. + $newReply->scrollIntoView(static::RESOLVE_BUTTON_SELECTOR); + $newReply->element(static::RESOLVE_BUTTON_SELECTOR)->click(); break; default: - $new_reply->keys('textarea', '{enter}'); + $newReply->keys('textarea', '{enter}'); break; } }); @@ -110,7 +120,7 @@ protected function setUp(): void $post = BeatmapDiscussionPost::factory()->timeline()->make([ 'user_id' => $this->user, ]); - $this->beatmapDiscussionPost = $this->beatmapDiscussion->beatmapDiscussionPosts()->save($post); + $this->beatmapDiscussion->beatmapDiscussionPosts()->save($post); $this->beforeApplicationDestroyed(function () { // Similar case to SanityTest, cleanup the models we created during the test. From fbfb68b1e65c914e3615955ec0479d23da2a76af Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 26 Jul 2023 15:04:19 +0900 Subject: [PATCH 051/203] handle guest user --- resources/js/beatmap-discussions/discussions-state.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 5132508578c..f13d61f6106 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -394,8 +394,8 @@ export default class DiscussionsState { case 'mapperNotes': return discussions.filter((discussion) => discussion.message_type === 'mapper_note'); case 'mine': { - const userId = core.currentUserOrFail.id; - return discussions.filter((discussion) => discussion.user_id === userId); + const currentUser = core.currentUser; + return currentUser != null ? discussions.filter((discussion) => discussion.user_id === currentUser.id) : []; } case 'pending': { const reviewsWithPending = new Set(); From 9551dd31eb68728eb41b84bae6a71de657b49aff Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 26 Jul 2023 15:15:10 +0900 Subject: [PATCH 052/203] unused event --- resources/js/beatmap-discussions/main.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index 69362ac5b19..e512e79b5af 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -73,7 +73,6 @@ export default class Main extends React.Component { componentDidMount() { $.subscribe(`beatmapsetDiscussions:update.${this.eventId}`, this.update); - $.subscribe(`beatmapDiscussion:jump.${this.eventId}`, this.jumpTo); $.subscribe(`beatmapDiscussionPost:toggleShowDeleted.${this.eventId}`, this.toggleShowDeleted); $(document).on(`ajax:success.${this.eventId}`, '.js-beatmapset-discussion-update', this.ujsDiscussionUpdate); From 23cf59cab41025476eed8303846d59b8f0629e7f Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 26 Jul 2023 15:17:31 +0900 Subject: [PATCH 053/203] should be jumping when not restoring --- resources/js/beatmap-discussions/discussions-state.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index f13d61f6106..b871d1aa63e 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -267,8 +267,8 @@ export default class DiscussionsState { if (existingState != null) { Object.apply(this, existingState); - this.jumpToDiscussion = true; } else { + this.jumpToDiscussion = true; for (const discussion of beatmapset.discussions) { if (discussion.posts != null) { for (const post of discussion.posts) { From 36d8ae71ce993d8b740a985fef13ab1622caacf3 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 26 Jul 2023 17:36:14 +0900 Subject: [PATCH 054/203] these are json date strings, not timestamps... --- resources/js/beatmap-discussions/discussions-state.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index b871d1aa63e..0fe6824ac41 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -205,13 +205,16 @@ export default class DiscussionsState { @computed get lastUpdate() { + const maxDiscussions = maxBy(this.beatmapset.discussions, 'updated_at')?.updated_at; + const maxEvents = maxBy(this.beatmapset.events, 'created_at')?.created_at; + const maxLastUpdate = Math.max( - +this.beatmapset.last_updated, - +(maxBy(this.beatmapset.discussions, 'updated_at')?.updated_at ?? 0), - +(maxBy(this.beatmapset.events, 'created_at')?.created_at ?? 0), + Date.parse(this.beatmapset.last_updated), + maxDiscussions != null ? Date.parse(maxDiscussions) : 0, + maxEvents != null ? Date.parse(maxEvents) : 0, ); - return maxLastUpdate != null ? moment(maxLastUpdate).unix() : null; + return moment(maxLastUpdate).unix(); } @computed From ef03a48eb0ab0aeab3b935cbe32eebdfc93291b0 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 26 Jul 2023 17:57:25 +0900 Subject: [PATCH 055/203] update correct beatmapset observable --- .../js/beatmap-discussions/discussions-state.ts | 14 +++++++++----- resources/js/beatmap-discussions/main.tsx | 4 ++-- .../js/beatmap-discussions/new-discussion.tsx | 2 +- resources/js/beatmap-discussions/new-reply.tsx | 2 +- resources/js/beatmap-discussions/nominations.tsx | 6 +++--- resources/js/beatmap-discussions/nominator.tsx | 2 +- .../js/models/beatmapset-discussions-store.ts | 7 +++++-- 7 files changed, 22 insertions(+), 15 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 0fe6824ac41..cc9b9ce14ad 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -6,7 +6,6 @@ import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussion import GameMode from 'interfaces/game-mode'; import { maxBy } from 'lodash'; import { action, computed, makeObservable, observable, toJS } from 'mobx'; -import BeatmapsetDiscussions from 'models/beatmapset-discussions'; import moment from 'moment'; import core from 'osu-core-singleton'; import { findDefault, group, sortWithMode } from 'utils/beatmap-helper'; @@ -14,6 +13,7 @@ import { makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper'; import { switchNever } from 'utils/switch-never'; import { Filter, filters } from './current-discussions'; import DiscussionMode, { DiscussionPage, discussionModes, isDiscussionPage } from './discussion-mode'; +import BeatmapsetDiscussionsStore from 'models/beatmapset-discussions-store'; export interface UpdateOptions { beatmapset: BeatmapsetWithDiscussionsJson; @@ -78,6 +78,10 @@ export default class DiscussionsState { private previousFilter: Filter = 'total'; private previousPage: DiscussionPage = 'general'; + get beatmapset() { + return this.store.beatmapset; + } + @computed get currentBeatmap() { const beatmap = this.store.beatmaps.get(this.currentBeatmapId); @@ -264,7 +268,7 @@ export default class DiscussionsState { return this.presentDiscussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved); } - constructor(public beatmapset: BeatmapsetWithDiscussionsJson, private store: BeatmapsetDiscussions, state?: string) { + constructor(private store: BeatmapsetDiscussionsStore, state?: string) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const existingState = state == null ? null : parseState(state); @@ -272,7 +276,7 @@ export default class DiscussionsState { Object.apply(this, existingState); } else { this.jumpToDiscussion = true; - for (const discussion of beatmapset.discussions) { + for (const discussion of store.beatmapset.discussions) { if (discussion.posts != null) { for (const post of discussion.posts) { this.readPostIds.add(post.id); @@ -284,7 +288,7 @@ export default class DiscussionsState { this.currentBeatmapId = (findDefault({ group: this.groupedBeatmaps }) ?? this.firstBeatmap).id; // Current url takes priority over saved state. - const query = parseUrl(null, beatmapset.discussions); + const query = parseUrl(null, store.beatmapset.discussions); if (query != null) { // TODO: maybe die instead? this.currentMode = query.mode; @@ -380,7 +384,7 @@ export default class DiscussionsState { } = options; if (beatmapset != null) { - this.beatmapset = beatmapset; + this.store.beatmapset = beatmapset; } if (watching != null) { diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index e512e79b5af..b0888f0843f 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -66,7 +66,7 @@ export default class Main extends React.Component { // using DiscussionsState['beatmapset'] as type cast to force errors if it doesn't match with props since the beatmapset is from discussionsState. const existingBeatmapset = parseJson(props.container.dataset.beatmapset); this.store = new BeatmapsetDiscussionsStore(existingBeatmapset ?? this.props.initial.beatmapset); - this.discussionsState = new DiscussionsState(props.initial.beatmapset, this.store, props.container.dataset.discussionsState); + this.discussionsState = new DiscussionsState(this.store, props.container.dataset.discussionsState); makeObservable(this); } @@ -223,7 +223,7 @@ export default class Main extends React.Component { }; private readonly jumpToDiscussionByHash = () => { - const target = parseUrl(null, this.discussionsState.discussionsArray); + const target = parseUrl(null, this.discussionsState.discussionsArray); if (target?.discussionId != null) { this.jumpTo(null, { id: target.discussionId, postId: target.postId }); diff --git a/resources/js/beatmap-discussions/new-discussion.tsx b/resources/js/beatmap-discussions/new-discussion.tsx index 1dd43c89231..66e835b11ea 100644 --- a/resources/js/beatmap-discussions/new-discussion.tsx +++ b/resources/js/beatmap-discussions/new-discussion.tsx @@ -246,7 +246,7 @@ export class NewDiscussion extends React.Component { for (const postId of json.beatmap_discussion_post_ids) { this.props.discussionsState.readPostIds.add(postId); } - this.props.discussionsState.beatmapset = json.beatmapset; + this.props.discussionsState.update({ beatmapset: json.beatmapset }); })) .fail(onError) .always(action(() => { diff --git a/resources/js/beatmap-discussions/new-reply.tsx b/resources/js/beatmap-discussions/new-reply.tsx index 9ef2ba5df05..b75071d713b 100644 --- a/resources/js/beatmap-discussions/new-reply.tsx +++ b/resources/js/beatmap-discussions/new-reply.tsx @@ -159,8 +159,8 @@ export class NewReply extends React.Component { .done((json) => runInAction(() => { this.editing = false; this.setMessage(''); - this.props.discussionsState.markAsRead(json.beatmap_discussion_post_ids); this.props.discussionsState.update({ beatmapset: json.beatmapset }); + this.props.discussionsState.markAsRead(json.beatmap_discussion_post_ids); })) .fail(onError) .always(action(() => { diff --git a/resources/js/beatmap-discussions/nominations.tsx b/resources/js/beatmap-discussions/nominations.tsx index 71e1e6c5245..b0976262bd0 100644 --- a/resources/js/beatmap-discussions/nominations.tsx +++ b/resources/js/beatmap-discussions/nominations.tsx @@ -178,7 +178,7 @@ export class Nominations extends React.Component { this.xhr.discussionLock .done((beatmapset) => runInAction(() => { - this.props.discussionsState.beatmapset = beatmapset; + this.props.discussionsState.update({ beatmapset }); })) .fail(onError) .always(action(() => { @@ -199,7 +199,7 @@ export class Nominations extends React.Component { this.xhr.discussionLock .done((beatmapset) => runInAction(() => { - this.props.discussionsState.beatmapset = beatmapset; + this.props.discussionsState.update({ beatmapset }); })) .fail(onError) .always(action(() => { @@ -293,7 +293,7 @@ export class Nominations extends React.Component { this.xhr.removeFromLoved .done((beatmapset) => runInAction(() => { - this.props.discussionsState.beatmapset = beatmapset; + this.props.discussionsState.update({ beatmapset }); })) .fail(onError) .always(action(() => { diff --git a/resources/js/beatmap-discussions/nominator.tsx b/resources/js/beatmap-discussions/nominator.tsx index 9596463b839..fefc7b213a2 100644 --- a/resources/js/beatmap-discussions/nominator.tsx +++ b/resources/js/beatmap-discussions/nominator.tsx @@ -163,7 +163,7 @@ export class Nominator extends React.Component { this.xhr = $.ajax(url, params); this.xhr.done((beatmapset) => runInAction(() => { - this.props.discussionsState.beatmapset = beatmapset; + this.props.discussionsState.update({ beatmapset }); this.hideNominationModal(); })) .fail(onError) diff --git a/resources/js/models/beatmapset-discussions-store.ts b/resources/js/models/beatmapset-discussions-store.ts index 9e96f32756c..824335397f2 100644 --- a/resources/js/models/beatmapset-discussions-store.ts +++ b/resources/js/models/beatmapset-discussions-store.ts @@ -4,11 +4,13 @@ import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import { isEmpty } from 'lodash'; -import { computed, makeObservable } from 'mobx'; +import { computed, makeObservable, observable } from 'mobx'; import { mapBy, mapByWithNulls } from 'utils/map'; import BeatmapsetDiscussions from './beatmapset-discussions'; export default class BeatmapsetDiscussionsStore implements BeatmapsetDiscussions { + @observable beatmapset: BeatmapsetWithDiscussionsJson; + @computed get beatmaps() { const hasDiscussion = new Set(); @@ -44,7 +46,8 @@ export default class BeatmapsetDiscussionsStore implements BeatmapsetDiscussions return mapByWithNulls(this.beatmapset.related_users, 'id'); } - constructor(private beatmapset: BeatmapsetWithDiscussionsJson) { + constructor(beatmapset: BeatmapsetWithDiscussionsJson) { + this.beatmapset = beatmapset; makeObservable(this); } } From 4258e90cda32ed536f88635231b858f30f6de90f Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 26 Jul 2023 18:02:00 +0900 Subject: [PATCH 056/203] pass whole response in --- resources/js/beatmap-discussions/discussions-state.ts | 8 +++++++- resources/js/beatmap-discussions/new-reply.tsx | 3 +-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index cc9b9ce14ad..241575d3231 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -6,6 +6,7 @@ import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussion import GameMode from 'interfaces/game-mode'; import { maxBy } from 'lodash'; import { action, computed, makeObservable, observable, toJS } from 'mobx'; +import BeatmapsetDiscussionsStore from 'models/beatmapset-discussions-store'; import moment from 'moment'; import core from 'osu-core-singleton'; import { findDefault, group, sortWithMode } from 'utils/beatmap-helper'; @@ -13,9 +14,9 @@ import { makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper'; import { switchNever } from 'utils/switch-never'; import { Filter, filters } from './current-discussions'; import DiscussionMode, { DiscussionPage, discussionModes, isDiscussionPage } from './discussion-mode'; -import BeatmapsetDiscussionsStore from 'models/beatmapset-discussions-store'; export interface UpdateOptions { + beatmap_discussion_post_ids: number[]; beatmapset: BeatmapsetWithDiscussionsJson; watching: boolean; } @@ -379,10 +380,15 @@ export default class DiscussionsState { @action update(options: Partial) { const { + beatmap_discussion_post_ids, beatmapset, watching, } = options; + if (beatmap_discussion_post_ids != null) { + this.markAsRead(beatmap_discussion_post_ids); + } + if (beatmapset != null) { this.store.beatmapset = beatmapset; } diff --git a/resources/js/beatmap-discussions/new-reply.tsx b/resources/js/beatmap-discussions/new-reply.tsx index b75071d713b..4b471519e9c 100644 --- a/resources/js/beatmap-discussions/new-reply.tsx +++ b/resources/js/beatmap-discussions/new-reply.tsx @@ -159,8 +159,7 @@ export class NewReply extends React.Component { .done((json) => runInAction(() => { this.editing = false; this.setMessage(''); - this.props.discussionsState.update({ beatmapset: json.beatmapset }); - this.props.discussionsState.markAsRead(json.beatmap_discussion_post_ids); + this.props.discussionsState.update(json); })) .fail(onError) .always(action(() => { From 513e6b783e4f9fc7bcfe1c82fe8ebb6d369d82ba Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 26 Jul 2023 20:06:13 +0900 Subject: [PATCH 057/203] class name hasn't been updated yet in this branch --- tests/Browser/BeatmapDiscussionPostsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Browser/BeatmapDiscussionPostsTest.php b/tests/Browser/BeatmapDiscussionPostsTest.php index c07bca232ff..df0486d8836 100644 --- a/tests/Browser/BeatmapDiscussionPostsTest.php +++ b/tests/Browser/BeatmapDiscussionPostsTest.php @@ -15,7 +15,7 @@ class BeatmapDiscussionPostsTest extends DuskTestCase { - private const NEW_REPLY_SELECTOR = '.beatmap-discussion-new-reply'; + private const NEW_REPLY_SELECTOR = '.beatmap-discussion-post--new-reply'; private const RESOLVE_BUTTON_SELECTOR = '.btn-osu-big[data-action=reply_resolve]'; private Beatmap $beatmap; From 26e914a3d4b0667af10a285f664151fc959da2a4 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Mon, 31 Jul 2023 18:19:59 +0900 Subject: [PATCH 058/203] action should have makeObservable? --- resources/js/beatmap-discussions/user-filter.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/resources/js/beatmap-discussions/user-filter.tsx b/resources/js/beatmap-discussions/user-filter.tsx index fd96d1c413e..c0765db9d27 100644 --- a/resources/js/beatmap-discussions/user-filter.tsx +++ b/resources/js/beatmap-discussions/user-filter.tsx @@ -4,7 +4,7 @@ import mapperGroup from 'beatmap-discussions/mapper-group'; import SelectOptions, { OptionRenderProps } from 'components/select-options'; import UserJson from 'interfaces/user-json'; -import { action } from 'mobx'; +import { action, makeObservable } from 'mobx'; import { observer } from 'mobx-react'; import BeatmapsetDiscussions from 'models/beatmapset-discussions'; import * as React from 'react'; @@ -58,6 +58,11 @@ export class UserFilter extends React.Component { return [allUsers, ...[...this.props.store.users.values()].map(mapUserProperties)]; } + constructor(props: Props) { + super(props); + makeObservable(this); + } + render() { return ( Date: Mon, 31 Jul 2023 18:25:54 +0900 Subject: [PATCH 059/203] mobx doesn't like undefined 0 index --- resources/js/beatmap-discussions/user-filter.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/resources/js/beatmap-discussions/user-filter.tsx b/resources/js/beatmap-discussions/user-filter.tsx index c0765db9d27..c8197e6f4c8 100644 --- a/resources/js/beatmap-discussions/user-filter.tsx +++ b/resources/js/beatmap-discussions/user-filter.tsx @@ -75,6 +75,12 @@ export class UserFilter extends React.Component { ); } + private getGroup(option: Option) { + if (this.isOwner(option)) return mapperGroup; + if (option.groups == null || option.groups.length === 0) return null; + return option.groups[0]; + } + @action private readonly handleChange = (option: Option) => { this.props.discussionsState.selectedUserId = option.id; @@ -88,7 +94,7 @@ export class UserFilter extends React.Component { // TODO: exclude null/undefined user from discussionsState if (option.id < 0) return; - const group = this.isOwner(option) ? mapperGroup : option.groups?.[0]; + const group = this.getGroup(option); const style = groupColour(group); const urlOptions = parseUrl(); From c790067b62dd64b354c3fa4707a1ab19d7ddd6fe Mon Sep 17 00:00:00 2001 From: bakaneko Date: Mon, 31 Jul 2023 18:34:41 +0900 Subject: [PATCH 060/203] account for type of everyone option --- resources/js/beatmap-discussions/user-filter.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/resources/js/beatmap-discussions/user-filter.tsx b/resources/js/beatmap-discussions/user-filter.tsx index c8197e6f4c8..ad2197b4d42 100644 --- a/resources/js/beatmap-discussions/user-filter.tsx +++ b/resources/js/beatmap-discussions/user-filter.tsx @@ -25,7 +25,7 @@ const noSelection = Object.freeze({ interface Option { groups: UserJson['groups']; - id: UserJson['id']; + id: UserJson['id'] | null; text: UserJson['username']; } @@ -91,9 +91,6 @@ export class UserFilter extends React.Component { } private readonly renderOption = ({ cssClasses, children, onClick, option }: OptionRenderProps
    @@ -243,47 +221,4 @@ export class Header extends React.Component {
    ); } - - private readonly renderType = (type: Filter) => { - if ((type === 'deleted') && !core.currentUser?.is_admin) { - return null; - } - - const bn = 'counter-box'; - - let topClasses = classWithModifiers(bn, 'beatmap-discussions', kebabCase(type)); - if (this.discussionsState.currentPage !== 'events' && this.discussionsState.currentFilter === type) { - topClasses += ' js-active'; - } - - return ( - -
    -
    - {trans(`beatmaps.discussions.stats.${snakeCase(type)}`)} -
    -
    - {this.discussionCounts[type]} -
    -
    -
    - - ); - }; - - private readonly setFilter = (event: React.SyntheticEvent) => { - event.preventDefault(); - this.discussionsState.changeFilter(event.currentTarget.dataset.type); - }; } diff --git a/resources/js/beatmap-discussions/type-filters.tsx b/resources/js/beatmap-discussions/type-filters.tsx new file mode 100644 index 00000000000..c516f5de53f --- /dev/null +++ b/resources/js/beatmap-discussions/type-filters.tsx @@ -0,0 +1,86 @@ +// 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 { kebabCase, snakeCase } from 'lodash'; +import { computed } from 'mobx'; +import { observer } from 'mobx-react'; +import core from 'osu-core-singleton'; +import * as React from 'react'; +import { makeUrl } from 'utils/beatmapset-discussion-helper'; +import { classWithModifiers } from 'utils/css'; +import { trans } from 'utils/lang'; +import { Filter } from './current-discussions'; +import DiscussionsState from './discussions-state'; + +interface Props { + discussionsState: DiscussionsState; +} + +const bn = 'counter-box'; +const statTypes: Filter[] = ['mine', 'mapperNotes', 'resolved', 'pending', 'praises', 'deleted', 'total']; + +@observer +export default class TypeFilters extends React.Component { + @computed + private get discussionCounts() { + const counts: Partial> = {}; + const selectedUserId = this.props.discussionsState.selectedUserId; + + for (const type of statTypes) { + let discussions = this.props.discussionsState.discussionsByFilter[type]; + if (selectedUserId != null) { + discussions = discussions.filter((discussion) => discussion.user_id === selectedUserId); + } + + counts[type] = discussions.length; + } + + return counts; + } + + render() { + return statTypes.map(this.renderType); + } + + private readonly renderType = (type: Filter) => { + if ((type === 'deleted') && !core.currentUser?.is_admin) { + return null; + } + + let topClasses = classWithModifiers(bn, 'beatmap-discussions', kebabCase(type)); + if (this.props.discussionsState.currentPage !== 'events' && this.props.discussionsState.currentFilter === type) { + topClasses += ' js-active'; + } + + return ( + +
    +
    + {trans(`beatmaps.discussions.stats.${snakeCase(type)}`)} +
    +
    + {this.discussionCounts[type]} +
    +
    +
    + + ); + }; + + private readonly setFilter = (event: React.SyntheticEvent) => { + event.preventDefault(); + this.props.discussionsState.changeFilter(event.currentTarget.dataset.type); + }; +} + From 18a80c911f1f86254da6deb4628e1917c047a7dc Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 15 Nov 2023 17:42:17 +0900 Subject: [PATCH 099/203] update url from state --- .../beatmap-discussions/discussions-state.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 3a933ad2070..72b57d37b24 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -5,7 +5,7 @@ import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import GameMode from 'interfaces/game-mode'; import { maxBy } from 'lodash'; -import { action, computed, makeObservable, observable, toJS } from 'mobx'; +import { action, computed, makeObservable, observable, reaction, toJS } from 'mobx'; import moment from 'moment'; import core from 'osu-core-singleton'; import BeatmapsetDiscussionsShowStore from 'stores/beatmapset-discussions-show-store'; @@ -293,6 +293,16 @@ export default class DiscussionsState { return this.presentDiscussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved); } + @computed + get url() { + return makeUrl({ + beatmap: this.currentBeatmap, + filter: this.currentFilter, + mode: this.currentPage, + user: this.selectedUserId ?? undefined, + }); + } + constructor(private readonly store: BeatmapsetDiscussionsShowStore, state?: string) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const existingState = state == null ? null : parseState(state); @@ -325,19 +335,18 @@ export default class DiscussionsState { } makeObservable(this); + + reaction(() => this.url, (current, prev) => { + if (current !== prev) { + Turbolinks.controller.advanceHistory(this.url); + } + }); } @action changeDiscussionPage(page?: string) { if (!isDiscussionPage(page)) return; - const url = makeUrl({ - beatmap: this.currentBeatmap, - filter: this.currentFilter, - mode: page, - user: this.selectedUserId ?? undefined, - }); - if (page === 'events') { // record page and filter when switching to events this.previousPage = this.currentPage; @@ -348,7 +357,6 @@ export default class DiscussionsState { } this.currentPage = page; - Turbolinks.controller.advanceHistory(url); } @action From f2615960e7daf17e116b4951a275ae2a801c1ffa Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 15 Nov 2023 17:45:51 +0900 Subject: [PATCH 100/203] dispose reaction --- resources/js/beatmap-discussions/discussions-state.ts | 7 ++++++- resources/js/beatmap-discussions/main.tsx | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 72b57d37b24..190f5cce1ce 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -59,6 +59,7 @@ export default class DiscussionsState { private previousFilter: Filter = 'total'; private previousPage: DiscussionPage = 'general'; + private readonly urlStateDisposer; get beatmapset() { return this.store.beatmapset; @@ -336,7 +337,7 @@ export default class DiscussionsState { makeObservable(this); - reaction(() => this.url, (current, prev) => { + this.urlStateDisposer = reaction(() => this.url, (current, prev) => { if (current !== prev) { Turbolinks.controller.advanceHistory(this.url); } @@ -379,6 +380,10 @@ export default class DiscussionsState { } } + destroy() { + this.urlStateDisposer(); + } + discussionsByBeatmap(beatmapId: number) { return this.presentDiscussions.filter((discussion) => (discussion.beatmap_id == null || discussion.beatmap_id === beatmapId)); } diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index ca8bcfcda22..85482053d34 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -83,6 +83,7 @@ export default class Main extends React.Component { window.clearTimeout(this.timeoutCheckNew); this.xhrCheckNew?.abort(); this.disposers.forEach((disposer) => disposer?.()); + this.discussionsState.destroy(); } render() { From 597cf512a2cb6155040fde6ab4fdbd614defbf6a Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 15 Nov 2023 17:52:01 +0900 Subject: [PATCH 101/203] missing observer --- .../js/beatmap-discussions/editor-discussion-component.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/js/beatmap-discussions/editor-discussion-component.tsx b/resources/js/beatmap-discussions/editor-discussion-component.tsx index 32843c93f7e..aa4bc965dd6 100644 --- a/resources/js/beatmap-discussions/editor-discussion-component.tsx +++ b/resources/js/beatmap-discussions/editor-discussion-component.tsx @@ -4,7 +4,7 @@ import { EmbedElement } from 'editor'; import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; -import { Observer } from 'mobx-react'; +import { Observer, observer } from 'mobx-react'; import * as React from 'react'; import { Transforms } from 'slate'; import { RenderElementProps } from 'slate-react'; @@ -38,6 +38,7 @@ interface Props extends RenderElementProps { store: BeatmapsetDiscussionsStore; } +@observer export default class EditorDiscussionComponent extends React.Component { static contextType = SlateContext; From d99b5022fdd2075ec1d1828dba759c0cf6fb4c07 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 15 Nov 2023 17:55:23 +0900 Subject: [PATCH 102/203] add TODO --- resources/js/entrypoints/beatmap-discussions.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/js/entrypoints/beatmap-discussions.tsx b/resources/js/entrypoints/beatmap-discussions.tsx index d6adbb17f6c..6019627a3d9 100644 --- a/resources/js/entrypoints/beatmap-discussions.tsx +++ b/resources/js/entrypoints/beatmap-discussions.tsx @@ -14,6 +14,7 @@ function parseJsonString(json?: string) { } core.reactTurbolinks.register('beatmap-discussions', (container: HTMLElement) => { + // TODO: avoid reparsing/loading everything on browser navigation for better performance. // using DiscussionsState['beatmapset'] as type cast to force errors if it doesn't match with props since the beatmapset is from discussionsState. const beatmapset = parseJsonString(container.dataset.beatmapset) ?? parseJson('json-beatmapset'); From ba4297589c9b27334cafa25eaa58f2a7ad3ccd56 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 15 Nov 2023 18:01:27 +0900 Subject: [PATCH 103/203] beatmapset comes from store now --- resources/js/beatmap-discussions/main.tsx | 2 +- resources/js/entrypoints/beatmap-discussions.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index 85482053d34..4a07d49fe4b 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -242,7 +242,7 @@ export default class Main extends React.Component { }; private readonly saveStateToContainer = () => { - this.props.container.dataset.beatmapset = JSON.stringify(this.discussionsState.beatmapset); + this.props.container.dataset.beatmapset = JSON.stringify(this.store.beatmapset); this.props.container.dataset.discussionsState = this.discussionsState.toJsonString(); }; diff --git a/resources/js/entrypoints/beatmap-discussions.tsx b/resources/js/entrypoints/beatmap-discussions.tsx index 6019627a3d9..36e89f37891 100644 --- a/resources/js/entrypoints/beatmap-discussions.tsx +++ b/resources/js/entrypoints/beatmap-discussions.tsx @@ -1,11 +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 DiscussionsState from 'beatmap-discussions/discussions-state'; import Main from 'beatmap-discussions/main'; import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import core from 'osu-core-singleton'; import React from 'react'; +import BeatmapsetDiscussionsShowStore from 'stores/beatmapset-discussions-show-store'; import { parseJson } from 'utils/json'; function parseJsonString(json?: string) { @@ -15,8 +15,7 @@ function parseJsonString(json?: string) { core.reactTurbolinks.register('beatmap-discussions', (container: HTMLElement) => { // TODO: avoid reparsing/loading everything on browser navigation for better performance. - // using DiscussionsState['beatmapset'] as type cast to force errors if it doesn't match with props since the beatmapset is from discussionsState. - const beatmapset = parseJsonString(container.dataset.beatmapset) + const beatmapset = parseJsonString(container.dataset.beatmapset) ?? parseJson('json-beatmapset'); return (
    Date: Wed, 15 Nov 2023 18:23:24 +0900 Subject: [PATCH 104/203] overwrite existing json data on the page when saving state --- resources/js/beatmap-discussions/main.tsx | 10 +++++-- .../js/entrypoints/beatmap-discussions.tsx | 29 ++++++------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index 4a07d49fe4b..b7969bed4f8 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -12,6 +12,7 @@ import core from 'osu-core-singleton'; import * as React from 'react'; import BeatmapsetDiscussionsShowStore from 'stores/beatmapset-discussions-show-store'; import { defaultFilter, parseUrl, stateFromDiscussion } from 'utils/beatmapset-discussion-helper'; +import { parseJson, storeJson } from 'utils/json'; import { nextVal } from 'utils/seq'; import { currentUrl } from 'utils/turbolinks'; import { Discussions } from './discussions'; @@ -25,13 +26,13 @@ const checkNewTimeoutDefault = 10000; const checkNewTimeoutMax = 60000; export interface InitialData { - beatmapset: BeatmapsetWithDiscussionsJson; reviews_config: { max_blocks: number; }; } interface Props { + beatmapsetSelectorId: string; container: HTMLElement; initial: InitialData; } @@ -54,7 +55,10 @@ export default class Main extends React.Component { constructor(props: Props) { super(props); - this.store = new BeatmapsetDiscussionsShowStore(this.props.initial.beatmapset); + // TODO: avoid reparsing/loading everything on browser navigation for better performance. + const beatmapset = parseJson(this.props.beatmapsetSelectorId); + + this.store = new BeatmapsetDiscussionsShowStore(beatmapset); this.discussionsState = new DiscussionsState(this.store, props.container.dataset.discussionsState); makeObservable(this); @@ -242,7 +246,7 @@ export default class Main extends React.Component { }; private readonly saveStateToContainer = () => { - this.props.container.dataset.beatmapset = JSON.stringify(this.store.beatmapset); + storeJson(this.props.beatmapsetSelectorId, this.store.beatmapset); this.props.container.dataset.discussionsState = this.discussionsState.toJsonString(); }; diff --git a/resources/js/entrypoints/beatmap-discussions.tsx b/resources/js/entrypoints/beatmap-discussions.tsx index 36e89f37891..dbe6ce079a0 100644 --- a/resources/js/entrypoints/beatmap-discussions.tsx +++ b/resources/js/entrypoints/beatmap-discussions.tsx @@ -2,28 +2,17 @@ // See the LICENCE file in the repository root for full licence text. import Main from 'beatmap-discussions/main'; -import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import core from 'osu-core-singleton'; import React from 'react'; -import BeatmapsetDiscussionsShowStore from 'stores/beatmapset-discussions-show-store'; import { parseJson } from 'utils/json'; -function parseJsonString(json?: string) { - if (json == null) return; - return JSON.parse(json) as T; -} -core.reactTurbolinks.register('beatmap-discussions', (container: HTMLElement) => { - // TODO: avoid reparsing/loading everything on browser navigation for better performance. - const beatmapset = parseJsonString(container.dataset.beatmapset) - ?? parseJson('json-beatmapset'); - return ( -
    - ); -}); +core.reactTurbolinks.register('beatmap-discussions', (container: HTMLElement) => ( +
    +)); From a1e335eec60ca62cf4e811805afad8c0c1486682 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 15 Nov 2023 18:29:41 +0900 Subject: [PATCH 105/203] remove initialData prop --- resources/js/beatmap-discussions/main.tsx | 17 ++++++++--------- .../js/entrypoints/beatmap-discussions.tsx | 4 +--- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index b7969bed4f8..2ab34963594 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -25,16 +25,16 @@ import { NewDiscussion } from './new-discussion'; const checkNewTimeoutDefault = 10000; const checkNewTimeoutMax = 60000; -export interface InitialData { - reviews_config: { +interface Props { + beatmapsetSelectorId: string; + container: HTMLElement; + reviewsConfig: { max_blocks: number; }; } -interface Props { - beatmapsetSelectorId: string; - container: HTMLElement; - initial: InitialData; +interface UpdateResponseJson { + beatmapset: BeatmapsetWithDiscussionsJson; } @observer @@ -47,10 +47,9 @@ export default class Main extends React.Component { private readonly modeSwitcherRef = React.createRef(); private readonly newDiscussionRef = React.createRef(); private nextTimeout = checkNewTimeoutDefault; - private readonly reviewsConfig = this.props.initial.reviews_config; @observable private readonly store; private timeoutCheckNew?: number; - private xhrCheckNew?: JQuery.jqXHR; + private xhrCheckNew?: JQuery.jqXHR; constructor(props: Props) { super(props); @@ -108,7 +107,7 @@ export default class Main extends React.Component { users={this.store.users} /> ) : ( - + {this.discussionsState.currentPage === 'reviews' ? (
    )); From 96d5acd975e7dbc6735951411b7c09e9286cbcf0 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 15 Nov 2023 19:59:48 +0900 Subject: [PATCH 106/203] store state into script element, remove container --- .../beatmap-discussions/discussions-state.ts | 60 ++++++++++--------- resources/js/beatmap-discussions/main.tsx | 17 +++--- .../js/entrypoints/beatmap-discussions.tsx | 8 +-- resources/js/utils/json.ts | 8 +-- 4 files changed, 45 insertions(+), 48 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 190f5cce1ce..d6af59ac6da 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -11,32 +11,47 @@ import core from 'osu-core-singleton'; import BeatmapsetDiscussionsShowStore from 'stores/beatmapset-discussions-show-store'; import { findDefault, group, sortWithMode } from 'utils/beatmap-helper'; import { canModeratePosts, makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper'; +import { parseJsonNullable, storeJson } from 'utils/json'; import { Filter, filters } from './current-discussions'; import DiscussionMode, { discussionModes } from './discussion-mode'; import DiscussionPage, { isDiscussionPage } from './discussion-page'; +const jsonId = 'json-discussions-state'; + export interface UpdateOptions { beatmap_discussion_post_ids: number[]; beatmapset: BeatmapsetWithDiscussionsJson; watching: boolean; } -function parseState(state: string) { +function replacer(key: string, value: unknown) { + // don't serialize constructor dependencies, they'll be handled separately. + if (key === 'beatmapset' || key === 'store') { + return undefined; + } + + if (value instanceof Set || value instanceof Map) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Array.from(value); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return JSON.parse(state, (key, value) => { - if (Array.isArray(value)) { - if (key === 'discussionCollapsed') { - return new Map(value); - } + return value; +} - if (key === 'readPostIds') { - return new Set(value); - } +function reviver(key: string, value: unknown) { + if (Array.isArray(value)) { + if (key === 'discussionCollapsed') { + return new Map(value); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return value; - }); + if (key === 'readPostIds') { + return new Set(value); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value; } function isFilter(value: unknown): value is Filter { @@ -304,9 +319,9 @@ export default class DiscussionsState { }); } - constructor(private readonly store: BeatmapsetDiscussionsShowStore, state?: string) { + constructor(private readonly store: BeatmapsetDiscussionsShowStore) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const existingState = state == null ? null : parseState(state); + const existingState = parseJsonNullable(jsonId, false, reviver); if (existingState != null) { Object.assign(this, existingState); @@ -397,21 +412,8 @@ export default class DiscussionsState { } } - toJsonString() { - return JSON.stringify(toJS(this), (key, value) => { - // don't serialize constructor dependencies, they'll be handled separately. - if (key === 'beatmapset' || key === 'store') { - return undefined; - } - - if (value instanceof Set || value instanceof Map) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return Array.from(value); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return value; - }); + saveState() { + storeJson(jsonId, toJS(this), replacer); } @action diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index 2ab34963594..e30f7af07e0 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -6,7 +6,7 @@ import { ReviewEditorConfigContext } from 'beatmap-discussions/review-editor-con import BackToTop from 'components/back-to-top'; import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import { route } from 'laroute'; -import { action, makeObservable, observable } from 'mobx'; +import { action, makeObservable, observable, toJS } from 'mobx'; import { observer } from 'mobx-react'; import core from 'osu-core-singleton'; import * as React from 'react'; @@ -24,10 +24,9 @@ import { NewDiscussion } from './new-discussion'; const checkNewTimeoutDefault = 10000; const checkNewTimeoutMax = 60000; +const beatmapsetJsonId = 'json-beatmapset'; interface Props { - beatmapsetSelectorId: string; - container: HTMLElement; reviewsConfig: { max_blocks: number; }; @@ -55,10 +54,10 @@ export default class Main extends React.Component { super(props); // TODO: avoid reparsing/loading everything on browser navigation for better performance. - const beatmapset = parseJson(this.props.beatmapsetSelectorId); + const beatmapset = parseJson(beatmapsetJsonId); this.store = new BeatmapsetDiscussionsShowStore(beatmapset); - this.discussionsState = new DiscussionsState(this.store, props.container.dataset.discussionsState); + this.discussionsState = new DiscussionsState(this.store); makeObservable(this); } @@ -68,7 +67,7 @@ export default class Main extends React.Component { $(document).on(`ajax:success.${this.eventId}`, '.js-beatmapset-discussion-update', this.ujsDiscussionUpdate); $(document).on(`click.${this.eventId}`, '.js-beatmap-discussion--jump', this.jumpToClick); - $(document).on(`turbolinks:before-cache.${this.eventId}`, this.saveStateToContainer); + $(document).on(`turbolinks:before-cache.${this.eventId}`, this.saveState); if (this.discussionsState.jumpToDiscussion) { this.disposers.add(core.reactTurbolinks.runAfterPageLoad(this.jumpToDiscussionByHash)); @@ -244,9 +243,9 @@ export default class Main extends React.Component { } }; - private readonly saveStateToContainer = () => { - storeJson(this.props.beatmapsetSelectorId, this.store.beatmapset); - this.props.container.dataset.discussionsState = this.discussionsState.toJsonString(); + private readonly saveState = () => { + storeJson(beatmapsetJsonId, toJS(this.store.beatmapset)); + this.discussionsState.saveState(); }; private readonly ujsDiscussionUpdate = (_event: unknown, beatmapset: BeatmapsetWithDiscussionsJson) => { diff --git a/resources/js/entrypoints/beatmap-discussions.tsx b/resources/js/entrypoints/beatmap-discussions.tsx index ad762d88b42..869a45c2974 100644 --- a/resources/js/entrypoints/beatmap-discussions.tsx +++ b/resources/js/entrypoints/beatmap-discussions.tsx @@ -7,10 +7,6 @@ import React from 'react'; import { parseJson } from 'utils/json'; -core.reactTurbolinks.register('beatmap-discussions', (container: HTMLElement) => ( -
    +core.reactTurbolinks.register('beatmap-discussions', () => ( +
    )); diff --git a/resources/js/utils/json.ts b/resources/js/utils/json.ts index d3276bef0ef..82979636b34 100644 --- a/resources/js/utils/json.ts +++ b/resources/js/utils/json.ts @@ -71,10 +71,10 @@ export function parseJson(id: string, remove = false): T { * @param id id of the HTMLScriptElement. * @param remove true to remove the element after parsing; false, otherwise. */ -export function parseJsonNullable(id: string, remove = false): T | undefined { +export function parseJsonNullable(id: string, remove = false, reviver?: (key: string, value: any) => any): T | undefined { const element = (window.newBody ?? document.body).querySelector(`#${id}`); if (!(element instanceof HTMLScriptElement)) return undefined; - const json = JSON.parse(element.text) as T; + const json = JSON.parse(element.text, reviver) as T; if (remove) { element.remove(); @@ -89,8 +89,8 @@ export function parseJsonNullable(id: string, remove = false): T | undefined * @param id id of the element to store to. Contents of an existing HTMLScriptElement will be overriden. * @param object state to store. */ -export function storeJson(id: string, object: unknown) { - const json = JSON.stringify(object); +export function storeJson(id: string, object: unknown, replacer?: (key: string, value: any) => any) { + const json = JSON.stringify(object, replacer); const maybeElement = document.getElementById(id); let element: HTMLScriptElement; From 41bbea6947fb745e9c4ffe598b4d298bd1a59346 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 15 Nov 2023 20:45:31 +0900 Subject: [PATCH 107/203] limit types getting serialized; no use serializing disposer, etc --- .../js/beatmap-discussions/discussions-state.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index d6af59ac6da..6696ac6a5f4 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -17,6 +17,7 @@ import DiscussionMode, { discussionModes } from './discussion-mode'; import DiscussionPage, { isDiscussionPage } from './discussion-page'; const jsonId = 'json-discussions-state'; +const serializableSimpleTypes = new Set(['boolean', 'number', 'string']); export interface UpdateOptions { beatmap_discussion_post_ids: number[]; @@ -25,18 +26,18 @@ export interface UpdateOptions { } function replacer(key: string, value: unknown) { - // don't serialize constructor dependencies, they'll be handled separately. - if (key === 'beatmapset' || key === 'store') { - return undefined; - } - if (value instanceof Set || value instanceof Map) { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return Array.from(value); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return value; + // assumes constructor params are objects and won't be serialized + // Also assumes Map and Set only have simple values. + if (key === '' || serializableSimpleTypes.has(typeof(value)) || value instanceof Array) { + return value; + } + + return undefined; } function reviver(key: string, value: unknown) { @@ -50,7 +51,6 @@ function reviver(key: string, value: unknown) { } } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; } From 4d46929f7c4208ac38802ab3ed02fbc8412a47ad Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 15 Nov 2023 20:46:50 +0900 Subject: [PATCH 108/203] undisable rule --- resources/js/beatmap-discussions/discussions-state.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 6696ac6a5f4..7ab44806359 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -320,7 +320,6 @@ export default class DiscussionsState { } constructor(private readonly store: BeatmapsetDiscussionsShowStore) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const existingState = parseJsonNullable(jsonId, false, reviver); if (existingState != null) { From 17a7641b2b0e928e21a4f6cd3ddd971437c21d8f Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 15 Nov 2023 22:03:10 +0900 Subject: [PATCH 109/203] invoke update directly --- .../js/beatmap-discussions/beatmap-owner-editor.tsx | 10 +++++----- .../js/beatmap-discussions/beatmaps-owner-editor.tsx | 3 +++ .../js/beatmap-discussions/discussion-vote-buttons.tsx | 7 +++++-- resources/js/beatmap-discussions/discussion.tsx | 5 ++++- resources/js/beatmap-discussions/editor.tsx | 7 ++++--- resources/js/beatmap-discussions/header.tsx | 2 +- .../js/beatmap-discussions/love-beatmap-dialog.tsx | 4 +++- resources/js/beatmap-discussions/main.tsx | 9 +-------- resources/js/beatmap-discussions/nominations.tsx | 2 ++ resources/js/beatmap-discussions/subscribe.tsx | 4 +++- 10 files changed, 31 insertions(+), 22 deletions(-) diff --git a/resources/js/beatmap-discussions/beatmap-owner-editor.tsx b/resources/js/beatmap-discussions/beatmap-owner-editor.tsx index 138f2755bcd..6c10a1060df 100644 --- a/resources/js/beatmap-discussions/beatmap-owner-editor.tsx +++ b/resources/js/beatmap-discussions/beatmap-owner-editor.tsx @@ -5,7 +5,7 @@ import { Spinner } from 'components/spinner'; import UserAvatar from 'components/user-avatar'; import UserLink from 'components/user-link'; import BeatmapJson from 'interfaces/beatmap-json'; -import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json'; +import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; import { action, computed, makeObservable, observable, runInAction } from 'mobx'; @@ -16,17 +16,17 @@ import { onErrorWithCallback } from 'utils/ajax'; import { classWithModifiers } from 'utils/css'; import { transparentGif } from 'utils/html'; import { trans } from 'utils/lang'; - -type BeatmapsetWithDiscussionJson = BeatmapsetExtendedJson; +import DiscussionsState from './discussions-state'; interface XhrCollection { - updateOwner: JQuery.jqXHR; + updateOwner: JQuery.jqXHR; userLookup: JQuery.jqXHR; } interface Props { beatmap: BeatmapJson; beatmapsetUser: UserJson; + discussionsState: DiscussionsState; user: UserJson; userByName: Map; } @@ -246,7 +246,7 @@ export default class BeatmapOwnerEditor extends React.Component { method: 'PUT', }); this.xhr.updateOwner.done((beatmapset) => runInAction(() => { - $.publish('beatmapsetDiscussions:update', { beatmapset }); + this.props.discussionsState.update({ beatmapset }); this.editing = false; })).fail(onErrorWithCallback(() => { this.updateOwner(userId); diff --git a/resources/js/beatmap-discussions/beatmaps-owner-editor.tsx b/resources/js/beatmap-discussions/beatmaps-owner-editor.tsx index cb2e54046f4..ca079a63d8a 100644 --- a/resources/js/beatmap-discussions/beatmaps-owner-editor.tsx +++ b/resources/js/beatmap-discussions/beatmaps-owner-editor.tsx @@ -10,9 +10,11 @@ import * as React from 'react'; import { group as groupBeatmaps } from 'utils/beatmap-helper'; import { trans } from 'utils/lang'; import BeatmapOwnerEditor from './beatmap-owner-editor'; +import DiscussionsState from './discussions-state'; interface Props { beatmapset: BeatmapsetExtendedJson; + discussionsState: DiscussionsState; onClose: () => void; users: Map; } @@ -61,6 +63,7 @@ export default class BeatmapsOwnerEditor extends React.Component { key={beatmap.id} beatmap={beatmap} beatmapsetUser={beatmapsetUser} + discussionsState={this.props.discussionsState} user={this.getUser(beatmap.user_id)} userByName={this.userByName} /> diff --git a/resources/js/beatmap-discussions/discussion-vote-buttons.tsx b/resources/js/beatmap-discussions/discussion-vote-buttons.tsx index 1f6cc648df1..5fea47f2fb4 100644 --- a/resources/js/beatmap-discussions/discussion-vote-buttons.tsx +++ b/resources/js/beatmap-discussions/discussion-vote-buttons.tsx @@ -3,6 +3,7 @@ import UserListPopup, { createTooltip } from 'components/user-list-popup'; import { BeatmapsetDiscussionJsonForShow } from 'interfaces/beatmapset-discussion-json'; +import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import UserJson from 'interfaces/user-json'; import { route } from 'laroute'; import { action, computed, makeObservable, observable } from 'mobx'; @@ -14,6 +15,7 @@ import { onError } from 'utils/ajax'; import { classWithModifiers } from 'utils/css'; import { trans } from 'utils/lang'; import { hideLoadingOverlay, showLoadingOverlay } from 'utils/loading-overlay'; +import DiscussionsState from './discussions-state'; const voteTypes = ['up', 'down'] as const; type VoteType = typeof voteTypes[number]; @@ -21,13 +23,14 @@ type VoteType = typeof voteTypes[number]; interface Props { cannotVote: boolean; discussion: BeatmapsetDiscussionJsonForShow; + discussionsState: DiscussionsState; users: Map; } @observer export default class DiscussionVoteButtons extends React.Component { private readonly tooltips: Partial> = {}; - @observable private voteXhr: JQuery.jqXHR | null = null; + @observable private voteXhr: JQuery.jqXHR | null = null; @computed private get canDownvote() { @@ -89,7 +92,7 @@ export default class DiscussionVoteButtons extends React.Component { }); this.voteXhr - .done((beatmapset) => $.publish('beatmapsetDiscussions:update', { beatmapset })) + .done((beatmapset) => this.props.discussionsState.update({ beatmapset })) .fail(onError) .always(action(() => { hideLoadingOverlay(); diff --git a/resources/js/beatmap-discussions/discussion.tsx b/resources/js/beatmap-discussions/discussion.tsx index 81977e20173..9b0e822c49e 100644 --- a/resources/js/beatmap-discussions/discussion.tsx +++ b/resources/js/beatmap-discussions/discussion.tsx @@ -247,7 +247,9 @@ export class Discussion extends React.Component { } private renderPostButtons() { - if (this.readonly || !isBeatmapsetDiscussionJsonForShow(this.props.discussion)) return null; + if (this.props.discussionsState == null || !isBeatmapsetDiscussionJsonForShow(this.props.discussion)) { + return null; + } const user = this.props.store.users.get(this.props.discussion.user_id); @@ -269,6 +271,7 @@ export class Discussion extends React.Component {
    - +
    >; + discussionsState: DiscussionsState; onClose: () => void; } @@ -128,7 +130,7 @@ export default class LoveBeatmapDialog extends React.Component { this.xhr = $.ajax(url, params); this.xhr.done((beatmapset) => { - $.publish('beatmapsetDiscussions:update', { beatmapset }); + this.props.discussionsState.update({ beatmapset }); this.props.onClose(); }).fail(onError) .always(action(() => { diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index e30f7af07e0..005f4cece21 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -16,7 +16,7 @@ import { parseJson, storeJson } from 'utils/json'; import { nextVal } from 'utils/seq'; import { currentUrl } from 'utils/turbolinks'; import { Discussions } from './discussions'; -import DiscussionsState, { UpdateOptions } from './discussions-state'; +import DiscussionsState from './discussions-state'; import { Events } from './events'; import { Header } from './header'; import { ModeSwitcher } from './mode-switcher'; @@ -63,8 +63,6 @@ export default class Main extends React.Component { } componentDidMount() { - $.subscribe(`beatmapsetDiscussions:update.${this.eventId}`, this.update); - $(document).on(`ajax:success.${this.eventId}`, '.js-beatmapset-discussion-update', this.ujsDiscussionUpdate); $(document).on(`click.${this.eventId}`, '.js-beatmap-discussion--jump', this.jumpToClick); $(document).on(`turbolinks:before-cache.${this.eventId}`, this.saveState); @@ -252,9 +250,4 @@ export default class Main extends React.Component { // to allow ajax:complete to be run window.setTimeout(() => this.discussionsState.update({ beatmapset }), 0); }; - - @action - private readonly update = (_event: unknown, options: Partial) => { - this.discussionsState.update(options); - }; } diff --git a/resources/js/beatmap-discussions/nominations.tsx b/resources/js/beatmap-discussions/nominations.tsx index 82722a01f4c..e80b4bf48b5 100644 --- a/resources/js/beatmap-discussions/nominations.tsx +++ b/resources/js/beatmap-discussions/nominations.tsx @@ -308,6 +308,7 @@ export class Nominations extends React.Component { @@ -492,6 +493,7 @@ export class Nominations extends React.Component { diff --git a/resources/js/beatmap-discussions/subscribe.tsx b/resources/js/beatmap-discussions/subscribe.tsx index e0ebdc6050e..1e69e9cc939 100644 --- a/resources/js/beatmap-discussions/subscribe.tsx +++ b/resources/js/beatmap-discussions/subscribe.tsx @@ -9,9 +9,11 @@ import { observer } from 'mobx-react'; import * as React from 'react'; import { onError } from 'utils/ajax'; import { trans } from 'utils/lang'; +import DiscussionsState from './discussions-state'; interface Props { beatmapset: BeatmapsetJson; + discussionsState: DiscussionsState; } @observer @@ -60,7 +62,7 @@ export class Subscribe extends React.Component { }); this.xhr.done(() => { - $.publish('beatmapsetDiscussions:update', { watching: !this.isWatching }); + this.props.discussionsState.update({ watching: !this.isWatching }); }) .fail(onError) .always(action(() => this.xhr = null)); From 6ee2a02f187c0d626c9a2be319c54a9686f2769a Mon Sep 17 00:00:00 2001 From: bakaneko Date: Wed, 15 Nov 2023 22:06:41 +0900 Subject: [PATCH 110/203] add note on which properties they're for ...maybe explicit key is better? :thinking: --- resources/js/beatmap-discussions/discussions-state.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 7ab44806359..d0eb269aff2 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -26,6 +26,7 @@ export interface UpdateOptions { } function replacer(key: string, value: unknown) { + // discussionCollapsed and readPostIds if (value instanceof Set || value instanceof Map) { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return Array.from(value); From cba066a9c9078bc081424ea6413fe65a8addb033 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 16 Nov 2023 16:55:37 +0900 Subject: [PATCH 111/203] store local observable; make mobx7 compatible --- .../js/stores/beatmapset-discussions-bundle-store.ts | 8 ++++++-- .../beatmapset-discussions-for-modding-profile-store.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/resources/js/stores/beatmapset-discussions-bundle-store.ts b/resources/js/stores/beatmapset-discussions-bundle-store.ts index 8fb10643c86..0b408683fc7 100644 --- a/resources/js/stores/beatmapset-discussions-bundle-store.ts +++ b/resources/js/stores/beatmapset-discussions-bundle-store.ts @@ -3,10 +3,13 @@ import BeatmapsetDiscussionsBundleJson from 'interfaces/beatmapset-discussions-bundle-json'; import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; -import { computed, makeObservable } from 'mobx'; +import { computed, makeObservable, observable } from 'mobx'; import { mapBy, mapByWithNulls } from 'utils/map'; export default class BeatmapsetDiscussionsBundleStore implements BeatmapsetDiscussionsStore { + /** TODO: accessor; readonly */ + @observable bundle; + @computed get beatmaps() { return mapBy(this.bundle.beatmaps, 'id'); @@ -28,7 +31,8 @@ export default class BeatmapsetDiscussionsBundleStore implements BeatmapsetDiscu return mapByWithNulls(this.bundle.users, 'id'); } - constructor(private readonly bundle: BeatmapsetDiscussionsBundleJson) { + constructor(bundle: BeatmapsetDiscussionsBundleJson) { + this.bundle = bundle; makeObservable(this); } } diff --git a/resources/js/stores/beatmapset-discussions-for-modding-profile-store.ts b/resources/js/stores/beatmapset-discussions-for-modding-profile-store.ts index 229abaed2ae..d36fa48cc96 100644 --- a/resources/js/stores/beatmapset-discussions-for-modding-profile-store.ts +++ b/resources/js/stores/beatmapset-discussions-for-modding-profile-store.ts @@ -3,10 +3,13 @@ import { BeatmapsetDiscussionsBundleJsonForModdingProfile } from 'interfaces/beatmapset-discussions-bundle-json'; import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; -import { computed, makeObservable } from 'mobx'; +import { computed, makeObservable, observable } from 'mobx'; import { mapBy, mapByWithNulls } from 'utils/map'; export default class BeatmapsetDiscussionsBundleForModdingProfileStore implements BeatmapsetDiscussionsStore { + /** TODO: accessor; readonly */ + @observable bundle; + @computed get beatmaps() { return mapBy(this.bundle.beatmaps, 'id'); @@ -27,7 +30,8 @@ export default class BeatmapsetDiscussionsBundleForModdingProfileStore implement return mapByWithNulls(this.bundle.users, 'id'); } - constructor(private readonly bundle: BeatmapsetDiscussionsBundleJsonForModdingProfile) { + constructor(bundle: BeatmapsetDiscussionsBundleJsonForModdingProfile) { + this.bundle = bundle; makeObservable(this); } } From 9ef120ed9a6ba8e6cb34ff464fe2e35b0b3cbe7b Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 16 Nov 2023 18:00:06 +0900 Subject: [PATCH 112/203] accessors (and thus observables) won't be iterable for stringify in mobx 7 --- .../beatmap-discussions/discussions-state.ts | 37 +++++++++---------- resources/js/utils/json.ts | 4 +- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index d0eb269aff2..f64eccdd65f 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -5,7 +5,7 @@ import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json'; import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json'; import GameMode from 'interfaces/game-mode'; import { maxBy } from 'lodash'; -import { action, computed, makeObservable, observable, reaction, toJS } from 'mobx'; +import { action, computed, makeObservable, observable, reaction } from 'mobx'; import moment from 'moment'; import core from 'osu-core-singleton'; import BeatmapsetDiscussionsShowStore from 'stores/beatmapset-discussions-show-store'; @@ -17,7 +17,6 @@ import DiscussionMode, { discussionModes } from './discussion-mode'; import DiscussionPage, { isDiscussionPage } from './discussion-page'; const jsonId = 'json-discussions-state'; -const serializableSimpleTypes = new Set(['boolean', 'number', 'string']); export interface UpdateOptions { beatmap_discussion_post_ids: number[]; @@ -25,22 +24,6 @@ export interface UpdateOptions { watching: boolean; } -function replacer(key: string, value: unknown) { - // discussionCollapsed and readPostIds - if (value instanceof Set || value instanceof Map) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return Array.from(value); - } - - // assumes constructor params are objects and won't be serialized - // Also assumes Map and Set only have simple values. - if (key === '' || serializableSimpleTypes.has(typeof(value)) || value instanceof Array) { - return value; - } - - return undefined; -} - function reviver(key: string, value: unknown) { if (Array.isArray(value)) { if (key === 'discussionCollapsed') { @@ -413,7 +396,23 @@ export default class DiscussionsState { } saveState() { - storeJson(jsonId, toJS(this), replacer); + storeJson(jsonId, this.toJson()); + } + + toJson() { + return { + currentBeatmapId: this.currentBeatmapId, + currentFilter: this.currentFilter, + currentPage: this.currentPage, + discussionCollapsed: [...this.discussionCollapsed], + discussionDefaultCollapsed: this.discussionDefaultCollapsed, + highlightedDiscussionId: this.highlightedDiscussionId, + jumpToDiscussion: this.jumpToDiscussion, + pinnedNewDiscussion: this.pinnedNewDiscussion, + readPostIds: [...this.readPostIds], + selectedUserId: this.selectedUserId, + showDeleted: this.showDeleted, + }; } @action diff --git a/resources/js/utils/json.ts b/resources/js/utils/json.ts index 82979636b34..9e871be5e1f 100644 --- a/resources/js/utils/json.ts +++ b/resources/js/utils/json.ts @@ -89,8 +89,8 @@ export function parseJsonNullable(id: string, remove = false, reviver?: (key: * @param id id of the element to store to. Contents of an existing HTMLScriptElement will be overriden. * @param object state to store. */ -export function storeJson(id: string, object: unknown, replacer?: (key: string, value: any) => any) { - const json = JSON.stringify(object, replacer); +export function storeJson(id: string, object: unknown) { + const json = JSON.stringify(object); const maybeElement = document.getElementById(id); let element: HTMLScriptElement; From ff92baf6d9404bc4383ae0d1f6dbe20f48788f44 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 16 Nov 2023 19:09:51 +0900 Subject: [PATCH 113/203] get array index with bounds check helper for mobx --- resources/js/beatmap-discussions/user-filter.tsx | 5 ++--- resources/js/utils/array.ts | 6 ++++++ 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 resources/js/utils/array.ts diff --git a/resources/js/beatmap-discussions/user-filter.tsx b/resources/js/beatmap-discussions/user-filter.tsx index c0308ea3968..1c790b54161 100644 --- a/resources/js/beatmap-discussions/user-filter.tsx +++ b/resources/js/beatmap-discussions/user-filter.tsx @@ -9,6 +9,7 @@ import { action, computed, makeObservable } from 'mobx'; import { observer } from 'mobx-react'; import { usernameSortAscending } from 'models/user'; import * as React from 'react'; +import { arrayGet } from 'utils/array'; import { makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper'; import { groupColour } from 'utils/css'; import { trans } from 'utils/lang'; @@ -95,10 +96,8 @@ export class UserFilter extends React.Component { private getGroup(option: Option) { if (this.isOwner(option)) return mapperGroup; - if (!option.groups) return null; - if (option.groups == null || option.groups.length === 0) return null; - return option.groups[0]; + return arrayGet(option.groups, 0); } @action diff --git a/resources/js/utils/array.ts b/resources/js/utils/array.ts new file mode 100644 index 00000000000..c9c7c9ae8da --- /dev/null +++ b/resources/js/utils/array.ts @@ -0,0 +1,6 @@ +// 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. + +export function arrayGet(array: T[] | null | undefined, index: number): T | undefined { + return array != null && array.length > index ? array[index] : undefined; +} From cca62cf0302b33fdcf7be293d31d1c9779b230b3 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 16 Nov 2023 19:11:05 +0900 Subject: [PATCH 114/203] rename for mobx since oob behaviour is specific for mobx warnings --- resources/js/beatmap-discussions/user-filter.tsx | 4 ++-- resources/js/utils/array.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/js/beatmap-discussions/user-filter.tsx b/resources/js/beatmap-discussions/user-filter.tsx index 1c790b54161..840b529a6e3 100644 --- a/resources/js/beatmap-discussions/user-filter.tsx +++ b/resources/js/beatmap-discussions/user-filter.tsx @@ -9,7 +9,7 @@ import { action, computed, makeObservable } from 'mobx'; import { observer } from 'mobx-react'; import { usernameSortAscending } from 'models/user'; import * as React from 'react'; -import { arrayGet } from 'utils/array'; +import { mobxArrayGet } from 'utils/array'; import { makeUrl, parseUrl } from 'utils/beatmapset-discussion-helper'; import { groupColour } from 'utils/css'; import { trans } from 'utils/lang'; @@ -97,7 +97,7 @@ export class UserFilter extends React.Component { private getGroup(option: Option) { if (this.isOwner(option)) return mapperGroup; - return arrayGet(option.groups, 0); + return mobxArrayGet(option.groups, 0); } @action diff --git a/resources/js/utils/array.ts b/resources/js/utils/array.ts index c9c7c9ae8da..b1e4dcbc20f 100644 --- a/resources/js/utils/array.ts +++ b/resources/js/utils/array.ts @@ -1,6 +1,6 @@ // 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. -export function arrayGet(array: T[] | null | undefined, index: number): T | undefined { +export function mobxArrayGet(array: T[] | null | undefined, index: number): T | undefined { return array != null && array.length > index ? array[index] : undefined; } From c4b0b313458eb9238df2cd97ae01a62b33a368ee Mon Sep 17 00:00:00 2001 From: nanaya Date: Fri, 1 Dec 2023 21:49:38 +0900 Subject: [PATCH 115/203] Add oauth verification --- app/Http/Controllers/UsersController.php | 4 +- app/Http/Middleware/AuthApi.php | 11 ++- app/Http/Middleware/UpdateUserLastvisit.php | 3 +- app/Http/Middleware/VerifyUser.php | 1 + app/Libraries/OAuth/RefreshTokenGrant.php | 40 +++++++++ .../SessionVerification/Controller.php | 12 +-- app/Libraries/SessionVerification/Helper.php | 15 +++- app/Models/OAuth/Token.php | 51 ++++++++++- app/Models/Traits/FasterAttributes.php | 7 ++ app/Providers/PassportServiceProvider.php | 29 +++++++ app/Transformers/UserCompactTransformer.php | 6 ++ app/helpers.php | 2 +- config/app.php | 1 + database/factories/OAuth/TokenFactory.php | 3 +- ...verified_column_to_oauth_access_tokens.php | 27 ++++++ resources/views/docs/_structures/user.md | 1 + routes/web.php | 5 ++ .../OAuth/TokensControllerTest.php | 85 +++++++++++++++++++ .../SessionVerification/ControllerTest.php | 53 ++++++++++++ tests/TestCase.php | 9 +- tests/api_routes.json | 36 ++++++++ 21 files changed, 377 insertions(+), 24 deletions(-) create mode 100644 app/Libraries/OAuth/RefreshTokenGrant.php create mode 100644 app/Providers/PassportServiceProvider.php create mode 100644 database/migrations/2023_12_18_104437_add_verified_column_to_oauth_access_tokens.php diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index b92a8bd65e1..42501f459e4 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -540,6 +540,7 @@ public function scores($_userId, $type) * * See [Get User](#get-user). * + * `session_verified` attribute is included. * Additionally, `statistics_rulesets` is included, containing statistics for all rulesets. * * @urlParam mode string [Ruleset](#ruleset). User default mode will be used if not specified. Example: osu @@ -548,7 +549,7 @@ public function scores($_userId, $type) */ public function me($mode = null) { - $user = auth()->user(); + $user = \Auth::user(); $currentMode = $mode ?? $user->playmode; if (!Beatmap::isModeValid($currentMode)) { @@ -561,6 +562,7 @@ public function me($mode = null) $user, (new UserTransformer())->setMode($currentMode), [ + 'session_verified', ...$this->showUserIncludes(), ...array_map( fn (string $ruleset) => "statistics_rulesets.{$ruleset}", diff --git a/app/Http/Middleware/AuthApi.php b/app/Http/Middleware/AuthApi.php index 592c1ef5a17..d7f6a50ecf8 100644 --- a/app/Http/Middleware/AuthApi.php +++ b/app/Http/Middleware/AuthApi.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; +use App\Libraries\SessionVerification; use Closure; use Illuminate\Auth\AuthenticationException; use Laravel\Passport\ClientRepository; @@ -95,10 +96,14 @@ private function validTokenFromRequest($psr) } if ($user !== null) { - auth()->setUser($user); + \Auth::setUser($user); $user->withAccessToken($token); - // this should match osu-notification-server OAuthVerifier - $user->markSessionVerified(); + + if ($token->isVerified()) { + $user->markSessionVerified(); + } else { + SessionVerification\Helper::issue($token, $user, true); + } } return $token; diff --git a/app/Http/Middleware/UpdateUserLastvisit.php b/app/Http/Middleware/UpdateUserLastvisit.php index a817773224a..02bf1f984f8 100644 --- a/app/Http/Middleware/UpdateUserLastvisit.php +++ b/app/Http/Middleware/UpdateUserLastvisit.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; +use App\Libraries\SessionVerification; use App\Models\Country; use Carbon\Carbon; use Closure; @@ -30,7 +31,7 @@ public function handle($request, Closure $next) if ($shouldUpdate) { $isInactive = $user->isInactive(); if ($isInactive) { - $isVerified = $user->isSessionVerified(); + $isVerified = SessionVerification\Helper::currentSession()->isVerified(); } if (!$isInactive || $isVerified) { diff --git a/app/Http/Middleware/VerifyUser.php b/app/Http/Middleware/VerifyUser.php index f50855bf4da..88c01549b4e 100644 --- a/app/Http/Middleware/VerifyUser.php +++ b/app/Http/Middleware/VerifyUser.php @@ -20,6 +20,7 @@ class VerifyUser 'notifications_controller@endpoint' => true, 'sessions_controller@destroy' => true, 'sessions_controller@store' => true, + 'users_controller@me' => true, 'wiki_controller@image' => true, 'wiki_controller@show' => true, 'wiki_controller@sitemap' => true, diff --git a/app/Libraries/OAuth/RefreshTokenGrant.php b/app/Libraries/OAuth/RefreshTokenGrant.php new file mode 100644 index 00000000000..690aca36490 --- /dev/null +++ b/app/Libraries/OAuth/RefreshTokenGrant.php @@ -0,0 +1,40 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries\OAuth; + +use App\Models\OAuth\Token; +use League\OAuth2\Server\Grant\RefreshTokenGrant as BaseRefreshTokenGrant; +use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; +use Psr\Http\Message\ServerRequestInterface; + +class RefreshTokenGrant extends BaseRefreshTokenGrant +{ + private ?array $oldRefreshToken = null; + + public function respondToAccessTokenRequest( + ServerRequestInterface $request, + ResponseTypeInterface $responseType, + \DateInterval $accessTokenTTL + ) { + $refreshTokenData = parent::respondToAccessTokenRequest($request, $responseType, $accessTokenTTL); + + // Copy previous verification state + $accessToken = (new \ReflectionProperty($refreshTokenData, 'accessToken'))->getValue($refreshTokenData); + Token::where('id', $accessToken->getIdentifier())->update([ + 'verified' => Token::select('verified')->find($this->oldRefreshToken['access_token_id'])->verified, + ]); + $this->oldRefreshToken = null; + + return $refreshTokenData; + } + + protected function validateOldRefreshToken(ServerRequestInterface $request, $clientId) + { + return $this->oldRefreshToken = parent::validateOldRefreshToken($request, $clientId); + } +} diff --git a/app/Libraries/SessionVerification/Controller.php b/app/Libraries/SessionVerification/Controller.php index 0333f65b192..a115cc41bd2 100644 --- a/app/Libraries/SessionVerification/Controller.php +++ b/app/Libraries/SessionVerification/Controller.php @@ -21,11 +21,11 @@ public static function initiate() $user = Helper::currentUserOrFail(); $email = $user->user_email; - $session = \Session::instance(); - if (State::fromSession($session) === null) { - Helper::logAttempt('input', 'new'); + $session = Helper::currentSession(); + Helper::issue($session, $user, true); - Helper::issue($session, $user); + if (is_api_request()) { + return response(null, $statusCode); } if (\Request::ajax()) { @@ -43,7 +43,7 @@ public static function initiate() public static function reissue() { - $session = \Session::instance(); + $session = Helper::currentSession(); if ($session->isVerified()) { return response(null, 204); } @@ -57,7 +57,7 @@ public static function verify() { $key = strtr(get_string(\Request::input('verification_key')) ?? '', [' ' => '']); $user = Helper::currentUserOrFail(); - $session = \Session::instance(); + $session = Helper::currentSession(); $state = State::fromSession($session); try { diff --git a/app/Libraries/SessionVerification/Helper.php b/app/Libraries/SessionVerification/Helper.php index aa44cf8c374..cb9a8c53e6b 100644 --- a/app/Libraries/SessionVerification/Helper.php +++ b/app/Libraries/SessionVerification/Helper.php @@ -15,6 +15,11 @@ class Helper { + public static function currentSession(): ?SessionVerificationInterface + { + return is_api_request() ? oauth_token() : \Session::instance(); + } + public static function currentUserOrFail(): User { $user = \Auth::user(); @@ -23,8 +28,16 @@ public static function currentUserOrFail(): User return $user; } - public static function issue(SessionVerificationInterface $session, User $user): void + public static function issue(SessionVerificationInterface $session, User $user, bool $initial = false): void { + if ($initial) { + if (State::fromSession($session) === null) { + static::logAttempt('input', 'new'); + } else { + return; + } + } + if (!is_valid_email_format($user->user_email)) { return; } diff --git a/app/Models/OAuth/Token.php b/app/Models/OAuth/Token.php index 402388f50cf..526168427f3 100644 --- a/app/Models/OAuth/Token.php +++ b/app/Models/OAuth/Token.php @@ -7,6 +7,7 @@ use App\Events\UserSessionEvent; use App\Exceptions\InvalidScopeException; +use App\Interfaces\SessionVerificationInterface; use App\Models\Traits\FasterAttributes; use App\Models\User; use Ds\Set; @@ -14,13 +15,25 @@ use Laravel\Passport\RefreshToken; use Laravel\Passport\Token as PassportToken; -class Token extends PassportToken +class Token extends PassportToken implements SessionVerificationInterface { // PassportToken doesn't have factory use HasFactory, FasterAttributes; + protected $casts = [ + 'expires_at' => 'datetime', + 'revoked' => 'boolean', + 'scopes' => 'array', + 'verified' => 'boolean', + ]; + private ?Set $scopeSet; + public static function findForVerification(string $id): ?static + { + return static::find($id); + } + public function refreshToken() { return $this->hasOne(RefreshToken::class, 'access_token_id'); @@ -49,8 +62,10 @@ public function getAttribute($key) 'name', 'user_id' => $this->getRawAttribute($key), - 'revoked' => (bool) $this->getRawAttribute($key), - 'scopes' => json_decode($this->getRawAttribute($key), true), + 'revoked', + 'verified' => $this->getNullableBool($key), + + 'scopes' => json_decode($this->getRawAttribute($key) ?? 'null', true), 'created_at', 'expires_at', @@ -62,6 +77,11 @@ public function getAttribute($key) }; } + public function getKeyForEvent(): string + { + return "oauth:{$this->getKey()}"; + } + /** * Resource owner for the token. * @@ -90,6 +110,16 @@ public function isOwnToken(): bool return $clientUserId !== null && $clientUserId === $this->user_id; } + public function isVerified(): bool + { + return $this->verified; + } + + public function markVerified(): void + { + $this->update(['verified' => true]); + } + public function revokeRecursive() { $result = $this->revoke(); @@ -103,7 +133,7 @@ public function revoke() $saved = parent::revoke(); if ($saved && $this->user_id !== null) { - UserSessionEvent::newLogout($this->user_id, ["oauth:{$this->getKey()}"])->broadcast(); + UserSessionEvent::newLogout($this->user_id, [$this->getKeyForEvent()])->broadcast(); } return $saved; @@ -124,6 +154,11 @@ public function setScopesAttribute(?array $value) $this->attributes['scopes'] = $this->castAttributeAsJson('scopes', $value); } + public function userId(): ?int + { + return $this->user_id; + } + public function validate(): void { static $scopesRequireDelegation = new Set(['chat.write', 'chat.write_manage', 'delegate']); @@ -185,6 +220,9 @@ public function save(array $options = []) { // Forces error if passport tries to issue an invalid client_credentials token. $this->validate(); + if (!$this->exists) { + $this->setVerifiedState(); + } return parent::save($options); } @@ -193,4 +231,9 @@ private function scopeSet(): Set { return $this->scopeSet ??= new Set($this->scopes ?? []); } + + private function setVerifiedState(): void + { + $this->verified ??= $this->user === null || !$this->client->password_client; + } } diff --git a/app/Models/Traits/FasterAttributes.php b/app/Models/Traits/FasterAttributes.php index f54fc880274..e0e4d7e1452 100644 --- a/app/Models/Traits/FasterAttributes.php +++ b/app/Models/Traits/FasterAttributes.php @@ -16,6 +16,13 @@ public function getRawAttribute(string $key) return $this->attributes[$key] ?? null; } + protected function getNullableBool(string $key) + { + $raw = $this->getRawAttribute($key); + + return $raw === null ? null : (bool) $raw; + } + /** * Fast Time Attribute to Json Transformer * diff --git a/app/Providers/PassportServiceProvider.php b/app/Providers/PassportServiceProvider.php new file mode 100644 index 00000000000..254863109e0 --- /dev/null +++ b/app/Providers/PassportServiceProvider.php @@ -0,0 +1,29 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Providers; + +use App\Libraries\OAuth\RefreshTokenGrant; +use Laravel\Passport\Bridge\RefreshTokenRepository; +use Laravel\Passport\Passport; +use Laravel\Passport\PassportServiceProvider as BasePassportServiceProvider; + +class PassportServiceProvider extends BasePassportServiceProvider +{ + /** + * Overrides RefreshTokenGrant to copy verified attribute of the token + */ + protected function makeRefreshTokenGrant() + { + $repository = $this->app->make(RefreshTokenRepository::class); + + $grant = new RefreshTokenGrant($repository); + $grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn()); + + return $grant; + } +} diff --git a/app/Transformers/UserCompactTransformer.php b/app/Transformers/UserCompactTransformer.php index 9532222aebe..a8269628298 100644 --- a/app/Transformers/UserCompactTransformer.php +++ b/app/Transformers/UserCompactTransformer.php @@ -86,6 +86,7 @@ class UserCompactTransformer extends TransformerAbstract 'scores_first_count', 'scores_pinned_count', 'scores_recent_count', + 'session_verified', 'statistics', 'statistics_rulesets', 'support_level', @@ -405,6 +406,11 @@ public function includeScoresRecentCount(User $user) return $this->primitive($user->scores($this->mode, true)->includeFails(false)->count()); } + public function includeSessionVerified(User $user) + { + return $this->primitive($user->token()?->isVerified() ?? false); + } + public function includeStatistics(User $user) { $stats = $user->statistics($this->mode); diff --git a/app/helpers.php b/app/helpers.php index 4929b170b43..cb6ef36bd9b 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -539,7 +539,7 @@ function mysql_escape_like($string) function oauth_token(): ?App\Models\OAuth\Token { - return request()->attributes->get(App\Http\Middleware\AuthApi::REQUEST_OAUTH_TOKEN_KEY); + return Request::instance()->attributes->get(App\Http\Middleware\AuthApi::REQUEST_OAUTH_TOKEN_KEY); } function osu_trans($key = null, $replace = [], $locale = null) diff --git a/config/app.php b/config/app.php index d39297d1714..9217d6732b2 100644 --- a/config/app.php +++ b/config/app.php @@ -186,6 +186,7 @@ App\Providers\EventServiceProvider::class, // Override default migrate:fresh App\Providers\MigrationServiceProvider::class, + App\Providers\PassportServiceProvider::class, App\Providers\RouteServiceProvider::class, // Override the session id naming (for redis key namespacing) App\Providers\SessionServiceProvider::class, diff --git a/database/factories/OAuth/TokenFactory.php b/database/factories/OAuth/TokenFactory.php index a956346bcc1..c5857ab6864 100644 --- a/database/factories/OAuth/TokenFactory.php +++ b/database/factories/OAuth/TokenFactory.php @@ -23,8 +23,9 @@ public function definition(): array 'expires_at' => fn () => now()->addDays(), 'id' => str_random(40), 'revoked' => false, - 'scopes' => ['public'], + 'scopes' => ['identify', 'public'], 'user_id' => User::factory(), + 'verified' => true, ]; } } diff --git a/database/migrations/2023_12_18_104437_add_verified_column_to_oauth_access_tokens.php b/database/migrations/2023_12_18_104437_add_verified_column_to_oauth_access_tokens.php new file mode 100644 index 00000000000..6510da683c6 --- /dev/null +++ b/database/migrations/2023_12_18_104437_add_verified_column_to_oauth_access_tokens.php @@ -0,0 +1,27 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::table('oauth_access_tokens', function (Blueprint $table) { + $table->boolean('verified')->default(true); + }); + } + + public function down(): void + { + Schema::table('oauth_access_tokens', function (Blueprint $table) { + $table->dropColumn('verified'); + }); + } +}; diff --git a/resources/views/docs/_structures/user.md b/resources/views/docs/_structures/user.md index 56e706c5a85..37e52d70201 100644 --- a/resources/views/docs/_structures/user.md +++ b/resources/views/docs/_structures/user.md @@ -69,6 +69,7 @@ replays_watched_counts | | scores_best_count | integer scores_first_count | integer scores_recent_count | integer +session_verified | boolean statistics | | statistics_rulesets | UserStatisticsRulesets support_level | | diff --git a/routes/web.php b/routes/web.php index cdf21216f54..7e113e9434f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -403,6 +403,11 @@ // There's also a different group which skips throttle middleware. Route::group(['as' => 'api.', 'prefix' => 'api', 'middleware' => ['api', ThrottleRequests::getApiThrottle(), 'require-scopes']], function () { Route::group(['prefix' => 'v2'], function () { + Route::group(['middleware' => ['require-scopes:identify']], function () { + Route::post('verify-session', 'AccountController@verify')->name('verify'); + Route::post('verify-session/reissue', 'AccountController@reissueCode')->name('verify.reissue'); + }); + Route::group(['as' => 'beatmaps.', 'prefix' => 'beatmaps'], function () { Route::get('lookup', 'BeatmapsController@lookup')->name('lookup'); diff --git a/tests/Controllers/OAuth/TokensControllerTest.php b/tests/Controllers/OAuth/TokensControllerTest.php index 296fef5edd0..fe641294377 100644 --- a/tests/Controllers/OAuth/TokensControllerTest.php +++ b/tests/Controllers/OAuth/TokensControllerTest.php @@ -5,12 +5,25 @@ namespace Tests\Controllers\OAuth; +use App\Mail\UserVerification as UserVerificationMail; use App\Models\OAuth\Token; +use App\Models\User; +use Database\Factories\OAuth\ClientFactory; use Database\Factories\OAuth\RefreshTokenFactory; +use Database\Factories\UserFactory; +use Defuse\Crypto\Crypto; use Tests\TestCase; class TokensControllerTest extends TestCase { + public static function dataProviderForTestIssueTokenWithRefreshTokenInheritsVerified(): array + { + return [ + [true], + [false], + ]; + } + public function testDestroyCurrent() { $refreshToken = (new RefreshTokenFactory())->create(); @@ -36,4 +49,76 @@ public function testDestroyCurrentClientGrant() $this->assertTrue($token->fresh()->revoked); } + + public function testIssueTokenWithPassword(): void + { + \Mail::fake(); + + $user = User::factory()->create(); + $client = (new ClientFactory())->create([ + 'password_client' => true, + ]); + + $this->expectCountChange(fn () => $user->tokens()->count(), 1); + + $tokenResp = $this->json('POST', route('oauth.passport.token'), [ + 'grant_type' => 'password', + 'client_id' => $client->getKey(), + 'client_secret' => $client->secret, + 'scope' => '*', + 'username' => $user->username, + 'password' => UserFactory::DEFAULT_PASSWORD, + ])->assertSuccessful(); + $tokenJson = json_decode($tokenResp->getContent(), true); + + $meResp = $this->json('GET', route('api.me'), [], [ + 'Authorization' => "Bearer {$tokenJson['access_token']}", + ])->assertSuccessful(); + $meJson = json_decode($meResp->getContent(), true); + + $this->assertFalse($meJson['session_verified']); + // unverified access to api should trigger this but not necessarily return 401 + \Mail::assertQueued(UserVerificationMail::class); + } + + /** + * @dataProvider dataProviderForTestIssueTokenWithRefreshTokenInheritsVerified + */ + public function testIssueTokenWithRefreshTokenInheritsVerified(bool $verified): void + { + \Mail::fake(); + + $refreshToken = (new RefreshTokenFactory())->create(); + $accessToken = $refreshToken->accessToken; + $accessToken->forceFill(['scopes' => ['*'], 'verified' => $verified])->save(); + $client = $accessToken->client; + $user = $accessToken->user; + $refreshTokenString = Crypto::encryptWithPassword(json_encode([ + 'client_id' => (string) $client->getKey(), + 'refresh_token_id' => $refreshToken->getKey(), + 'access_token_id' => $accessToken->getKey(), + 'scopes' => $accessToken->scopes, + 'user_id' => $user->getKey(), + 'expire_time' => $refreshToken->expires_at->getTimestamp(), + ]), \Crypt::getKey()); + + $this->expectCountChange(fn () => $user->tokens()->count(), 1); + + $tokenResp = $this->json('POST', route('oauth.passport.token'), [ + 'grant_type' => 'refresh_token', + 'client_id' => $client->getKey(), + 'client_secret' => $client->secret, + 'refresh_token' => $refreshTokenString, + 'scope' => implode(' ', $accessToken->scopes), + ])->assertSuccessful(); + $tokenJson = json_decode($tokenResp->getContent(), true); + + $meResp = $this->json('GET', route('api.me'), [], [ + 'Authorization' => "Bearer {$tokenJson['access_token']}", + ])->assertSuccessful(); + $meJson = json_decode($meResp->getContent(), true); + + $this->assertSame($meJson['session_verified'], $verified); + \Mail::assertQueued(UserVerificationMail::class, $verified ? 0 : 1); + } } diff --git a/tests/Libraries/SessionVerification/ControllerTest.php b/tests/Libraries/SessionVerification/ControllerTest.php index 58f3ecc3da9..b7a6a8e8504 100644 --- a/tests/Libraries/SessionVerification/ControllerTest.php +++ b/tests/Libraries/SessionVerification/ControllerTest.php @@ -11,6 +11,8 @@ use App\Libraries\SessionVerification; use App\Mail\UserVerification as UserVerificationMail; use App\Models\LoginAttempt; +use App\Models\OAuth\Client; +use App\Models\OAuth\Token; use App\Models\User; use Tests\TestCase; @@ -131,6 +133,32 @@ public function testVerifyLinkMismatch(): void $this->assertFalse(SessionStore::findOrNew($sessionId)->isVerified()); } + public function testVerifyLinkOAuth(): void + { + $token = Token::factory()->create([ + 'client_id' => Client::factory()->create(['password_client' => true]), + 'verified' => false, + ]); + + $this + ->actingWithToken($token) + ->get(route('api.me')) + ->assertSuccessful(); + + $linkKey = SessionVerification\State::fromSession($token)->linkKey; + + \Auth::logout(); + $this + ->withPersistentSession(SessionStore::findOrNew()) + ->get(route('account.verify', ['key' => $linkKey])) + ->assertSuccessful(); + + $record = LoginAttempt::find('127.0.0.1'); + + $this->assertFalse($record->containsUser($token->user, 'verify-mismatch:')); + $this->assertTrue($token->fresh()->isVerified()); + } + public function testVerifyMismatch(): void { $user = User::factory()->create(); @@ -156,4 +184,29 @@ public function testVerifyMismatch(): void $this->assertTrue($record->containsUser($user, 'verify-mismatch:')); $this->assertFalse($session->isVerified()); } + + public function testVerifyOAuth(): void + { + $token = Token::factory()->create([ + 'client_id' => Client::factory()->create(['password_client' => true]), + 'verified' => false, + ]); + + $this + ->actingWithToken($token) + ->get(route('api.me')) + ->assertSuccessful(); + + $key = SessionVerification\State::fromSession($token)->key; + + $this + ->actingWithToken($token) + ->post(route('api.verify', ['verification_key' => $key])) + ->assertSuccessful(); + + $record = LoginAttempt::find('127.0.0.1'); + + $this->assertFalse($record->containsUser($token->user, 'verify-mismatch:')); + $this->assertTrue($token->fresh()->isVerified()); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 7fe0f8462e2..5b0852dbcfb 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -245,17 +245,14 @@ protected function clearMailFake() */ protected function createToken(?User $user, ?array $scopes = null, ?Client $client = null) { - $client ??= Client::factory()->create(); - - $token = $client->tokens()->create([ + return ($client ?? Client::factory()->create())->tokens()->create([ 'expires_at' => now()->addDays(1), 'id' => uniqid(), 'revoked' => false, 'scopes' => $scopes, - 'user_id' => optional($user)->getKey(), + 'user_id' => $user?->getKey(), + 'verified' => true, ]); - - return $token; } protected function expectCountChange(callable $callback, int $change, string $message = '') diff --git a/tests/api_routes.json b/tests/api_routes.json index b2d1ca6eb11..50bc3234933 100644 --- a/tests/api_routes.json +++ b/tests/api_routes.json @@ -1,4 +1,40 @@ [ + { + "uri": "api/v2/verify-session", + "methods": [ + "POST" + ], + "controller": "App\\Http\\Controllers\\AccountController@verify", + "middlewares": [ + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", + "App\\Http\\Middleware\\RequireScopes", + "App\\Http\\Middleware\\RequireScopes:identify", + "Illuminate\\Auth\\Middleware\\Authenticate", + "App\\Http\\Middleware\\VerifyUser", + "App\\Http\\Middleware\\ThrottleRequests:60,10" + ], + "scopes": [ + "identify" + ] + }, + { + "uri": "api/v2/verify-session/reissue", + "methods": [ + "POST" + ], + "controller": "App\\Http\\Controllers\\AccountController@reissueCode", + "middlewares": [ + "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", + "App\\Http\\Middleware\\RequireScopes", + "App\\Http\\Middleware\\RequireScopes:identify", + "Illuminate\\Auth\\Middleware\\Authenticate", + "App\\Http\\Middleware\\VerifyUser", + "App\\Http\\Middleware\\ThrottleRequests:60,10" + ], + "scopes": [ + "identify" + ] + }, { "uri": "api/v2/beatmaps/lookup", "methods": [ From 67cbfd1ae3fa96300bd62e6150c67c6bb7cff67f Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 18 Dec 2023 21:15:40 +0900 Subject: [PATCH 116/203] Note on default verified value --- app/Models/OAuth/Token.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Models/OAuth/Token.php b/app/Models/OAuth/Token.php index 526168427f3..56e53f31fec 100644 --- a/app/Models/OAuth/Token.php +++ b/app/Models/OAuth/Token.php @@ -234,6 +234,8 @@ private function scopeSet(): Set private function setVerifiedState(): void { + // client credential doesn't have user attached and auth code is + // already verified during grant process $this->verified ??= $this->user === null || !$this->client->password_client; } } From c89e861bc0ad0ee4b5ebf2687c2530608d5eb8c9 Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 18 Dec 2023 21:21:49 +0900 Subject: [PATCH 117/203] Allow any scopes for verification --- routes/web.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/web.php b/routes/web.php index 7e113e9434f..b255721faae 100644 --- a/routes/web.php +++ b/routes/web.php @@ -403,7 +403,7 @@ // There's also a different group which skips throttle middleware. Route::group(['as' => 'api.', 'prefix' => 'api', 'middleware' => ['api', ThrottleRequests::getApiThrottle(), 'require-scopes']], function () { Route::group(['prefix' => 'v2'], function () { - Route::group(['middleware' => ['require-scopes:identify']], function () { + Route::group(['middleware' => ['require-scopes:any']], function () { Route::post('verify-session', 'AccountController@verify')->name('verify'); Route::post('verify-session/reissue', 'AccountController@reissueCode')->name('verify.reissue'); }); From edac1b864149748c719eeae0a97db6b0b4f4c312 Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 18 Dec 2023 21:34:05 +0900 Subject: [PATCH 118/203] Fix handling already verified session --- .../SessionVerification/Controller.php | 8 ++- .../SessionVerification/ControllerTest.php | 62 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/app/Libraries/SessionVerification/Controller.php b/app/Libraries/SessionVerification/Controller.php index a115cc41bd2..55160944991 100644 --- a/app/Libraries/SessionVerification/Controller.php +++ b/app/Libraries/SessionVerification/Controller.php @@ -45,7 +45,7 @@ public static function reissue() { $session = Helper::currentSession(); if ($session->isVerified()) { - return response(null, 204); + return response(null, 422); } Helper::issue($session, Helper::currentUserOrFail()); @@ -55,9 +55,13 @@ public static function reissue() public static function verify() { + $session = Helper::currentSession(); + if ($session->isVerified()) { + return response(null, 204); + } + $key = strtr(get_string(\Request::input('verification_key')) ?? '', [' ' => '']); $user = Helper::currentUserOrFail(); - $session = Helper::currentSession(); $state = State::fromSession($session); try { diff --git a/tests/Libraries/SessionVerification/ControllerTest.php b/tests/Libraries/SessionVerification/ControllerTest.php index b7a6a8e8504..6589cb63480 100644 --- a/tests/Libraries/SessionVerification/ControllerTest.php +++ b/tests/Libraries/SessionVerification/ControllerTest.php @@ -58,6 +58,37 @@ public function testReissue(): void $this->assertNotSame($state->key, SessionVerification\State::fromSession($session)->key); } + public function testReissueOAuthVerified(): void + { + \Mail::fake(); + $token = Token::factory()->create(['verified' => true]); + + $this + ->actingWithToken($token) + ->post(route('api.verify.reissue')) + ->assertStatus(422); + + \Mail::assertNotQueued(UserVerificationMail::class); + $this->assertNull(SessionVerification\State::fromSession($token)); + } + + public function testReissueVerified(): void + { + \Mail::fake(); + $user = User::factory()->create(); + $session = \Session::instance(); + $session->markVerified(); + + $this + ->be($user) + ->withPersistentSession($session) + ->post(route('account.reissue-code')) + ->assertStatus(422); + + \Mail::assertNotQueued(UserVerificationMail::class); + $this->assertNull(SessionVerification\State::fromSession($session)); + } + public function testVerify(): void { $user = User::factory()->create(); @@ -209,4 +240,35 @@ public function testVerifyOAuth(): void $this->assertFalse($record->containsUser($token->user, 'verify-mismatch:')); $this->assertTrue($token->fresh()->isVerified()); } + + public function testVerifyOAuthVerified(): void + { + \Mail::fake(); + $token = Token::factory()->create(['verified' => true]); + + $this + ->actingWithToken($token) + ->post(route('api.verify', ['verification_key' => 'invalid'])) + ->assertSuccessful(); + + $this->assertNull(SessionVerification\State::fromSession($token)); + \Mail::assertNotQueued(UserVerificationMail::class); + } + + public function testVerifyVerified(): void + { + \Mail::fake(); + $user = User::factory()->create(); + $session = \Session::instance(); + $session->markVerified(); + + $this + ->be($user) + ->withPersistentSession($session) + ->post(route('account.verify'), ['verification_key' => 'invalid']) + ->assertSuccessful(); + + $this->assertNull(SessionVerification\State::fromSession($session)); + \Mail::assertNotQueued(UserVerificationMail::class); + } } From b5964a3930291c3162fdc80bc126d922568f3bd4 Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 18 Dec 2023 21:34:15 +0900 Subject: [PATCH 119/203] Better routes? --- routes/web.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/web.php b/routes/web.php index b255721faae..593d0b0ca92 100644 --- a/routes/web.php +++ b/routes/web.php @@ -404,8 +404,8 @@ Route::group(['as' => 'api.', 'prefix' => 'api', 'middleware' => ['api', ThrottleRequests::getApiThrottle(), 'require-scopes']], function () { Route::group(['prefix' => 'v2'], function () { Route::group(['middleware' => ['require-scopes:any']], function () { - Route::post('verify-session', 'AccountController@verify')->name('verify'); - Route::post('verify-session/reissue', 'AccountController@reissueCode')->name('verify.reissue'); + Route::post('session/verify', 'AccountController@verify')->name('verify'); + Route::post('session/verify/reissue', 'AccountController@reissueCode')->name('verify.reissue'); }); Route::group(['as' => 'beatmaps.', 'prefix' => 'beatmaps'], function () { From a52f81d8dba5934ec088a6f55bdb9b6acc1972b4 Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 18 Dec 2023 21:34:41 +0900 Subject: [PATCH 120/203] Enable full verification for api routes --- app/Http/Kernel.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index c6062330a92..12cb86edc9a 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -24,6 +24,7 @@ class Kernel extends HttpKernel Middleware\SetLocaleApi::class, Middleware\CheckUserBanStatus::class, Middleware\UpdateUserLastvisit::class, + Middleware\VerifyUserAlways::class, ], 'web' => [ Middleware\StripCookies::class, From 7a99b870ad79eda9390bc325da0a5eda66a07355 Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 18 Dec 2023 21:39:37 +0900 Subject: [PATCH 121/203] Update route data --- tests/api_routes.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/api_routes.json b/tests/api_routes.json index 50bc3234933..439a44ab358 100644 --- a/tests/api_routes.json +++ b/tests/api_routes.json @@ -1,6 +1,6 @@ [ { - "uri": "api/v2/verify-session", + "uri": "api/v2/session/verify", "methods": [ "POST" ], @@ -8,17 +8,17 @@ "middlewares": [ "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", "App\\Http\\Middleware\\RequireScopes", - "App\\Http\\Middleware\\RequireScopes:identify", + "App\\Http\\Middleware\\RequireScopes:any", "Illuminate\\Auth\\Middleware\\Authenticate", "App\\Http\\Middleware\\VerifyUser", "App\\Http\\Middleware\\ThrottleRequests:60,10" ], "scopes": [ - "identify" + "any" ] }, { - "uri": "api/v2/verify-session/reissue", + "uri": "api/v2/session/verify/reissue", "methods": [ "POST" ], @@ -26,13 +26,13 @@ "middlewares": [ "App\\Http\\Middleware\\ThrottleRequests:1200,1,api:", "App\\Http\\Middleware\\RequireScopes", - "App\\Http\\Middleware\\RequireScopes:identify", + "App\\Http\\Middleware\\RequireScopes:any", "Illuminate\\Auth\\Middleware\\Authenticate", "App\\Http\\Middleware\\VerifyUser", "App\\Http\\Middleware\\ThrottleRequests:60,10" ], "scopes": [ - "identify" + "any" ] }, { From 6cb2ccbe9cc12d0b437629c4cf4f15b981d722df Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 19 Dec 2023 15:46:17 +0900 Subject: [PATCH 122/203] Use builtin test helper thing --- .../OAuth/TokensControllerTest.php | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/Controllers/OAuth/TokensControllerTest.php b/tests/Controllers/OAuth/TokensControllerTest.php index fe641294377..0084eac1c06 100644 --- a/tests/Controllers/OAuth/TokensControllerTest.php +++ b/tests/Controllers/OAuth/TokensControllerTest.php @@ -61,22 +61,21 @@ public function testIssueTokenWithPassword(): void $this->expectCountChange(fn () => $user->tokens()->count(), 1); - $tokenResp = $this->json('POST', route('oauth.passport.token'), [ + $tokenJson = $this->json('POST', route('oauth.passport.token'), [ 'grant_type' => 'password', 'client_id' => $client->getKey(), 'client_secret' => $client->secret, 'scope' => '*', 'username' => $user->username, 'password' => UserFactory::DEFAULT_PASSWORD, - ])->assertSuccessful(); - $tokenJson = json_decode($tokenResp->getContent(), true); + ])->assertSuccessful() + ->decodeResponseJson(); - $meResp = $this->json('GET', route('api.me'), [], [ + $this->json('GET', route('api.me'), [], [ 'Authorization' => "Bearer {$tokenJson['access_token']}", - ])->assertSuccessful(); - $meJson = json_decode($meResp->getContent(), true); + ])->assertSuccessful() + ->assertJsonPath('session_verified', false); - $this->assertFalse($meJson['session_verified']); // unverified access to api should trigger this but not necessarily return 401 \Mail::assertQueued(UserVerificationMail::class); } @@ -104,21 +103,20 @@ public function testIssueTokenWithRefreshTokenInheritsVerified(bool $verified): $this->expectCountChange(fn () => $user->tokens()->count(), 1); - $tokenResp = $this->json('POST', route('oauth.passport.token'), [ + $tokenJson = $this->json('POST', route('oauth.passport.token'), [ 'grant_type' => 'refresh_token', 'client_id' => $client->getKey(), 'client_secret' => $client->secret, 'refresh_token' => $refreshTokenString, 'scope' => implode(' ', $accessToken->scopes), - ])->assertSuccessful(); - $tokenJson = json_decode($tokenResp->getContent(), true); + ])->assertSuccessful() + ->decodeResponseJson(); - $meResp = $this->json('GET', route('api.me'), [], [ + $this->json('GET', route('api.me'), [], [ 'Authorization' => "Bearer {$tokenJson['access_token']}", - ])->assertSuccessful(); - $meJson = json_decode($meResp->getContent(), true); + ])->assertSuccessful() + ->assertJsonPath('session_verified', $verified); - $this->assertSame($meJson['session_verified'], $verified); \Mail::assertQueued(UserVerificationMail::class, $verified ? 0 : 1); } } From 73414accf69a3d170d6461f1205126b0b2b61897 Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 19 Dec 2023 16:01:34 +0900 Subject: [PATCH 123/203] Add token encode helper --- app/Libraries/OAuth/EncodeToken.php | 45 +++++++++++++++++++ .../OAuth/TokensControllerTest.php | 11 +---- tests/TestCase.php | 23 ++-------- 3 files changed, 50 insertions(+), 29 deletions(-) create mode 100644 app/Libraries/OAuth/EncodeToken.php diff --git a/app/Libraries/OAuth/EncodeToken.php b/app/Libraries/OAuth/EncodeToken.php new file mode 100644 index 00000000000..03cc93ed4cd --- /dev/null +++ b/app/Libraries/OAuth/EncodeToken.php @@ -0,0 +1,45 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Libraries\OAuth; + +use App\Models\OAuth\Token; +use Defuse\Crypto\Crypto; +use Firebase\JWT\JWT; +use Laravel\Passport\Passport; +use Laravel\Passport\RefreshToken; + +class EncodeToken +{ + public static function encodeAccessToken(Token $token): string + { + $privateKey = $GLOBALS['cfg']['passport']['private_key'] + ?? file_get_contents(Passport::keyPath('oauth-private.key')); + + return JWT::encode([ + 'aud' => $token->client_id, + 'exp' => $token->expires_at->timestamp, + 'iat' => $token->created_at->timestamp, // issued at + 'jti' => $token->getKey(), + 'nbf' => $token->created_at->timestamp, // valid after + 'sub' => $token->user_id, + 'scopes' => $token->scopes, + ], $privateKey, 'RS256'); + } + + public static function encodeRefreshToken(RefreshToken $refreshToken, Token $accessToken): string + { + return Crypto::encryptWithPassword(json_encode([ + 'client_id' => (string) $accessToken->client_id, + 'refresh_token_id' => $refreshToken->getKey(), + 'access_token_id' => $accessToken->getKey(), + 'scopes' => $accessToken->scopes, + 'user_id' => $accessToken->user_id, + 'expire_time' => $refreshToken->expires_at->getTimestamp(), + ]), \Crypt::getKey()); + } +} diff --git a/tests/Controllers/OAuth/TokensControllerTest.php b/tests/Controllers/OAuth/TokensControllerTest.php index 0084eac1c06..483bb58000e 100644 --- a/tests/Controllers/OAuth/TokensControllerTest.php +++ b/tests/Controllers/OAuth/TokensControllerTest.php @@ -5,13 +5,13 @@ namespace Tests\Controllers\OAuth; +use App\Libraries\OAuth\EncodeToken; use App\Mail\UserVerification as UserVerificationMail; use App\Models\OAuth\Token; use App\Models\User; use Database\Factories\OAuth\ClientFactory; use Database\Factories\OAuth\RefreshTokenFactory; use Database\Factories\UserFactory; -use Defuse\Crypto\Crypto; use Tests\TestCase; class TokensControllerTest extends TestCase @@ -92,14 +92,7 @@ public function testIssueTokenWithRefreshTokenInheritsVerified(bool $verified): $accessToken->forceFill(['scopes' => ['*'], 'verified' => $verified])->save(); $client = $accessToken->client; $user = $accessToken->user; - $refreshTokenString = Crypto::encryptWithPassword(json_encode([ - 'client_id' => (string) $client->getKey(), - 'refresh_token_id' => $refreshToken->getKey(), - 'access_token_id' => $accessToken->getKey(), - 'scopes' => $accessToken->scopes, - 'user_id' => $user->getKey(), - 'expire_time' => $refreshToken->expires_at->getTimestamp(), - ]), \Crypt::getKey()); + $refreshTokenString = EncodeToken::encodeRefreshToken($refreshToken, $accessToken); $this->expectCountChange(fn () => $user->tokens()->count(), 1); diff --git a/tests/TestCase.php b/tests/TestCase.php index 5b0852dbcfb..7f88c021f25 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,6 +8,7 @@ use App\Events\NewPrivateNotificationEvent; use App\Http\Middleware\AuthApi; use App\Jobs\Notifications\BroadcastNotificationBase; +use App\Libraries\OAuth\EncodeToken; use App\Libraries\Search\ScoreSearch; use App\Libraries\Session\Store as SessionStore; use App\Models\Beatmapset; @@ -15,7 +16,6 @@ use App\Models\User; use Artisan; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; -use Firebase\JWT\JWT; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -175,29 +175,12 @@ protected function actingAsVerified($user) return $this; } - // FIXME: figure out how to generate the encrypted token without doing it - // manually here. Or alternatively some other way to authenticate - // with token. protected function actingWithToken($token) { - static $privateKey; - - if ($privateKey === null) { - $privateKey = $GLOBALS['cfg']['passport']['private_key'] ?? file_get_contents(Passport::keyPath('oauth-private.key')); - } - - $encryptedToken = JWT::encode([ - 'aud' => $token->client_id, - 'exp' => $token->expires_at->timestamp, - 'iat' => $token->created_at->timestamp, // issued at - 'jti' => $token->getKey(), - 'nbf' => $token->created_at->timestamp, // valid after - 'sub' => $token->user_id, - 'scopes' => $token->scopes, - ], $privateKey, 'RS256'); - $this->actAsUserWithToken($token); + $encodedToken = EncodeToken::encodeAccessToken($token); + return $this->withHeaders([ 'Authorization' => "Bearer {$encryptedToken}", ]); From 6bfae6b983fbf0b3117388d71789bb3486b0bf95 Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 19 Dec 2023 16:06:12 +0900 Subject: [PATCH 124/203] Set correct token right away --- tests/Controllers/OAuth/TokensControllerTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Controllers/OAuth/TokensControllerTest.php b/tests/Controllers/OAuth/TokensControllerTest.php index 483bb58000e..a736226d794 100644 --- a/tests/Controllers/OAuth/TokensControllerTest.php +++ b/tests/Controllers/OAuth/TokensControllerTest.php @@ -87,9 +87,13 @@ public function testIssueTokenWithRefreshTokenInheritsVerified(bool $verified): { \Mail::fake(); - $refreshToken = (new RefreshTokenFactory())->create(); + $refreshToken = (new RefreshTokenFactory())->create([ + 'access_token_id' => Token::factory()->create([ + 'scopes' => ['*'], + 'verified' => $verified, + ]), + ]); $accessToken = $refreshToken->accessToken; - $accessToken->forceFill(['scopes' => ['*'], 'verified' => $verified])->save(); $client = $accessToken->client; $user = $accessToken->user; $refreshTokenString = EncodeToken::encodeRefreshToken($refreshToken, $accessToken); From 76a08ebebf9bb21a0f23d2d1fa628f6216b7ad3b Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 19 Dec 2023 16:08:47 +0900 Subject: [PATCH 125/203] Reverse creation order and always create password client --- .../Controllers/OAuth/TokensControllerTest.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/Controllers/OAuth/TokensControllerTest.php b/tests/Controllers/OAuth/TokensControllerTest.php index a736226d794..04df9487451 100644 --- a/tests/Controllers/OAuth/TokensControllerTest.php +++ b/tests/Controllers/OAuth/TokensControllerTest.php @@ -7,6 +7,7 @@ use App\Libraries\OAuth\EncodeToken; use App\Mail\UserVerification as UserVerificationMail; +use App\Models\OAuth\Client; use App\Models\OAuth\Token; use App\Models\User; use Database\Factories\OAuth\ClientFactory; @@ -87,16 +88,16 @@ public function testIssueTokenWithRefreshTokenInheritsVerified(bool $verified): { \Mail::fake(); - $refreshToken = (new RefreshTokenFactory())->create([ - 'access_token_id' => Token::factory()->create([ - 'scopes' => ['*'], - 'verified' => $verified, - ]), + $client = Client::factory()->create(['password_client' => true]); + $accessToken = Token::factory()->create([ + 'client_id' => $client, + 'scopes' => ['*'], + 'verified' => $verified, ]); - $accessToken = $refreshToken->accessToken; - $client = $accessToken->client; - $user = $accessToken->user; + $refreshToken = (new RefreshTokenFactory()) + ->create(['access_token_id' => $accessToken]); $refreshTokenString = EncodeToken::encodeRefreshToken($refreshToken, $accessToken); + $user = $accessToken->user; $this->expectCountChange(fn () => $user->tokens()->count(), 1); From 6367a1979fe0dd890bf8db4d61d4d8015f436dc5 Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 19 Dec 2023 16:18:19 +0900 Subject: [PATCH 126/203] Fix variable name --- tests/TestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index 7f88c021f25..ced9c9e4a5c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -182,7 +182,7 @@ protected function actingWithToken($token) $encodedToken = EncodeToken::encodeAccessToken($token); return $this->withHeaders([ - 'Authorization' => "Bearer {$encryptedToken}", + 'Authorization' => "Bearer {$encodedToken}", ]); } From 095daa00908e008959acb72a256f745cfee40254 Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 19 Dec 2023 16:57:28 +0900 Subject: [PATCH 127/203] Unify timestamp method --- app/Libraries/OAuth/EncodeToken.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Libraries/OAuth/EncodeToken.php b/app/Libraries/OAuth/EncodeToken.php index 03cc93ed4cd..700655b6b58 100644 --- a/app/Libraries/OAuth/EncodeToken.php +++ b/app/Libraries/OAuth/EncodeToken.php @@ -39,7 +39,7 @@ public static function encodeRefreshToken(RefreshToken $refreshToken, Token $acc 'access_token_id' => $accessToken->getKey(), 'scopes' => $accessToken->scopes, 'user_id' => $accessToken->user_id, - 'expire_time' => $refreshToken->expires_at->getTimestamp(), + 'expire_time' => $refreshToken->expires_at->timestamp, ]), \Crypt::getKey()); } } From 711b991f35a802f16455de358e5c9c8c1aeefaf6 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 11 Jan 2024 20:17:02 +0900 Subject: [PATCH 128/203] move cleanups to turbolinks:before-cache --- resources/js/beatmap-discussions/main.tsx | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index 005f4cece21..5c8ae5ff2cc 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -65,7 +65,7 @@ export default class Main extends React.Component { componentDidMount() { $(document).on(`ajax:success.${this.eventId}`, '.js-beatmapset-discussion-update', this.ujsDiscussionUpdate); $(document).on(`click.${this.eventId}`, '.js-beatmap-discussion--jump', this.jumpToClick); - $(document).on(`turbolinks:before-cache.${this.eventId}`, this.saveState); + $(document).on(`turbolinks:before-cache.${this.eventId}`, this.destroy); if (this.discussionsState.jumpToDiscussion) { this.disposers.add(core.reactTurbolinks.runAfterPageLoad(this.jumpToDiscussionByHash)); @@ -77,13 +77,6 @@ export default class Main extends React.Component { componentWillUnmount() { $.unsubscribe(`.${this.eventId}`); $(document).off(`.${this.eventId}`); - - document.documentElement.style.removeProperty('--scroll-padding-top-extra'); - - window.clearTimeout(this.timeoutCheckNew); - this.xhrCheckNew?.abort(); - this.disposers.forEach((disposer) => disposer?.()); - this.discussionsState.destroy(); } render() { @@ -160,6 +153,18 @@ export default class Main extends React.Component { }); }; + private readonly destroy = () => { + document.documentElement.style.removeProperty('--scroll-padding-top-extra'); + window.clearTimeout(this.timeoutCheckNew); + this.xhrCheckNew?.abort(); + + storeJson(beatmapsetJsonId, toJS(this.store.beatmapset)); + this.discussionsState.saveState(); + + this.disposers.forEach((disposer) => disposer?.()); + this.discussionsState.destroy(); + }; + private readonly handleNewDiscussionFocus = () => { // Bug with position: sticky and scroll-padding: https://bugs.chromium.org/p/chromium/issues/detail?id=1466472 document.documentElement.style.removeProperty('--scroll-padding-top-extra'); @@ -241,11 +246,6 @@ export default class Main extends React.Component { } }; - private readonly saveState = () => { - storeJson(beatmapsetJsonId, toJS(this.store.beatmapset)); - this.discussionsState.saveState(); - }; - private readonly ujsDiscussionUpdate = (_event: unknown, beatmapset: BeatmapsetWithDiscussionsJson) => { // to allow ajax:complete to be run window.setTimeout(() => this.discussionsState.update({ beatmapset }), 0); From e168dbdef739017aee6526cd928bf21342bcfcd8 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 11 Jan 2024 20:20:52 +0900 Subject: [PATCH 129/203] remove listener on destroy --- resources/js/beatmap-discussions/main.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index 5c8ae5ff2cc..a49467cfa8d 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -65,7 +65,7 @@ export default class Main extends React.Component { componentDidMount() { $(document).on(`ajax:success.${this.eventId}`, '.js-beatmapset-discussion-update', this.ujsDiscussionUpdate); $(document).on(`click.${this.eventId}`, '.js-beatmap-discussion--jump', this.jumpToClick); - $(document).on(`turbolinks:before-cache.${this.eventId}`, this.destroy); + document.addEventListener('turbolinks:before-cache', this.destroy); if (this.discussionsState.jumpToDiscussion) { this.disposers.add(core.reactTurbolinks.runAfterPageLoad(this.jumpToDiscussionByHash)); @@ -154,6 +154,8 @@ export default class Main extends React.Component { }; private readonly destroy = () => { + document.removeEventListener('turbolinks:before-cache', this.destroy); + document.documentElement.style.removeProperty('--scroll-padding-top-extra'); window.clearTimeout(this.timeoutCheckNew); this.xhrCheckNew?.abort(); From b74f5f51cada13afb041797d96238a58c2375b75 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 11 Jan 2024 22:18:43 +0900 Subject: [PATCH 130/203] getter order --- .../js/beatmap-discussions/discussions-state.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index f64eccdd65f..e44a7ee089d 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -247,6 +247,14 @@ export default class DiscussionsState { return moment(maxLastUpdate).unix(); } + @computed + get presentDiscussions() { + return canModeratePosts() + ? this.discussionsArray + : this.discussionsArray.filter((discussion) => discussion.deleted_at == null); + } + + @computed get selectedUser() { return this.store.users.get(this.selectedUserId); @@ -260,13 +268,6 @@ export default class DiscussionsState { return sortWithMode([...this.store.beatmaps.values()]); } - @computed - get presentDiscussions() { - return canModeratePosts() - ? this.discussionsArray - : this.discussionsArray.filter((discussion) => discussion.deleted_at == null); - } - @computed get totalHype() { return this.presentDiscussions From 1aeef0caa82a00634a8609f5e47c07c3b1e5ac59 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 12 Jan 2024 00:13:23 +0900 Subject: [PATCH 131/203] count unresolved only --- .../beatmap-discussions/discussions-state.ts | 47 ++++++++++--------- resources/js/beatmap-discussions/header.tsx | 8 +++- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index e44a7ee089d..35622c58955 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -163,27 +163,6 @@ export default class DiscussionsState { return value; } - @computed - get discussionsCountByPlaymode() { - const counts: Record = { - fruits: 0, - mania: 0, - osu: 0, - taiko: 0, - }; - - for (const discussion of this.discussionsArray) { - if (discussion.beatmap_id != null) { - const mode = this.store.beatmaps.get(discussion.beatmap_id)?.mode; - if (mode != null) { - counts[mode]++; - } - } - } - - return counts; - } - @computed get discussionForSelectedBeatmap() { return this.discussionsByBeatmap(this.currentBeatmapId); @@ -254,7 +233,6 @@ export default class DiscussionsState { : this.discussionsArray.filter((discussion) => discussion.deleted_at == null); } - @computed get selectedUser() { return this.store.users.get(this.selectedUserId); @@ -294,6 +272,27 @@ export default class DiscussionsState { return this.presentDiscussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved); } + @computed + get unresolvedDiscussionsCountByPlaymode() { + const counts: Record = { + fruits: 0, + mania: 0, + osu: 0, + taiko: 0, + }; + + for (const discussion of this.discussionsArray) { + if (discussion.beatmap_id != null && discussion.can_be_resolved && !discussion.resolved) { + const mode = this.store.beatmaps.get(discussion.beatmap_id)?.mode; + if (mode != null) { + counts[mode]++; + } + } + } + + return counts; + } + @computed get url() { return makeUrl({ @@ -416,6 +415,10 @@ export default class DiscussionsState { }; } + unresolvedDiscussionsCountByBeatmap(beatmapId: number) { + return this.presentDiscussions.filter((discussion) => (discussion.beatmap_id === beatmapId && discussion.can_be_resolved && !discussion.resolved)).length; + } + @action update(options: Partial) { const { diff --git a/resources/js/beatmap-discussions/header.tsx b/resources/js/beatmap-discussions/header.tsx index 7fe81458f29..a965f3bc405 100644 --- a/resources/js/beatmap-discussions/header.tsx +++ b/resources/js/beatmap-discussions/header.tsx @@ -77,7 +77,7 @@ export class Header extends React.Component { ({ - count: this.discussionsState.discussionsCountByPlaymode[mode], + count: this.discussionsState.unresolvedDiscussionsCountByPlaymode[mode], disabled: (this.discussionsState.groupedBeatmaps.get(mode)?.length ?? 0) === 0, mode, }))} @@ -96,7 +96,11 @@ export class Header extends React.Component { private readonly createLink = (beatmap: BeatmapJson) => makeUrl({ beatmap }); // TODO: does it need to be computed? - private readonly getCount = (beatmap: BeatmapExtendedJson) => beatmap.deleted_at == null ? this.discussionsState.discussionsByBeatmap(beatmap.id).length : undefined; + private readonly getCount = (beatmap: BeatmapExtendedJson) => ( + beatmap.deleted_at == null + ? this.discussionsState.unresolvedDiscussionsCountByBeatmap(beatmap.id) + : undefined + ); @action private readonly onClickMode = (event: React.MouseEvent, mode: GameMode) => { From 3f374b5fc3c4384f8ee84794ea9cddb8e46cd8ed Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 12 Jan 2024 13:10:13 +0900 Subject: [PATCH 132/203] pass discussionState to beatmap list component --- .../js/beatmap-discussions/beatmap-list.tsx | 41 +++++++++++-------- resources/js/beatmap-discussions/header.tsx | 29 +------------ 2 files changed, 26 insertions(+), 44 deletions(-) diff --git a/resources/js/beatmap-discussions/beatmap-list.tsx b/resources/js/beatmap-discussions/beatmap-list.tsx index c5dabb64f50..31ad5da972d 100644 --- a/resources/js/beatmap-discussions/beatmap-list.tsx +++ b/resources/js/beatmap-discussions/beatmap-list.tsx @@ -3,22 +3,20 @@ import BeatmapListItem from 'components/beatmap-list-item'; import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; -import BeatmapsetJson from 'interfaces/beatmapset-json'; import UserJson from 'interfaces/user-json'; +import { action, computed, makeObservable } from 'mobx'; +import { observer } from 'mobx-react'; import { deletedUserJson } from 'models/user'; import * as React from 'react'; +import { makeUrl } from 'utils/beatmapset-discussion-helper'; import { blackoutToggle } from 'utils/blackout'; import { classWithModifiers } from 'utils/css'; import { formatNumber } from 'utils/html'; import { nextVal } from 'utils/seq'; +import DiscussionsState from './discussions-state'; interface Props { - beatmaps: BeatmapExtendedJson[]; - beatmapset: BeatmapsetJson; - createLink: (beatmap: BeatmapExtendedJson) => string; - currentBeatmap: BeatmapExtendedJson; - getCount: (beatmap: BeatmapExtendedJson) => number | undefined; - onSelectBeatmap: (beatmapId: number) => void; + discussionsState: DiscussionsState; users: Map; } @@ -26,12 +24,20 @@ interface State { showingSelector: boolean; } -export default class BeatmapList extends React.PureComponent { +@observer +export default class BeatmapList extends React.Component { private readonly eventId = `beatmapset-discussions-show-beatmap-list-${nextVal()}`; + @computed + private get beatmaps() { + return this.props.discussionsState.groupedBeatmaps.get(this.props.discussionsState.currentBeatmap.mode) ?? []; + } + constructor(props: Props) { super(props); + makeObservable(this); + this.state = { showingSelector: false, }; @@ -53,10 +59,10 @@ export default class BeatmapList extends React.PureComponent { @@ -73,19 +79,19 @@ export default class BeatmapList extends React.PureComponent { } private readonly beatmapListItem = (beatmap: BeatmapExtendedJson) => { - const count = this.props.getCount(beatmap); + const count = this.props.discussionsState.unresolvedDiscussionsCountByBeatmap(beatmap.id); return (
    @@ -114,12 +120,15 @@ export default class BeatmapList extends React.PureComponent { this.hideSelector(); }; + @action private readonly selectBeatmap = (e: React.MouseEvent) => { if (e.button !== 0) return; e.preventDefault(); const beatmapId = parseInt(e.currentTarget.dataset.id ?? '', 10); - this.props.onSelectBeatmap(beatmapId); + + this.props.discussionsState.currentBeatmapId = beatmapId; + this.props.discussionsState.changeDiscussionPage('timeline'); }; private readonly setSelector = (state: boolean) => { diff --git a/resources/js/beatmap-discussions/header.tsx b/resources/js/beatmap-discussions/header.tsx index a965f3bc405..2485edd7c1c 100644 --- a/resources/js/beatmap-discussions/header.tsx +++ b/resources/js/beatmap-discussions/header.tsx @@ -11,8 +11,6 @@ import HeaderV4 from 'components/header-v4'; import PlaymodeTabs from 'components/playmode-tabs'; import StringWithComponent from 'components/string-with-component'; import UserLink from 'components/user-link'; -import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; -import BeatmapJson from 'interfaces/beatmap-json'; import BeatmapsetDiscussionsStore from 'interfaces/beatmapset-discussions-store'; import GameMode, { gameModes } from 'interfaces/game-mode'; import { route } from 'laroute'; @@ -20,7 +18,6 @@ import { action, computed, makeObservable } from 'mobx'; import { observer } from 'mobx-react'; import { deletedUserJson } from 'models/user'; import * as React from 'react'; -import { makeUrl } from 'utils/beatmapset-discussion-helper'; import { getArtist, getTitle } from 'utils/beatmapset-helper'; import { trans } from 'utils/lang'; import BeatmapList from './beatmap-list'; @@ -38,10 +35,6 @@ interface Props { @observer export class Header extends React.Component { - private get beatmaps() { - return this.discussionsState.groupedBeatmaps; - } - private get beatmapset() { return this.discussionsState.beatmapset; } @@ -93,27 +86,12 @@ export class Header extends React.Component { ); } - private readonly createLink = (beatmap: BeatmapJson) => makeUrl({ beatmap }); - - // TODO: does it need to be computed? - private readonly getCount = (beatmap: BeatmapExtendedJson) => ( - beatmap.deleted_at == null - ? this.discussionsState.unresolvedDiscussionsCountByBeatmap(beatmap.id) - : undefined - ); - @action private readonly onClickMode = (event: React.MouseEvent, mode: GameMode) => { event.preventDefault(); this.discussionsState.changeGameMode(mode); }; - @action - private readonly onSelectBeatmap = (beatmapId: number) => { - this.discussionsState.currentBeatmapId = beatmapId; - this.discussionsState.changeDiscussionPage('timeline'); - }; - private renderHeaderBottom() { const bn = 'beatmap-discussions-header-bottom'; @@ -181,12 +159,7 @@ export class Header extends React.Component {
    From e214e2c6693f20c96d82d1fda553c02d33ae65e4 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 12 Jan 2024 13:48:08 +0900 Subject: [PATCH 133/203] mobx the state --- .../js/beatmap-discussions/beatmap-list.tsx | 46 ++++++++----------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/resources/js/beatmap-discussions/beatmap-list.tsx b/resources/js/beatmap-discussions/beatmap-list.tsx index 31ad5da972d..79ae5704f92 100644 --- a/resources/js/beatmap-discussions/beatmap-list.tsx +++ b/resources/js/beatmap-discussions/beatmap-list.tsx @@ -4,7 +4,7 @@ import BeatmapListItem from 'components/beatmap-list-item'; import BeatmapExtendedJson from 'interfaces/beatmap-extended-json'; import UserJson from 'interfaces/user-json'; -import { action, computed, makeObservable } from 'mobx'; +import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import { deletedUserJson } from 'models/user'; import * as React from 'react'; @@ -20,13 +20,10 @@ interface Props { users: Map; } -interface State { - showingSelector: boolean; -} - @observer -export default class BeatmapList extends React.Component { +export default class BeatmapList extends React.Component { private readonly eventId = `beatmapset-discussions-show-beatmap-list-${nextVal()}`; + @observable private showingSelector = false; @computed private get beatmaps() { @@ -37,16 +34,12 @@ export default class BeatmapList extends React.Component { super(props); makeObservable(this); - - this.state = { - showingSelector: false, - }; } componentDidMount() { $(document).on(`click.${this.eventId}`, this.onDocumentClick); - $(document).on(`turbolinks:before-cache.${this.eventId}`, this.hideSelector); - this.syncBlackout(); + $(document).on(`turbolinks:before-cache.${this.eventId}`, this.handleBeforeCache); + blackoutToggle(this.showingSelector, 0.5); } componentWillUnmount() { @@ -55,7 +48,7 @@ export default class BeatmapList extends React.Component { render() { return ( -
    +
    { ); }; - private readonly hideSelector = () => { - if (this.state.showingSelector) { - this.setSelector(false); - } + @action + private readonly handleBeforeCache = () => { + this.setShowingSelector(false); }; + @action private readonly onDocumentClick = (e: JQuery.ClickEvent) => { if (e.button !== 0) return; @@ -117,7 +110,7 @@ export default class BeatmapList extends React.Component { return; } - this.hideSelector(); + this.setShowingSelector(false); }; @action @@ -131,20 +124,17 @@ export default class BeatmapList extends React.Component { this.props.discussionsState.changeDiscussionPage('timeline'); }; - private readonly setSelector = (state: boolean) => { - if (this.state.showingSelector !== state) { - this.setState({ showingSelector: state }, this.syncBlackout); - } - }; - - private readonly syncBlackout = () => { - blackoutToggle(this.state.showingSelector, 0.5); - }; + @action + private setShowingSelector(state: boolean) { + this.showingSelector = state; + blackoutToggle(state, 0.5); + } + @action private readonly toggleSelector = (e: React.MouseEvent) => { if (e.button !== 0) return; e.preventDefault(); - this.setSelector(!this.state.showingSelector); + this.setShowingSelector(!this.showingSelector); }; } From dc67466f04ab9a3c0db97cb8f1af66058f13f973 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 12 Jan 2024 14:07:00 +0900 Subject: [PATCH 134/203] just loop once since the values are always used --- .../js/beatmap-discussions/beatmap-list.tsx | 2 +- .../js/beatmap-discussions/discussions-state.ts | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/resources/js/beatmap-discussions/beatmap-list.tsx b/resources/js/beatmap-discussions/beatmap-list.tsx index 79ae5704f92..0baacabdf13 100644 --- a/resources/js/beatmap-discussions/beatmap-list.tsx +++ b/resources/js/beatmap-discussions/beatmap-list.tsx @@ -72,7 +72,7 @@ export default class BeatmapList extends React.Component { } private readonly beatmapListItem = (beatmap: BeatmapExtendedJson) => { - const count = this.props.discussionsState.unresolvedDiscussionsCountByBeatmap(beatmap.id); + const count = this.props.discussionsState.unresolvedDiscussionsCountByBeatmap[beatmap.id]; return (
    > = {}; + + for (const discussion of this.discussionsArray) { + if (discussion.beatmap_id != null && discussion.can_be_resolved && !discussion.resolved) { + counts[discussion.beatmap_id] = (counts[discussion.beatmap_id] ?? 0) + 1; + } + } + + return counts; + } + @computed get url() { return makeUrl({ @@ -415,10 +428,6 @@ export default class DiscussionsState { }; } - unresolvedDiscussionsCountByBeatmap(beatmapId: number) { - return this.presentDiscussions.filter((discussion) => (discussion.beatmap_id === beatmapId && discussion.can_be_resolved && !discussion.resolved)).length; - } - @action update(options: Partial) { const { From e7078e961b7f7d928fccb071116e8afc70ac294e Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 12 Jan 2024 14:45:42 +0900 Subject: [PATCH 135/203] combine unresolved discussion counts --- .../js/beatmap-discussions/beatmap-list.tsx | 2 +- .../beatmap-discussions/discussions-state.ts | 27 +++++++------------ resources/js/beatmap-discussions/header.tsx | 2 +- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/resources/js/beatmap-discussions/beatmap-list.tsx b/resources/js/beatmap-discussions/beatmap-list.tsx index 0baacabdf13..7c5a60db2fa 100644 --- a/resources/js/beatmap-discussions/beatmap-list.tsx +++ b/resources/js/beatmap-discussions/beatmap-list.tsx @@ -72,7 +72,7 @@ export default class BeatmapList extends React.Component { } private readonly beatmapListItem = (beatmap: BeatmapExtendedJson) => { - const count = this.props.discussionsState.unresolvedDiscussionsCountByBeatmap[beatmap.id]; + const count = this.props.discussionsState.unresolvedDiscussionCounts.byBeatmap[beatmap.id]; return (
    = { + get unresolvedDiscussionCounts() { + const byBeatmap: Partial> = {}; + const byMode: Record = { fruits: 0, mania: 0, osu: 0, @@ -283,27 +284,19 @@ export default class DiscussionsState { for (const discussion of this.discussionsArray) { if (discussion.beatmap_id != null && discussion.can_be_resolved && !discussion.resolved) { + byBeatmap[discussion.beatmap_id] = (byBeatmap[discussion.beatmap_id] ?? 0) + 1; + const mode = this.store.beatmaps.get(discussion.beatmap_id)?.mode; if (mode != null) { - counts[mode]++; + byMode[mode]++; } } } - return counts; - } - - @computed - get unresolvedDiscussionsCountByBeatmap() { - const counts: Partial> = {}; - - for (const discussion of this.discussionsArray) { - if (discussion.beatmap_id != null && discussion.can_be_resolved && !discussion.resolved) { - counts[discussion.beatmap_id] = (counts[discussion.beatmap_id] ?? 0) + 1; - } - } - - return counts; + return { + byBeatmap, + byMode, + }; } @computed diff --git a/resources/js/beatmap-discussions/header.tsx b/resources/js/beatmap-discussions/header.tsx index 2485edd7c1c..b73c97847e9 100644 --- a/resources/js/beatmap-discussions/header.tsx +++ b/resources/js/beatmap-discussions/header.tsx @@ -70,7 +70,7 @@ export class Header extends React.Component { ({ - count: this.discussionsState.unresolvedDiscussionsCountByPlaymode[mode], + count: this.discussionsState.unresolvedDiscussionCounts.byMode[mode], disabled: (this.discussionsState.groupedBeatmaps.get(mode)?.length ?? 0) === 0, mode, }))} From 1be1eb870ff06d0a60b5ea529184907d53183790 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 12 Jan 2024 14:46:48 +0900 Subject: [PATCH 136/203] unused --- resources/js/beatmap-discussions/discussions-state.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 84789b1d054..a5605ea9b02 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -267,11 +267,6 @@ export default class DiscussionsState { }, 0); } - @computed - get unresolvedDiscussions() { - return this.presentDiscussions.filter((discussion) => discussion.can_be_resolved && !discussion.resolved); - } - @computed get unresolvedDiscussionCounts() { const byBeatmap: Partial> = {}; From a4320dfc5a5cea5aa22c0f0c81df0015fd6a139d Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 12 Jan 2024 17:45:22 +0900 Subject: [PATCH 137/203] exclude deleted discussions from counts and filters --- .../beatmap-discussions/discussions-state.ts | 30 +++++++++++-------- .../js/beatmap-discussions/nominations.tsx | 4 +-- .../js/beatmap-discussions/nominator.tsx | 4 +-- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index a5605ea9b02..27d96afc741 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -54,7 +54,7 @@ export default class DiscussionsState { @observable readPostIds = new Set(); @observable selectedUserId: number | null = null; - @observable showDeleted = true; + @observable showDeleted = true; // this toggle only affects All and deleted discussion filters, other filters don't show deleted private previousFilter: Filter = 'total'; private previousPage: DiscussionPage = 'general'; @@ -94,12 +94,13 @@ export default class DiscussionsState { const reviewsWithPending = new Set(); for (const discussion of this.discussionForSelectedBeatmap) { - if (currentUser != null && discussion.user_id === currentUser.id) { - groups.mine.push(discussion); - } - if (discussion.deleted_at != null) { groups.deleted.push(discussion); + continue; + } + + if (currentUser != null && discussion.user_id === currentUser.id) { + groups.mine.push(discussion); } if (discussion.message_type === 'hype') { @@ -226,6 +227,11 @@ export default class DiscussionsState { return moment(maxLastUpdate).unix(); } + @computed + get nonDeletedDiscussions() { + return this.discussionsArray.filter((discussion) => discussion.deleted_at == null); + } + @computed get presentDiscussions() { return canModeratePosts() @@ -240,21 +246,18 @@ export default class DiscussionsState { @computed get sortedBeatmaps() { - // TODO - // filter to only include beatmaps from the current discussion's beatmapset (for the modding profile page) - // const beatmaps = filter(this.props.beatmaps, this.isCurrentBeatmap); return sortWithMode([...this.store.beatmaps.values()]); } @computed - get totalHype() { - return this.presentDiscussions + get totalHypeCount() { + return this.nonDeletedDiscussions .reduce((sum, discussion) => +(discussion.message_type === 'hype') + sum, 0); } @computed - get unresolvedIssues() { - return this.presentDiscussions + get unresolvedIssueCount() { + return this.nonDeletedDiscussions .reduce((sum, discussion) => { if (discussion.can_be_resolved && !discussion.resolved) { if (discussion.beatmap_id == null) return sum++; @@ -277,7 +280,7 @@ export default class DiscussionsState { taiko: 0, }; - for (const discussion of this.discussionsArray) { + for (const discussion of this.nonDeletedDiscussions) { if (discussion.beatmap_id != null && discussion.can_be_resolved && !discussion.resolved) { byBeatmap[discussion.beatmap_id] = (byBeatmap[discussion.beatmap_id] ?? 0) + 1; @@ -383,6 +386,7 @@ export default class DiscussionsState { this.urlStateDisposer(); } + // TODO: move to discussionForSelectedBeatmap if nothing else uses this. discussionsByBeatmap(beatmapId: number) { return this.presentDiscussions.filter((discussion) => (discussion.beatmap_id == null || discussion.beatmap_id === beatmapId)); } diff --git a/resources/js/beatmap-discussions/nominations.tsx b/resources/js/beatmap-discussions/nominations.tsx index e80b4bf48b5..85ad184efd1 100644 --- a/resources/js/beatmap-discussions/nominations.tsx +++ b/resources/js/beatmap-discussions/nominations.tsx @@ -433,7 +433,7 @@ export class Nominations extends React.Component { if (!this.beatmapset.can_be_hyped || this.beatmapset.hype == null) return; const requiredHype = this.beatmapset.hype.required; - const hype = this.props.discussionsState.totalHype; + const hype = this.props.discussionsState.totalHypeCount; return (
    @@ -517,7 +517,7 @@ export class Nominations extends React.Component { private renderNominationBar() { const requiredHype = this.beatmapset.hype?.required ?? 0; // TODO: skip if null? - const hypeRaw = this.props.discussionsState.totalHype; + const hypeRaw = this.props.discussionsState.totalHypeCount; const mapCanBeNominated = this.beatmapset.status === 'pending' && hypeRaw >= requiredHype; if (!(mapCanBeNominated || this.isQualified)) return; diff --git a/resources/js/beatmap-discussions/nominator.tsx b/resources/js/beatmap-discussions/nominator.tsx index 3ae920cf8fa..d3ca3e93a7c 100644 --- a/resources/js/beatmap-discussions/nominator.tsx +++ b/resources/js/beatmap-discussions/nominator.tsx @@ -39,7 +39,7 @@ export class Nominator extends React.Component { } private get currentHype() { - return this.props.discussionsState.totalHype; + return this.props.discussionsState.totalHypeCount; } private get mapCanBeNominated() { @@ -74,7 +74,7 @@ export class Nominator extends React.Component { } private get unresolvedIssues() { - return this.props.discussionsState.unresolvedIssues; + return this.props.discussionsState.unresolvedIssueCount; } private get users() { From a1872a21bff099112759fbf7785965d659025f69 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 12 Jan 2024 17:53:40 +0900 Subject: [PATCH 138/203] combine getter/functions --- .../beatmap-discussions/discussions-state.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 27d96afc741..63107b5440b 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -166,7 +166,11 @@ export default class DiscussionsState { @computed get discussionForSelectedBeatmap() { - return this.discussionsByBeatmap(this.currentBeatmapId); + const discussions = canModeratePosts() + ? this.discussionsArray + : this.nonDeletedDiscussions; + + return discussions.filter((discussion) => (discussion.beatmap_id == null || discussion.beatmap_id === this.currentBeatmapId)); } @computed @@ -232,13 +236,6 @@ export default class DiscussionsState { return this.discussionsArray.filter((discussion) => discussion.deleted_at == null); } - @computed - get presentDiscussions() { - return canModeratePosts() - ? this.discussionsArray - : this.discussionsArray.filter((discussion) => discussion.deleted_at == null); - } - @computed get selectedUser() { return this.store.users.get(this.selectedUserId); @@ -386,11 +383,6 @@ export default class DiscussionsState { this.urlStateDisposer(); } - // TODO: move to discussionForSelectedBeatmap if nothing else uses this. - discussionsByBeatmap(beatmapId: number) { - return this.presentDiscussions.filter((discussion) => (discussion.beatmap_id == null || discussion.beatmap_id === beatmapId)); - } - @action markAsRead(ids: number | number[]) { if (Array.isArray(ids)) { From bfe562f9c86aefcc1a84c60014f6938d3e3ebc76 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 12 Jan 2024 17:54:00 +0900 Subject: [PATCH 139/203] fix missing polling --- resources/js/beatmap-discussions/main.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/js/beatmap-discussions/main.tsx b/resources/js/beatmap-discussions/main.tsx index a49467cfa8d..3b947b1f27b 100644 --- a/resources/js/beatmap-discussions/main.tsx +++ b/resources/js/beatmap-discussions/main.tsx @@ -150,6 +150,7 @@ export default class Main extends React.Component { this.nextTimeout = Math.min(this.nextTimeout, checkNewTimeoutMax); this.timeoutCheckNew = window.setTimeout(this.checkNew, this.nextTimeout); + this.xhrCheckNew = undefined; }); }; From c4e282400fb10b43215f1062ec2c72736ce61b4d Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 12 Jan 2024 18:08:25 +0900 Subject: [PATCH 140/203] more consistent naming? --- resources/js/beatmap-discussions/discussions-state.ts | 2 +- resources/js/beatmap-discussions/nominator.tsx | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index 63107b5440b..eeb341c90c8 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -253,7 +253,7 @@ export default class DiscussionsState { } @computed - get unresolvedIssueCount() { + get unresolvedDiscussionTotalCount() { return this.nonDeletedDiscussions .reduce((sum, discussion) => { if (discussion.can_be_resolved && !discussion.resolved) { diff --git a/resources/js/beatmap-discussions/nominator.tsx b/resources/js/beatmap-discussions/nominator.tsx index d3ca3e93a7c..8c4793454cd 100644 --- a/resources/js/beatmap-discussions/nominator.tsx +++ b/resources/js/beatmap-discussions/nominator.tsx @@ -73,10 +73,6 @@ export class Nominator extends React.Component { : Object.keys(this.beatmapset.nominations.required) as GameMode[]; } - private get unresolvedIssues() { - return this.props.discussionsState.unresolvedIssueCount; - } - private get users() { return this.props.store.users; } @@ -191,7 +187,7 @@ export class Nominator extends React.Component { } let tooltipText: string | undefined; - if (this.unresolvedIssues > 0) { + if (this.props.discussionsState.unresolvedDiscussionTotalCount > 0) { tooltipText = trans('beatmaps.nominations.unresolved_issues'); } else if (this.beatmapset.nominations.nominated) { tooltipText = trans('beatmaps.nominations.already_nominated'); From 5ec3929be6c040ad296489b4de91a0a0183ea29c Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 12 Jan 2024 18:58:03 +0900 Subject: [PATCH 141/203] add note --- resources/js/beatmap-discussions/discussions-state.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index eeb341c90c8..de3abe2de22 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -397,6 +397,9 @@ export default class DiscussionsState { } toJson() { + // Convert serialized properties into primitives supported by JSON.stringify. + // Values that need conversion should have the appropriate reviver to restore + // the original type when deserializing. return { currentBeatmapId: this.currentBeatmapId, currentFilter: this.currentFilter, From 2c3a264ecca7c1fed2dc6142cdfdd950b6181b55 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 12 Jan 2024 21:19:15 +0900 Subject: [PATCH 142/203] discriminate pased on props; remove unused default prop --- .../js/beatmap-discussions-history/main.tsx | 1 - .../js/beatmap-discussions/discussion.tsx | 26 +++++++++---------- resources/js/modding-profile/discussions.tsx | 1 - 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/resources/js/beatmap-discussions-history/main.tsx b/resources/js/beatmap-discussions-history/main.tsx index a77cce7b98d..2c747dabc65 100644 --- a/resources/js/beatmap-discussions-history/main.tsx +++ b/resources/js/beatmap-discussions-history/main.tsx @@ -38,7 +38,6 @@ export default class Main extends React.Component
    diff --git a/resources/js/beatmap-discussions/discussion.tsx b/resources/js/beatmap-discussions/discussion.tsx index 9b0e822c49e..7143984fb15 100644 --- a/resources/js/beatmap-discussions/discussion.tsx +++ b/resources/js/beatmap-discussions/discussion.tsx @@ -24,14 +24,20 @@ import { UserCard } from './user-card'; const bn = 'beatmap-discussion'; -interface Props { - discussion: BeatmapsetDiscussionJsonForBundle | BeatmapsetDiscussionJsonForShow; - discussionsState: DiscussionsState | null; // TODO: make optional? +interface BaseProps { isTimelineVisible: boolean; parentDiscussion?: BeatmapsetDiscussionJson | null; store: BeatmapsetDiscussionsStore; } +type Props = BaseProps & ({ + discussion: BeatmapsetDiscussionJsonForBundle; + discussionsState: null; // TODO: make optional? +} | { + discussion: BeatmapsetDiscussionJsonForShow; + discussionsState: DiscussionsState; +}); + function DiscussionTypeIcon({ type }: { type: DiscussionType | 'resolved' }) { const titleKey = type === 'resolved' ? 'beatmaps.discussions.resolved' @@ -46,16 +52,8 @@ function DiscussionTypeIcon({ type }: { type: DiscussionType | 'resolved' }) { ); } -function isBeatmapsetDiscussionJsonForShow(value: Props['discussion']): value is BeatmapsetDiscussionJsonForShow { - return 'posts' in value; -} - @observer export class Discussion extends React.Component { - static defaultProps = { - readonly: false, - }; - private lastResolvedState = false; private get beatmapset() { @@ -95,7 +93,7 @@ export class Discussion extends React.Component { @computed private get resolvedSystemPostId() { // TODO: handling resolved status in bundles....? - if (this.readonly || !isBeatmapsetDiscussionJsonForShow(this.props.discussion)) return -1; + if (this.props.discussionsState == null) return -1; const systemPost = findLast(this.props.discussion.posts, (post) => post.system && post.message.type === 'resolved'); return systemPost?.id ?? -1; @@ -199,7 +197,7 @@ export class Discussion extends React.Component { } private postFooter() { - if (this.readonly || !isBeatmapsetDiscussionJsonForShow(this.props.discussion)) return null; + if (this.props.discussionsState == null) return null; let cssClasses = `${bn}__expanded`; if (this.collapsed) { @@ -247,7 +245,7 @@ export class Discussion extends React.Component { } private renderPostButtons() { - if (this.props.discussionsState == null || !isBeatmapsetDiscussionJsonForShow(this.props.discussion)) { + if (this.props.discussionsState == null) { return null; } diff --git a/resources/js/modding-profile/discussions.tsx b/resources/js/modding-profile/discussions.tsx index ffdaaee3917..84179a5089a 100644 --- a/resources/js/modding-profile/discussions.tsx +++ b/resources/js/modding-profile/discussions.tsx @@ -53,7 +53,6 @@ export default class Discussions extends React.Component { discussion={discussion} discussionsState={null} isTimelineVisible={false} - readonly store={this.props.store} />
    From efcdec8a284c5ab49bc41d109f2a87ef5a53239f Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 11 Jan 2024 22:45:38 +0900 Subject: [PATCH 143/203] Update score table structure - a bunch of attributes are moved out of the json - pp and legacy id moved to main scores table --- app/Http/Controllers/BeatmapsController.php | 2 +- app/Jobs/RemoveBeatmapsetSoloScores.php | 2 - app/Libraries/Search/ScoreSearch.php | 4 +- .../Multiplayer/PlaylistItemUserHighScore.php | 6 +- app/Models/Multiplayer/ScoreLink.php | 2 +- app/Models/Multiplayer/UserScoreAggregate.php | 8 +- app/Models/Score/Model.php | 29 ++-- app/Models/Solo/Score.php | 135 ++++++++++++------ app/Models/Solo/ScoreData.php | 84 +---------- app/Models/Solo/ScorePerformance.php | 21 --- app/Transformers/ScoreTransformer.php | 72 ++++++---- database/factories/Solo/ScoreFactory.php | 22 ++- ...01_12_115738_update_scores_table_final.php | 79 ++++++++++ docker-compose.yml | 6 +- .../BeatmapsControllerSoloScoresTest.php | 61 ++++---- .../Rooms/Playlist/ScoresControllerTest.php | 12 +- .../PasswordResetControllerTest.php | 11 ++ tests/Controllers/ScoresControllerTest.php | 2 +- tests/Jobs/RemoveBeatmapsetSoloScoresTest.php | 9 +- tests/Models/ContestTest.php | 2 +- tests/Models/Multiplayer/ScoreLinkTest.php | 62 ++++---- .../Multiplayer/UserScoreAggregateTest.php | 3 +- tests/Models/Solo/ScoreEsIndexTest.php | 24 ++-- tests/Models/Solo/ScoreTest.php | 24 ++-- 24 files changed, 358 insertions(+), 324 deletions(-) delete mode 100644 app/Models/Solo/ScorePerformance.php create mode 100644 database/migrations/2024_01_12_115738_update_scores_table_final.php diff --git a/app/Http/Controllers/BeatmapsController.php b/app/Http/Controllers/BeatmapsController.php index 97158c45737..097852301e7 100644 --- a/app/Http/Controllers/BeatmapsController.php +++ b/app/Http/Controllers/BeatmapsController.php @@ -399,7 +399,7 @@ public function soloScores($id) 'type' => $type, 'user' => $currentUser, ]); - $scores = $esFetch->all()->loadMissing(['beatmap', 'performance', 'user.country', 'user.userProfileCustomization']); + $scores = $esFetch->all()->loadMissing(['beatmap', 'user.country', 'user.userProfileCustomization']); $userScore = $esFetch->userBest(); $scoreTransformer = new ScoreTransformer(ScoreTransformer::TYPE_SOLO); diff --git a/app/Jobs/RemoveBeatmapsetSoloScores.php b/app/Jobs/RemoveBeatmapsetSoloScores.php index b70d45947e4..cfe16299e1a 100644 --- a/app/Jobs/RemoveBeatmapsetSoloScores.php +++ b/app/Jobs/RemoveBeatmapsetSoloScores.php @@ -11,7 +11,6 @@ use App\Models\Beatmap; use App\Models\Beatmapset; use App\Models\Solo\Score; -use App\Models\Solo\ScorePerformance; use DB; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -68,7 +67,6 @@ private function deleteScores(Collection $scores): void $scoresQuery->update(['preserve' => false]); $this->scoreSearch->queueForIndex($this->schemas, $ids); DB::transaction(function () use ($ids, $scoresQuery): void { - ScorePerformance::whereKey($ids)->delete(); $scoresQuery->delete(); }); } diff --git a/app/Libraries/Search/ScoreSearch.php b/app/Libraries/Search/ScoreSearch.php index c7125d1ce28..08f17c1e9e1 100644 --- a/app/Libraries/Search/ScoreSearch.php +++ b/app/Libraries/Search/ScoreSearch.php @@ -68,8 +68,8 @@ public function getQuery(): BoolQuery $beforeTotalScore = $this->params->beforeTotalScore; if ($beforeTotalScore === null && $this->params->beforeScore !== null) { $beforeTotalScore = $this->params->beforeScore->isLegacy() - ? $this->params->beforeScore->data->legacyTotalScore - : $this->params->beforeScore->data->totalScore; + ? $this->params->beforeScore->legacy_total_score + : $this->params->beforeScore->total_score; } if ($beforeTotalScore !== null) { $scoreQuery = (new BoolQuery())->shouldMatch(1); diff --git a/app/Models/Multiplayer/PlaylistItemUserHighScore.php b/app/Models/Multiplayer/PlaylistItemUserHighScore.php index 05617852f3d..8adf655d761 100644 --- a/app/Models/Multiplayer/PlaylistItemUserHighScore.php +++ b/app/Models/Multiplayer/PlaylistItemUserHighScore.php @@ -71,7 +71,7 @@ public static function scoresAround(ScoreLink $scoreLink): array { $placeholder = new static([ 'score_id' => $scoreLink->getKey(), - 'total_score' => $scoreLink->score->data->totalScore, + 'total_score' => $scoreLink->score->total_score, ]); static $typeOptions = [ @@ -117,10 +117,10 @@ public function updateWithScoreLink(ScoreLink $scoreLink): void $score = $scoreLink->score; $this->fill([ - 'accuracy' => $score->data->accuracy, + 'accuracy' => $score->accuracy, 'pp' => $score->pp, 'score_id' => $scoreLink->getKey(), - 'total_score' => $score->data->totalScore, + 'total_score' => $score->total_score, ])->save(); } } diff --git a/app/Models/Multiplayer/ScoreLink.php b/app/Models/Multiplayer/ScoreLink.php index 8bca690c1e2..f2977be647f 100644 --- a/app/Models/Multiplayer/ScoreLink.php +++ b/app/Models/Multiplayer/ScoreLink.php @@ -109,7 +109,7 @@ public function position(): ?int $query = PlaylistItemUserHighScore ::where('playlist_item_id', $this->playlist_item_id) ->cursorSort('score_asc', [ - 'total_score' => $score->data->totalScore, + 'total_score' => $score->total_score, 'score_id' => $this->getKey(), ]); diff --git a/app/Models/Multiplayer/UserScoreAggregate.php b/app/Models/Multiplayer/UserScoreAggregate.php index db0a9146378..9569542252f 100644 --- a/app/Models/Multiplayer/UserScoreAggregate.php +++ b/app/Models/Multiplayer/UserScoreAggregate.php @@ -83,7 +83,7 @@ public function addScoreLink(ScoreLink $scoreLink, ?PlaylistItemUserHighScore $h $scoreLink->playlist_item_id, ); - if ($score->data->passed && $score->data->totalScore > $highestScore->total_score) { + if ($score->passed && $score->total_score > $highestScore->total_score) { $this->updateUserTotal($scoreLink, $highestScore); $highestScore->updateWithScoreLink($scoreLink); } @@ -134,7 +134,7 @@ public function recalculate() $scoreLinks = ScoreLink ::whereHas('playlistItem', fn ($q) => $q->where('room_id', $this->room_id)) ->where('user_id', $this->user_id) - ->with('score.performance') + ->with('score') ->get(); foreach ($scoreLinks as $scoreLink) { $this->addScoreLink( @@ -221,8 +221,8 @@ private function updateUserTotal(ScoreLink $currentScoreLink, PlaylistItemUserHi $current = $currentScoreLink->score; - $this->total_score += $current->data->totalScore; - $this->accuracy += $current->data->accuracy; + $this->total_score += $current->total_score; + $this->accuracy += $current->accuracy; $this->pp += $current->pp; $this->completed++; $this->last_score_id = $currentScoreLink->getKey(); diff --git a/app/Models/Score/Model.php b/app/Models/Score/Model.php index a9befb727b0..16e636361fc 100644 --- a/app/Models/Score/Model.php +++ b/app/Models/Score/Model.php @@ -5,11 +5,11 @@ namespace App\Models\Score; +use App\Enums\Ruleset; use App\Exceptions\ClassNotFoundException; use App\Libraries\Mods; use App\Models\Beatmap; use App\Models\Model as BaseModel; -use App\Models\Solo\ScoreData; use App\Models\Traits\Scoreable; use App\Models\User; @@ -161,28 +161,27 @@ public function getMode(): string return snake_case(get_class_basename(static::class)); } - protected function getData() + public function statistics(): array { - $mods = array_map(fn ($m) => ['acronym' => $m, 'settings' => []], $this->enabled_mods); $statistics = [ 'miss' => $this->countmiss, 'great' => $this->count300, ]; - $ruleset = $this->getMode(); + $ruleset = Ruleset::tryFromName($this->getMode()); switch ($ruleset) { - case 'osu': + case Ruleset::osu: $statistics['ok'] = $this->count100; $statistics['meh'] = $this->count50; break; - case 'taiko': + case Ruleset::taiko: $statistics['ok'] = $this->count100; break; - case 'fruits': + case Ruleset::catch: $statistics['large_tick_hit'] = $this->count100; $statistics['small_tick_hit'] = $this->count50; $statistics['small_tick_miss'] = $this->countkatu; break; - case 'mania': + case Ruleset::mania: $statistics['perfect'] = $this->countgeki; $statistics['good'] = $this->countkatu; $statistics['ok'] = $this->count100; @@ -190,18 +189,6 @@ protected function getData() break; } - return new ScoreData([ - 'accuracy' => $this->accuracy(), - 'beatmap_id' => $this->beatmap_id, - 'ended_at' => $this->date_json, - 'max_combo' => $this->maxcombo, - 'mods' => $mods, - 'passed' => $this->pass, - 'rank' => $this->rank, - 'ruleset_id' => Beatmap::modeInt($ruleset), - 'statistics' => $statistics, - 'total_score' => $this->score, - 'user_id' => $this->user_id, - ]); + return $statistics; } } diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index 1c4841b188c..dcbf8231951 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -7,6 +7,8 @@ namespace App\Models\Solo; +use App\Enums\ScoreRank; +use App\Exceptions\InvariantException; use App\Libraries\Score\UserRank; use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; @@ -16,7 +18,6 @@ use App\Models\ScoreToken; use App\Models\Traits; use App\Models\User; -use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use LaravelRedis; use Storage; @@ -45,26 +46,36 @@ class Score extends Model implements Traits\ReportableInterface protected $casts = [ 'data' => ScoreData::class, + 'ended_at' => 'datetime', 'has_replay' => 'boolean', + 'passed' => 'boolean', 'preserve' => 'boolean', + 'ranked' => 'boolean', + 'started_at' => 'datetime', ]; - public static function createFromJsonOrExplode(array $params) + public static function createFromJsonOrExplode(array $params): static { - $score = new static([ - 'beatmap_id' => $params['beatmap_id'], - 'ruleset_id' => $params['ruleset_id'], - 'user_id' => $params['user_id'], - 'data' => $params, - ]); + $params['data'] = [ + 'maximum_statistics' => $params['maximum_statistics'] ?? [], + 'mods' => $params['mods'] ?? [], + 'statistics' => $params['statistics'] ?? [], + ]; + unset( + $params['maximum_statistics'], + $params['mods'], + $params['statistics'], + ); + + $score = new static($params); - $score->data->assertCompleted(); + $score->assertCompleted(); // this should potentially just be validation rather than applying this logic here, but // older lazer builds potentially submit incorrect details here (and we still want to // accept their scores. - if (!$score->data->passed) { - $score->data->rank = 'F'; + if (!$score->passed) { + $score->rank = 'F'; } $score->saveOrExplode(); @@ -72,26 +83,32 @@ public static function createFromJsonOrExplode(array $params) return $score; } - public static function extractParams(array $params, ScoreToken|MultiplayerScoreLink $scoreToken): array + public static function extractParams(array $rawParams, ScoreToken|MultiplayerScoreLink $scoreToken): array { - return [ - ...get_params($params, null, [ - 'accuracy:float', - 'max_combo:int', - 'maximum_statistics:array', - 'passed:bool', - 'rank:string', - 'statistics:array', - 'total_score:int', - ]), - 'beatmap_id' => $scoreToken->beatmap_id, - 'build_id' => $scoreToken->build_id, - 'ended_at' => json_time(Carbon::now()), - 'mods' => app('mods')->parseInputArray($scoreToken->ruleset_id, get_arr($params['mods'] ?? null) ?? []), - 'ruleset_id' => $scoreToken->ruleset_id, - 'started_at' => $scoreToken->created_at_json, - 'user_id' => $scoreToken->user_id, - ]; + $params = get_params($rawParams, null, [ + 'accuracy:float', + 'max_combo:int', + 'maximum_statistics:array', + 'mods:array', + 'passed:bool', + 'rank:string', + 'statistics:array', + 'total_score:int', + ]); + + $params['maximum_statistics'] ??= []; + $params['statistics'] ??= []; + + $params['mods'] = app('mods')->parseInputArray($scoreToken->ruleset_id, $params['mods'] ?? []); + + $params['beatmap_id'] = $scoreToken->beatmap_id; + $params['build_id'] = $scoreToken->build_id; + $params['ended_at'] = new \DateTime(); + $params['ruleset_id'] = $scoreToken->ruleset_id; + $params['started_at'] = $scoreToken->created_at; + $params['user_id'] = $scoreToken->user_id; + + return $params; } /** @@ -119,11 +136,6 @@ public function beatmap() return $this->belongsTo(Beatmap::class, 'beatmap_id'); } - public function performance() - { - return $this->hasOne(ScorePerformance::class, 'score_id'); - } - public function user() { return $this->belongsTo(User::class, 'user_id'); @@ -147,22 +159,34 @@ public function scopeIndexable(Builder $query): Builder public function getAttribute($key) { return match ($key) { + 'accuracy', 'beatmap_id', + 'build_id', 'id', + 'legacy_score_id', + 'legacy_total_score', + 'max_combo', + 'pp', 'ruleset_id', + 'total_score', 'unix_updated_at', 'user_id' => $this->getRawAttribute($key), + 'rank' => $this->getRawAttribute($key) ?? 'F', + 'data' => $this->getClassCastableAttributeValue($key, $this->getRawAttribute($key)), 'has_replay', - 'preserve', - 'ranked' => (bool) $this->getRawAttribute($key), + 'passed', + 'preserve' => (bool) $this->getRawAttribute($key), + + 'ranked' => (bool) ($this->getRawAttribute($key) ?? true), - 'created_at' => $this->getTimeFast($key), - 'created_at_json' => $this->getJsonTimeFast($key), + 'ended_at', + 'started_at' => $this->getTimeFast($key), - 'pp' => $this->performance?->pp, + 'ended_at_json', + 'started_at_json' => $this->getJsonTimeFast($key), 'beatmap', 'performance', @@ -171,6 +195,23 @@ public function getAttribute($key) }; } + public function assertCompleted(): void + { + if (ScoreRank::tryFrom($this->rank ?? '') === null) { + throw new InvariantException("'{$this->rank}' is not a valid rank."); + } + + foreach (['total_score', 'accuracy', 'max_combo', 'passed'] as $field) { + if (!present($this->$field)) { + throw new InvariantException("field missing: '{$field}'"); + } + } + + if ($this->data->statistics->isEmpty()) { + throw new InvariantException("field cannot be empty: 'statistics'"); + } + } + public function createLegacyEntryOrExplode() { $score = $this->makeLegacyEntry(); @@ -193,12 +234,12 @@ public function getReplayFile(): ?string public function isLegacy(): bool { - return $this->data->buildId === null; + return $this->legacy_score_id !== null; } public function legacyScore(): ?LegacyScore\Best\Model { - $id = $this->data->legacyScoreId; + $id = $this->legacy_score_id; return $id === null ? null @@ -216,11 +257,11 @@ public function makeLegacyEntry(): LegacyScore\Model 'beatmapset_id' => $this->beatmap?->beatmapset_id ?? 0, 'countmiss' => $statistics->miss, 'enabled_mods' => app('mods')->idsToBitset(array_column($data->mods, 'acronym')), - 'maxcombo' => $data->maxCombo, - 'pass' => $data->passed, - 'perfect' => $data->passed && $statistics->miss + $statistics->large_tick_miss === 0, - 'rank' => $data->rank, - 'score' => $data->totalScore, + 'maxcombo' => $this->max_combo, + 'pass' => $this->passed, + 'perfect' => $this->passed && $statistics->miss + $statistics->large_tick_miss === 0, + 'rank' => $this->rank, + 'score' => $this->total_score, 'scorechecksum' => "\0", 'user_id' => $this->user_id, ]); diff --git a/app/Models/Solo/ScoreData.php b/app/Models/Solo/ScoreData.php index ebebeaf61fd..51ed8bc0b98 100644 --- a/app/Models/Solo/ScoreData.php +++ b/app/Models/Solo/ScoreData.php @@ -7,30 +7,15 @@ namespace App\Models\Solo; -use App\Enums\ScoreRank; -use App\Exceptions\InvariantException; use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use JsonSerializable; class ScoreData implements Castable, JsonSerializable { - public float $accuracy; - public int $beatmapId; - public ?int $buildId; - public string $endedAt; - public ?int $legacyScoreId; - public ?int $legacyTotalScore; - public int $maxCombo; public ScoreDataStatistics $maximumStatistics; public array $mods; - public bool $passed; - public string $rank; - public int $rulesetId; - public ?string $startedAt; public ScoreDataStatistics $statistics; - public int $totalScore; - public int $userId; public function __construct(array $data) { @@ -51,22 +36,9 @@ public function __construct(array $data) } } - $this->accuracy = $data['accuracy'] ?? 0; - $this->beatmapId = $data['beatmap_id']; - $this->buildId = $data['build_id'] ?? null; - $this->endedAt = $data['ended_at']; - $this->legacyScoreId = $data['legacy_score_id'] ?? null; - $this->legacyTotalScore = $data['legacy_total_score'] ?? null; - $this->maxCombo = $data['max_combo'] ?? 0; $this->maximumStatistics = new ScoreDataStatistics($data['maximum_statistics'] ?? []); $this->mods = $mods; - $this->passed = $data['passed'] ?? false; - $this->rank = $data['rank'] ?? 'F'; - $this->rulesetId = $data['ruleset_id']; - $this->startedAt = $data['started_at'] ?? null; $this->statistics = new ScoreDataStatistics($data['statistics'] ?? []); - $this->totalScore = $data['total_score'] ?? 0; - $this->userId = $data['user_id']; } public static function castUsing(array $arguments) @@ -75,25 +47,13 @@ public static function castUsing(array $arguments) { public function get($model, $key, $value, $attributes) { - $dataJson = json_decode($value, true); - $dataJson['beatmap_id'] ??= $attributes['beatmap_id']; - $dataJson['ended_at'] ??= $model->created_at_json; - $dataJson['ruleset_id'] ??= $attributes['ruleset_id']; - $dataJson['user_id'] ??= $attributes['user_id']; - - return new ScoreData($dataJson); + return new ScoreData(json_decode($value, true)); } public function set($model, $key, $value, $attributes) { if (!($value instanceof ScoreData)) { - $value = new ScoreData([ - 'beatmap_id' => $attributes['beatmap_id'] ?? null, - 'ended_at' => $attributes['created_at'] ?? null, - 'ruleset_id' => $attributes['ruleset_id'] ?? null, - 'user_id' => $attributes['user_id'] ?? null, - ...$value, - ]); + $value = new ScoreData($value); } return ['data' => json_encode($value)]; @@ -101,50 +61,12 @@ public function set($model, $key, $value, $attributes) }; } - public function assertCompleted(): void - { - if (ScoreRank::tryFrom($this->rank) === null) { - throw new InvariantException("'{$this->rank}' is not a valid rank."); - } - - foreach (['totalScore', 'accuracy', 'maxCombo', 'passed'] as $field) { - if (!present($this->$field)) { - throw new InvariantException("field missing: '{$field}'"); - } - } - - if ($this->statistics->isEmpty()) { - throw new InvariantException("field cannot be empty: 'statistics'"); - } - } - public function jsonSerialize(): array { - $ret = [ - 'accuracy' => $this->accuracy, - 'beatmap_id' => $this->beatmapId, - 'build_id' => $this->buildId, - 'ended_at' => $this->endedAt, - 'legacy_score_id' => $this->legacyScoreId, - 'legacy_total_score' => $this->legacyTotalScore, - 'max_combo' => $this->maxCombo, + return [ 'maximum_statistics' => $this->maximumStatistics, 'mods' => $this->mods, - 'passed' => $this->passed, - 'rank' => $this->rank, - 'ruleset_id' => $this->rulesetId, - 'started_at' => $this->startedAt, 'statistics' => $this->statistics, - 'total_score' => $this->totalScore, - 'user_id' => $this->userId, ]; - - foreach ($ret as $field => $value) { - if ($value === null) { - unset($ret[$field]); - } - } - - return $ret; } } diff --git a/app/Models/Solo/ScorePerformance.php b/app/Models/Solo/ScorePerformance.php deleted file mode 100644 index b30a1f9a410..00000000000 --- a/app/Models/Solo/ScorePerformance.php +++ /dev/null @@ -1,21 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -namespace App\Models\Solo; - -use App\Models\Model; - -/** - * @property int $score_id - * @property float|null $pp - */ -class ScorePerformance extends Model -{ - public $incrementing = false; - public $timestamps = false; - - protected $primaryKey = 'score_id'; - protected $table = 'score_performance'; -} diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index 8526ad7ea48..45f2df4f46e 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -7,6 +7,7 @@ namespace App\Transformers; +use App\Enums\Ruleset; use App\Models\Beatmap; use App\Models\DeletedUser; use App\Models\LegacyMatch; @@ -22,7 +23,7 @@ class ScoreTransformer extends TransformerAbstract const MULTIPLAYER_BASE_INCLUDES = ['user.country', 'user.cover']; // warning: the preload is actually for PlaylistItemUserHighScore, not for Score const MULTIPLAYER_BASE_PRELOAD = [ - 'scoreLink.score.performance', + 'scoreLink.score', 'scoreLink.user.country', 'scoreLink.user.userProfileCustomization', ]; @@ -90,43 +91,62 @@ public function transform(LegacyMatch\Score|MultiplayerScoreLink|ScoreModel|Solo public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) { + $ret = [ + 'best_id' => null, + 'build_id' => null, + 'has_replay' => false, + 'legacy_perfect' => null, + 'pp' => null, + ]; if ($score instanceof ScoreModel) { - $legacyPerfect = $score->perfect; $best = $score->best; - if ($best !== null) { - $bestId = $best->getKey(); - $pp = $best->pp; - $hasReplay = $best->replay; + $ret['best_id'] = $best->getKey(); + $ret['has_replay'] = $best->replay; + $ret['pp'] = $best->pp; } + + $ret['accuracy'] = $score->accuracy(); + $ret['ended_at'] = $score->date_json; + $ret['legacy_perfect'] = $score->perfect; + $ret['max_combo'] = $score->maxcombo; + $ret['mods'] = array_map(fn ($m) => ['acronym' => $m, 'settings' => []], $score->enabled_mods); + $ret['passed'] = $score->pass; + $ret['ruleset_id'] = Ruleset::tryFromName($score->getMode())->value; + $ret['statistics'] = $score->statistics(); + $ret['total_score'] = $score->score; } else { if ($score instanceof MultiplayerScoreLink) { - $multiplayerAttributes = [ - 'playlist_item_id' => $score->playlist_item_id, - 'room_id' => $score->playlistItem->room_id, - 'solo_score_id' => $score->score_id, - ]; + $ret['playlist_item_id'] = $score->playlist_item_id; + $ret['room_id'] = $score->playlistItem->room_id; + $ret['solo_score_id'] = $score->score_id; $score = $score->score; } - $pp = $score->pp; - $hasReplay = $score->has_replay; + $data = $score->data; + $ret['accuracy'] = $score->accuracy; + $ret['build_id'] = $score->build_id; + $ret['ended_at'] = $score->ended_at_json; + $ret['has_replay'] = $score->has_replay; + $ret['maximum_statistics'] = $data->maximumStatistics; + $ret['mods'] = $data->mods; + $ret['pp'] = $score->pp; + $ret['ruleset_id'] = $score->ruleset_id; + $ret['started_at'] = $score->started_at_json; + $ret['statistics'] = $data->statistics; + $ret['total_score'] = $score->total_score; } - $hasReplay ??= false; + $ret['beatmap_id'] = $score->beatmap_id; + $ret['id'] = $score->getKey(); + $ret['rank'] = $score->rank; + $ret['type'] = $score->getMorphClass(); + $ret['user_id'] = $score->user_id; - return [ - ...$score->data->jsonSerialize(), - ...($multiplayerAttributes ?? []), - 'best_id' => $bestId ?? null, - 'has_replay' => $hasReplay, - 'id' => $score->getKey(), - 'legacy_perfect' => $legacyPerfect ?? null, - 'pp' => $pp ?? null, - // TODO: remove this redundant field sometime after 2024-02 - 'replay' => $hasReplay, - 'type' => $score->getMorphClass(), - ]; + // TODO: remove this redundant field sometime after 2024-02 + $ret['replay'] = $ret['has_replay']; + + return $ret; } public function transformLegacy(LegacyMatch\Score|ScoreModel|SoloScore $score) diff --git a/database/factories/Solo/ScoreFactory.php b/database/factories/Solo/ScoreFactory.php index f7c39b3233a..de75879064f 100644 --- a/database/factories/Solo/ScoreFactory.php +++ b/database/factories/Solo/ScoreFactory.php @@ -7,6 +7,7 @@ namespace Database\Factories\Solo; +use App\Enums\ScoreRank; use App\Models\Beatmap; use App\Models\Solo\Score; use App\Models\User; @@ -19,7 +20,12 @@ class ScoreFactory extends Factory public function definition(): array { return [ + 'accuracy' => fn (): float => $this->faker->randomFloat(1, 0, 1), 'beatmap_id' => Beatmap::factory()->ranked(), + 'ended_at' => new \DateTime(), + 'pp' => fn (): float => $this->faker->randomFloat(4, 0, 1000), + 'rank' => fn () => array_rand_val(ScoreRank::cases())->value, + 'total_score' => fn (): int => $this->faker->randomNumber(7), 'user_id' => User::factory(), // depends on beatmap_id @@ -41,19 +47,11 @@ private function makeData(?array $overrides = null): callable { return fn (array $attr): array => array_map( fn ($value) => is_callable($value) ? $value($attr) : $value, - array_merge([ - 'accuracy' => fn (): float => $this->faker->randomFloat(1, 0, 1), - 'beatmap_id' => $attr['beatmap_id'], - 'ended_at' => fn (): string => json_time(now()), - 'max_combo' => fn (): int => rand(1, Beatmap::find($attr['beatmap_id'])->countNormal), + [ + 'statistics' => ['great' => 1], 'mods' => [], - 'passed' => true, - 'rank' => fn (): string => array_rand_val(['A', 'S', 'B', 'SH', 'XH', 'X']), - 'ruleset_id' => $attr['ruleset_id'], - 'started_at' => fn (): string => json_time(now()->subSeconds(600)), - 'total_score' => fn (): int => $this->faker->randomNumber(7), - 'user_id' => $attr['user_id'], - ], $overrides ?? []), + ...($overrides ?? []), + ], ); } } diff --git a/database/migrations/2024_01_12_115738_update_scores_table_final.php b/database/migrations/2024_01_12_115738_update_scores_table_final.php new file mode 100644 index 00000000000..53073651725 --- /dev/null +++ b/database/migrations/2024_01_12_115738_update_scores_table_final.php @@ -0,0 +1,79 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + private static function resetView(): void + { + DB::statement('DROP VIEW scores'); + DB::statement('CREATE VIEW scores AS SELECT * FROM solo_scores'); + } + + public function up(): void + { + Schema::drop('solo_scores'); + Schema::drop('solo_scores_performance'); + DB::statement("CREATE TABLE `solo_scores` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `ruleset_id` smallint unsigned NOT NULL, + `beatmap_id` mediumint unsigned NOT NULL, + `has_replay` tinyint NOT NULL DEFAULT '0', + `preserve` tinyint NOT NULL DEFAULT '0', + `ranked` tinyint NOT NULL DEFAULT '1', + `rank` char(2) NOT NULL DEFAULT '', + `passed` tinyint NOT NULL DEFAULT '0', + `accuracy` float NOT NULL DEFAULT '0', + `max_combo` int unsigned NOT NULL DEFAULT '0', + `total_score` int unsigned NOT NULL DEFAULT '0', + `data` json NOT NULL, + `pp` float DEFAULT NULL, + `legacy_score_id` bigint unsigned DEFAULT NULL, + `legacy_total_score` int unsigned NOT NULL DEFAULT '0', + `started_at` timestamp NULL DEFAULT NULL, + `ended_at` timestamp NOT NULL, + `unix_updated_at` int unsigned NOT NULL DEFAULT (unix_timestamp()), + `build_id` smallint unsigned DEFAULT NULL, + PRIMARY KEY (`id`,`preserve`,`unix_updated_at`), + KEY `user_ruleset_index` (`user_id`,`ruleset_id`), + KEY `beatmap_user_index` (`beatmap_id`,`user_id`), + KEY `legacy_score_lookup` (`ruleset_id`,`legacy_score_id`) + )"); + + static::resetView(); + } + + public function down(): void + { + Schema::drop('solo_scores'); + DB::statement("CREATE TABLE `solo_scores` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `user_id` int unsigned NOT NULL, + `beatmap_id` mediumint unsigned NOT NULL, + `ruleset_id` smallint unsigned NOT NULL, + `data` json NOT NULL, + `has_replay` tinyint DEFAULT '0', + `preserve` tinyint NOT NULL DEFAULT '0', + `ranked` tinyint NOT NULL DEFAULT '1', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `unix_updated_at` int unsigned NOT NULL DEFAULT (unix_timestamp()), + PRIMARY KEY (`id`,`preserve`,`unix_updated_at`), + KEY `user_ruleset_index` (`user_id`,`ruleset_id`), + KEY `beatmap_user_index` (`beatmap_id`,`user_id`) + )"); + DB::statement('CREATE TABLE `solo_scores_performance` ( + `score_id` bigint unsigned NOT NULL, + `pp` float DEFAULT NULL, + PRIMARY KEY (`score_id`) + )'); + + static::resetView(); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index 40ce3c0bcc1..297936f5e18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -145,7 +145,8 @@ services: - "${NGINX_PORT:-8080}:80" score-indexer: - image: pppy/osu-elastic-indexer:master + # TODO: switch to main image once updated with support for new score structure + image: nanaya/osu-elastic-indexer:v44 command: ["queue", "watch"] depends_on: redis: @@ -159,7 +160,8 @@ services: SCHEMA: "${SCHEMA:-1}" score-indexer-test: - image: pppy/osu-elastic-indexer:master + # TODO: switch to main image once updated with support for new score structure + image: nanaya/osu-elastic-indexer:v44 command: ["queue", "watch"] depends_on: redis: diff --git a/tests/Controllers/BeatmapsControllerSoloScoresTest.php b/tests/Controllers/BeatmapsControllerSoloScoresTest.php index eedbaf7c9f8..c632fe41952 100644 --- a/tests/Controllers/BeatmapsControllerSoloScoresTest.php +++ b/tests/Controllers/BeatmapsControllerSoloScoresTest.php @@ -41,108 +41,115 @@ public static function setUpBeforeClass(): void $countryAcronym = static::$user->country_acronym; static::$scores = []; - $scoreFactory = SoloScore::factory(); - foreach (['solo' => 0, 'legacy' => null] as $type => $buildId) { - $defaultData = ['build_id' => $buildId]; + $scoreFactory = SoloScore::factory()->state(['build_id' => 0]); + foreach (['solo' => null, 'legacy' => 1] as $type => $legacyScoreId) { + $scoreFactory = $scoreFactory->state([ + 'legacy_score_id' => $legacyScoreId, + ]); - static::$scores = array_merge(static::$scores, [ - "{$type}:user" => $scoreFactory->withData($defaultData, ['total_score' => 1100])->create([ + static::$scores = [ + ...static::$scores, + "{$type}:user" => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1100, 'user_id' => static::$user, ]), - "{$type}:userMods" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1050, + "{$type}:userMods" => $scoreFactory->withData([ 'mods' => static::defaultMods(['DT', 'HD']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1050, 'user_id' => static::$user, ]), - "{$type}:userModsNC" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1050, + "{$type}:userModsNC" => $scoreFactory->withData([ 'mods' => static::defaultMods(['NC']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1050, 'user_id' => static::$user, ]), - "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1010, + "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData([ 'mods' => static::defaultMods(['NC', 'PF']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1010, 'user_id' => static::$otherUser, ]), - "{$type}:userModsLowerScore" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, + "{$type}:userModsLowerScore" => $scoreFactory->withData([ 'mods' => static::defaultMods(['DT', 'HD']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => static::$user, ]), - "{$type}:friend" => $scoreFactory->withData($defaultData, ['total_score' => 1000])->create([ + "{$type}:friend" => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => $friend, ]), // With preference mods - "{$type}:otherUser" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, + "{$type}:otherUser" => $scoreFactory->withData([ 'mods' => static::defaultMods(['PF']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => static::$otherUser, ]), - "{$type}:otherUserMods" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, + "{$type}:otherUserMods" => $scoreFactory->withData([ 'mods' => static::defaultMods(['HD', 'PF', 'NC']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => static::$otherUser, ]), - "{$type}:otherUserModsExtraNonPreferences" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, + "{$type}:otherUserModsExtraNonPreferences" => $scoreFactory->withData([ 'mods' => static::defaultMods(['DT', 'HD', 'HR']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => static::$otherUser, ]), - "{$type}:otherUserModsUnrelated" => $scoreFactory->withData($defaultData, [ - 'total_score' => 1000, + "{$type}:otherUserModsUnrelated" => $scoreFactory->withData([ 'mods' => static::defaultMods(['FL']), ])->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => static::$otherUser, ]), // Same total score but achieved later so it should come up after earlier score - "{$type}:otherUser2Later" => $scoreFactory->withData($defaultData, ['total_score' => 1000])->create([ + "{$type}:otherUser2Later" => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), ]), - "{$type}:otherUser3SameCountry" => $scoreFactory->withData($defaultData, ['total_score' => 1000])->create([ + "{$type}:otherUser3SameCountry" => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, 'preserve' => true, + 'total_score' => 1000, 'user_id' => User::factory()->state(['country_acronym' => $countryAcronym]), ]), // Non-preserved score should be filtered out - "{$type}:nonPreserved" => $scoreFactory->withData($defaultData)->create([ + "{$type}:nonPreserved" => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, 'preserve' => false, 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), ]), // Unrelated score - "{$type}:unrelated" => $scoreFactory->withData($defaultData)->create([ + "{$type}:unrelated" => $scoreFactory->create([ 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), ]), - ]); + ]; } UserRelation::create([ diff --git a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php index d9efb5b7fb9..d42526b930c 100644 --- a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php +++ b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php @@ -23,19 +23,19 @@ public function testIndex() $scoreLinks[] = ScoreLink ::factory() ->state(['playlist_item_id' => $playlist]) - ->completed([], ['passed' => true, 'total_score' => 30]) + ->completed(['passed' => true, 'total_score' => 30]) ->create(); $scoreLinks[] = $userScoreLink = ScoreLink ::factory() ->state([ 'playlist_item_id' => $playlist, 'user_id' => $user, - ])->completed([], ['passed' => true, 'total_score' => 20]) + ])->completed(['passed' => true, 'total_score' => 20]) ->create(); $scoreLinks[] = ScoreLink ::factory() ->state(['playlist_item_id' => $playlist]) - ->completed([], ['passed' => true, 'total_score' => 10]) + ->completed(['passed' => true, 'total_score' => 10]) ->create(); foreach ($scoreLinks as $scoreLink) { @@ -65,19 +65,19 @@ public function testShow() $scoreLinks[] = ScoreLink ::factory() ->state(['playlist_item_id' => $playlist]) - ->completed([], ['passed' => true, 'total_score' => 30]) + ->completed(['passed' => true, 'total_score' => 30]) ->create(); $scoreLinks[] = $userScoreLink = ScoreLink ::factory() ->state([ 'playlist_item_id' => $playlist, 'user_id' => $user, - ])->completed([], ['passed' => true, 'total_score' => 20]) + ])->completed(['passed' => true, 'total_score' => 20]) ->create(); $scoreLinks[] = ScoreLink ::factory() ->state(['playlist_item_id' => $playlist]) - ->completed([], ['passed' => true, 'total_score' => 10]) + ->completed(['passed' => true, 'total_score' => 10]) ->create(); foreach ($scoreLinks as $scoreLink) { diff --git a/tests/Controllers/PasswordResetControllerTest.php b/tests/Controllers/PasswordResetControllerTest.php index 75a6462a20f..0629c19deec 100644 --- a/tests/Controllers/PasswordResetControllerTest.php +++ b/tests/Controllers/PasswordResetControllerTest.php @@ -16,6 +16,8 @@ class PasswordResetControllerTest extends TestCase { + private string $origCacheDefault; + private static function randomPassword(): string { return str_random(10); @@ -259,6 +261,15 @@ protected function setUp(): void { parent::setUp(); $this->withoutMiddleware(ThrottleRequests::class); + // There's no easy way to clear data cache in redis otherwise + $this->origCacheDefault = $GLOBALS['cfg']['cache']['default']; + config_set('cache.default', 'array'); + } + + protected function tearDown(): void + { + parent::tearDown(); + config_set('cache.default', $this->origCacheDefault); } private function generateKey(User $user): string diff --git a/tests/Controllers/ScoresControllerTest.php b/tests/Controllers/ScoresControllerTest.php index ec4a585a24f..3620fa5b494 100644 --- a/tests/Controllers/ScoresControllerTest.php +++ b/tests/Controllers/ScoresControllerTest.php @@ -33,8 +33,8 @@ public function testDownload() public function testDownloadSoloScore() { $soloScore = SoloScore::factory() - ->withData(['legacy_score_id' => $this->score->getKey()]) ->create([ + 'legacy_score_id' => $this->score->getKey(), 'ruleset_id' => Beatmap::MODES[$this->score->getMode()], 'has_replay' => true, ]); diff --git a/tests/Jobs/RemoveBeatmapsetSoloScoresTest.php b/tests/Jobs/RemoveBeatmapsetSoloScoresTest.php index e378f83e857..df4d3cb592e 100644 --- a/tests/Jobs/RemoveBeatmapsetSoloScoresTest.php +++ b/tests/Jobs/RemoveBeatmapsetSoloScoresTest.php @@ -16,7 +16,6 @@ use App\Models\Group; use App\Models\Language; use App\Models\Solo\Score; -use App\Models\Solo\ScorePerformance; use App\Models\User; use App\Models\UserGroup; use App\Models\UserGroupEvent; @@ -36,9 +35,6 @@ public function testHandle() fn (): Score => $this->createScore($beatmapset), array_fill(0, 10, null), ); - foreach ($scores as $i => $score) { - $score->performance()->create(['pp' => rand(0, 1000)]); - } $userAdditionalScores = array_map( fn (Score $score) => $this->createScore($beatmapset, $score->user_id, $score->ruleset_id), $scores, @@ -48,12 +44,10 @@ public function testHandle() // These scores shouldn't be deleted for ($i = 0; $i < 10; $i++) { - $score = $this->createScore($beatmapset); - $score->performance()->create(['pp' => rand(0, 1000)]); + $this->createScore($beatmapset); } $this->expectCountChange(fn () => Score::count(), count($scores) * -2, 'removes scores'); - $this->expectCountChange(fn () => ScorePerformance::count(), count($scores) * -1, 'removes score performances'); static::reindexScores(); @@ -71,7 +65,6 @@ public function testHandle() Genre::truncate(); Language::truncate(); Score::select()->delete(); // TODO: revert to truncate after the table is actually renamed - ScorePerformance::select()->delete(); // TODO: revert to truncate after the table is actually renamed User::truncate(); UserGroup::truncate(); UserGroupEvent::truncate(); diff --git a/tests/Models/ContestTest.php b/tests/Models/ContestTest.php index acad06b2096..6fb7ed5e8db 100644 --- a/tests/Models/ContestTest.php +++ b/tests/Models/ContestTest.php @@ -78,7 +78,7 @@ public function testAssertVoteRequirementPlaylistBeatmapsets( MultiplayerScoreLink::factory()->state([ 'playlist_item_id' => $playlistItem, 'user_id' => $userId, - ])->completed([], [ + ])->completed([ 'ended_at' => $endedAt, 'passed' => $passed, ])->create(); diff --git a/tests/Models/Multiplayer/ScoreLinkTest.php b/tests/Models/Multiplayer/ScoreLinkTest.php index cc1e09f7800..efc84c75220 100644 --- a/tests/Models/Multiplayer/ScoreLinkTest.php +++ b/tests/Models/Multiplayer/ScoreLinkTest.php @@ -12,11 +12,26 @@ use App\Models\Multiplayer\PlaylistItem; use App\Models\Multiplayer\ScoreLink; use App\Models\ScoreToken; -use Carbon\Carbon; use Tests\TestCase; class ScoreLinkTest extends TestCase { + private static array $commonScoreParams; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + static::$commonScoreParams = [ + 'accuracy' => 0.5, + 'ended_at' => new \DateTime(), + 'max_combo' => 1, + 'statistics' => [ + 'great' => 1, + ], + 'total_score' => 1, + ]; + } + public function testRequiredModsMissing() { $playlistItem = PlaylistItem::factory()->create([ @@ -32,14 +47,10 @@ public function testRequiredModsMissing() $this->expectException(InvariantException::class); $this->expectExceptionMessage('This play does not include the mods required.'); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), - 'mods' => [], - 'statistics' => [ - 'great' => 1, - ], ]); } @@ -57,14 +68,11 @@ public function testRequiredModsPresent() $this->expectNotToPerformAssertions(); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, + 'mods' => [['acronym' => 'HD']], 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), - 'mods' => [['acronym' => 'HD']], - 'statistics' => [ - 'great' => 1, - ], ]); } @@ -85,17 +93,14 @@ public function testExpectedAllowedMod() $this->expectNotToPerformAssertions(); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, - 'ruleset_id' => $playlistItem->ruleset_id, - 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), 'mods' => [ ['acronym' => 'DT'], ['acronym' => 'HD'], ], - 'statistics' => [ - 'great' => 1, - ], + 'ruleset_id' => $playlistItem->ruleset_id, + 'user_id' => $scoreToken->user_id, ]); } @@ -117,17 +122,14 @@ public function testUnexpectedAllowedMod() $this->expectException(InvariantException::class); $this->expectExceptionMessage('This play includes mods that are not allowed.'); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, - 'ruleset_id' => $playlistItem->ruleset_id, - 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), 'mods' => [ ['acronym' => 'DT'], ['acronym' => 'HD'], ], - 'statistics' => [ - 'great' => 1, - ], + 'ruleset_id' => $playlistItem->ruleset_id, + 'user_id' => $scoreToken->user_id, ]); } @@ -142,14 +144,11 @@ public function testUnexpectedModWhenNoModsAreAllowed() $this->expectException(InvariantException::class); $this->expectExceptionMessage('This play includes mods that are not allowed.'); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, + 'mods' => [['acronym' => 'HD']], 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), - 'mods' => [['acronym' => 'HD']], - 'statistics' => [ - 'great' => 1, - ], ]); } @@ -170,14 +169,11 @@ public function testUnexpectedModAcceptedIfAlwaysValidForSubmission() $this->expectNotToPerformAssertions(); ScoreLink::complete($scoreToken, [ + ...static::$commonScoreParams, 'beatmap_id' => $playlistItem->beatmap_id, + 'mods' => [['acronym' => 'TD']], 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $scoreToken->user_id, - 'ended_at' => json_date(Carbon::now()), - 'mods' => [['acronym' => 'TD']], - 'statistics' => [ - 'great' => 1, - ], ]); } } diff --git a/tests/Models/Multiplayer/UserScoreAggregateTest.php b/tests/Models/Multiplayer/UserScoreAggregateTest.php index 37ded3882f9..6e4bc8f48cf 100644 --- a/tests/Models/Multiplayer/UserScoreAggregateTest.php +++ b/tests/Models/Multiplayer/UserScoreAggregateTest.php @@ -240,8 +240,9 @@ private function addPlay(User $user, PlaylistItem $playlistItem, array $params): [ 'beatmap_id' => $playlistItem->beatmap_id, 'ended_at' => json_time(new \DateTime()), - 'ruleset_id' => $playlistItem->ruleset_id, + 'max_combo' => 1, 'statistics' => ['good' => 1], + 'ruleset_id' => $playlistItem->ruleset_id, 'user_id' => $user->getKey(), ...$params, ], diff --git a/tests/Models/Solo/ScoreEsIndexTest.php b/tests/Models/Solo/ScoreEsIndexTest.php index 2b7fcf6d117..1b53c276887 100644 --- a/tests/Models/Solo/ScoreEsIndexTest.php +++ b/tests/Models/Solo/ScoreEsIndexTest.php @@ -40,7 +40,6 @@ public static function setUpBeforeClass(): void static::$beatmap = Beatmap::factory()->qualified()->create(); $scoreFactory = Score::factory()->state(['preserve' => true]); - $defaultData = ['build_id' => 1]; $mods = [ ['acronym' => 'DT', 'settings' => []], @@ -51,43 +50,44 @@ public static function setUpBeforeClass(): void ]; static::$scores = [ - 'otherUser' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1150, + 'otherUser' => $scoreFactory->withData([ 'mods' => $unrelatedMods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1150, 'user_id' => $otherUser, ]), - 'otherUserMods' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1140, + 'otherUserMods' => $scoreFactory->withData([ 'mods' => $mods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1140, 'user_id' => $otherUser, ]), - 'otherUser2' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1150, + 'otherUser2' => $scoreFactory->withData([ 'mods' => $mods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1150, 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), ]), - 'otherUser3SameCountry' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1130, + 'otherUser3SameCountry' => $scoreFactory->withData([ 'mods' => $unrelatedMods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1130, 'user_id' => User::factory()->state(['country_acronym' => static::$user->country_acronym]), ]), - 'user' => $scoreFactory->withData($defaultData, ['total_score' => 1100])->create([ + 'user' => $scoreFactory->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1100, 'user_id' => static::$user, ]), - 'userMods' => $scoreFactory->withData($defaultData, [ - 'total_score' => 1050, + 'userMods' => $scoreFactory->withData([ 'mods' => $mods, ])->create([ 'beatmap_id' => static::$beatmap, + 'total_score' => 1050, 'user_id' => static::$user, ]), ]; diff --git a/tests/Models/Solo/ScoreTest.php b/tests/Models/Solo/ScoreTest.php index 5f300cc4dfb..550bc08dd04 100644 --- a/tests/Models/Solo/ScoreTest.php +++ b/tests/Models/Solo/ScoreTest.php @@ -50,8 +50,8 @@ public function testLegacyPassScoreRetainsRank() 'user_id' => 1, ]); - $this->assertTrue($score->data->passed); - $this->assertSame($score->data->rank, 'S'); + $this->assertTrue($score->passed); + $this->assertSame($score->rank, 'S'); $legacy = $score->createLegacyEntryOrExplode(); @@ -75,8 +75,8 @@ public function testLegacyFailScoreIsRankF() 'user_id' => 1, ]); - $this->assertFalse($score->data->passed); - $this->assertSame($score->data->rank, 'F'); + $this->assertFalse($score->passed); + $this->assertSame($score->rank, 'F'); $legacy = $score->createLegacyEntryOrExplode(); @@ -132,13 +132,15 @@ public function testLegacyScoreHitCountsFromStudlyCaseStatistics() public function testModsPropertyType() { - $score = new Score(['data' => [ + $score = new Score([ 'beatmap_id' => 0, + 'data' => [ + 'mods' => [['acronym' => 'DT']], + ], 'ended_at' => json_time(now()), - 'mods' => [['acronym' => 'DT']], 'ruleset_id' => 0, 'user_id' => 0, - ]]); + ]); $this->assertTrue($score->data->mods[0] instanceof stdClass, 'mods entry should be of type stdClass'); } @@ -147,8 +149,7 @@ public function testWeightedPp(): void { $pp = 10; $weight = 0.5; - $score = Score::factory()->create(); - $score->performance()->create(['pp' => $pp]); + $score = Score::factory()->create(['pp' => $pp]); $score->weight = $weight; $this->assertSame($score->weightedPp(), $pp * $weight); @@ -156,7 +157,7 @@ public function testWeightedPp(): void public function testWeightedPpWithoutPerformance(): void { - $score = Score::factory()->create(); + $score = Score::factory()->create(['pp' => null]); $score->weight = 0.5; $this->assertNull($score->weightedPp()); @@ -164,8 +165,7 @@ public function testWeightedPpWithoutPerformance(): void public function testWeightedPpWithoutWeight(): void { - $score = Score::factory()->create(); - $score->performance()->create(['pp' => 10]); + $score = Score::factory()->create(['pp' => 10]); $this->assertNull($score->weightedPp()); } From 07aa036211874ee44bb38975552045e664dbcde2 Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 16 Jan 2024 19:28:21 +0900 Subject: [PATCH 144/203] Adjust score date attribute --- app/Transformers/ScoreTransformer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index 45f2df4f46e..31188c8ee56 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -166,7 +166,7 @@ public function transformLegacy(LegacyMatch\Score|ScoreModel|SoloScore $score) $soloScore = $score; $score = $soloScore->makeLegacyEntry(); $score->score_id = $soloScore->getKey(); - $createdAt = $soloScore->created_at_json; + $createdAt = $soloScore->ended_at_json; $type = $soloScore->getMorphClass(); $pp = $soloScore->pp; } else { From 64a6ff3d53af659d686f24b566450ac4e0377b50 Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 16 Jan 2024 19:36:36 +0900 Subject: [PATCH 145/203] More table clean up --- ...4_01_12_115738_update_scores_table_final.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/database/migrations/2024_01_12_115738_update_scores_table_final.php b/database/migrations/2024_01_12_115738_update_scores_table_final.php index 53073651725..8a93c4edf2c 100644 --- a/database/migrations/2024_01_12_115738_update_scores_table_final.php +++ b/database/migrations/2024_01_12_115738_update_scores_table_final.php @@ -19,7 +19,6 @@ private static function resetView(): void public function up(): void { Schema::drop('solo_scores'); - Schema::drop('solo_scores_performance'); DB::statement("CREATE TABLE `solo_scores` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `user_id` int unsigned NOT NULL, @@ -47,6 +46,12 @@ public function up(): void KEY `legacy_score_lookup` (`ruleset_id`,`legacy_score_id`) )"); + DB::statement('DROP VIEW score_legacy_id_map'); + Schema::drop('solo_scores_legacy_id_map'); + + DB::statement('DROP VIEW score_performance'); + Schema::drop('solo_scores_performance'); + static::resetView(); } @@ -68,11 +73,21 @@ public function down(): void KEY `user_ruleset_index` (`user_id`,`ruleset_id`), KEY `beatmap_user_index` (`beatmap_id`,`user_id`) )"); + + DB::statement('CREATE TABLE `solo_scores_legacy_id_map` ( + `ruleset_id` smallint unsigned NOT NULL, + `old_score_id` bigint unsigned NOT NULL, + `score_id` bigint unsigned NOT NULL, + PRIMARY KEY (`ruleset_id`,`old_score_id`) + )'); + DB::statement('CREATE VIEW score_legacy_id_map AS SELECT * FROM solo_scores_legacy_id_map'); + DB::statement('CREATE TABLE `solo_scores_performance` ( `score_id` bigint unsigned NOT NULL, `pp` float DEFAULT NULL, PRIMARY KEY (`score_id`) )'); + DB::statement('CREATE VIEW score_performance AS SELECT * FROM solo_scores_performance'); static::resetView(); } From 9457af856a9ef85299be8b47eb8644be3046e0de Mon Sep 17 00:00:00 2001 From: nanaya Date: Wed, 27 Dec 2023 18:13:44 +0900 Subject: [PATCH 146/203] Add menu to switch score display mode --- resources/css/bem/simple-menu.less | 6 +++++ resources/lang/en/layout.php | 2 ++ resources/views/layout/_popup_user.blade.php | 4 ++++ .../views/layout/_score_mode_toggle.blade.php | 24 +++++++++++++++++++ .../views/layout/header_mobile/user.blade.php | 2 ++ 5 files changed, 38 insertions(+) create mode 100644 resources/views/layout/_score_mode_toggle.blade.php diff --git a/resources/css/bem/simple-menu.less b/resources/css/bem/simple-menu.less index 34ec88f10b3..986ceb81437 100644 --- a/resources/css/bem/simple-menu.less +++ b/resources/css/bem/simple-menu.less @@ -135,6 +135,12 @@ } } + &__extra { + background-color: hsl(var(--hsl-b5)); + padding: @_padding-vertical @_gutter; + margin: -@_padding-vertical -@_gutter @_padding-vertical; + } + &__form { margin: -@_padding-vertical -@_gutter; } diff --git a/resources/lang/en/layout.php b/resources/lang/en/layout.php index 3f642aff3ac..5a59a7c25cc 100644 --- a/resources/lang/en/layout.php +++ b/resources/lang/en/layout.php @@ -195,6 +195,8 @@ 'account-edit' => 'Settings', 'follows' => 'Watchlists', 'friends' => 'Friends', + 'legacy_score_only_toggle' => 'Lazer mode', + 'legacy_score_only_toggle_tooltip' => 'Lazer mode shows scores set from lazer with a new scoring algorithm', 'logout' => 'Sign Out', 'profile' => 'My Profile', ], diff --git a/resources/views/layout/_popup_user.blade.php b/resources/views/layout/_popup_user.blade.php index 1ddb765c8de..cb6b364ab61 100644 --- a/resources/views/layout/_popup_user.blade.php +++ b/resources/views/layout/_popup_user.blade.php @@ -16,6 +16,10 @@ class="simple-menu__header simple-menu__header--link js-current-user-cover"
    {{ Auth::user()->username }}
    +
    + @include('layout._score_mode_toggle', ['class' => 'simple-menu__item']) +
    + . Licensed under the GNU Affero General Public License v3.0. + See the LICENCE file in the repository root for full licence text. +--}} +@php + $legacyScoreOnlyValue = App\Libraries\Search\ScoreSearchParams::showLegacyForUser(Auth::user()); + $icon = $legacyScoreOnlyValue + ? 'far fa-square' + : 'fas fa-check-square'; +@endphp + diff --git a/resources/views/layout/header_mobile/user.blade.php b/resources/views/layout/header_mobile/user.blade.php index 7788db0fda6..d7875bb3c90 100644 --- a/resources/views/layout/header_mobile/user.blade.php +++ b/resources/views/layout/header_mobile/user.blade.php @@ -9,6 +9,8 @@ class="navbar-mobile-item__main js-react--user-card" data-is-current-user="1" >
    + @include('layout._score_mode_toggle', ['class' => 'navbar-mobile-item__main']) + Date: Wed, 17 Jan 2024 19:48:28 +0900 Subject: [PATCH 147/203] Add missing attributes --- app/Transformers/ScoreTransformer.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index 31188c8ee56..8c2c91efacd 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -115,6 +115,8 @@ public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) $ret['ruleset_id'] = Ruleset::tryFromName($score->getMode())->value; $ret['statistics'] = $score->statistics(); $ret['total_score'] = $score->score; + + $ret['legacy_total_score'] = $ret['total_score']; } else { if ($score instanceof MultiplayerScoreLink) { $ret['playlist_item_id'] = $score->playlist_item_id; @@ -124,16 +126,19 @@ public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) } $data = $score->data; + $ret['maximum_statistics'] = $data->maximumStatistics; + $ret['mods'] = $data->mods; + $ret['statistics'] = $data->statistics; + $ret['accuracy'] = $score->accuracy; $ret['build_id'] = $score->build_id; $ret['ended_at'] = $score->ended_at_json; $ret['has_replay'] = $score->has_replay; - $ret['maximum_statistics'] = $data->maximumStatistics; - $ret['mods'] = $data->mods; + $ret['legacy_total_score'] = $score->legacy_total_score; + $ret['max_combo'] = $score->maxcombo; $ret['pp'] = $score->pp; $ret['ruleset_id'] = $score->ruleset_id; $ret['started_at'] = $score->started_at_json; - $ret['statistics'] = $data->statistics; $ret['total_score'] = $score->total_score; } From 1a5b451d13758ce4143c6ef4b6a7fde2f0364c8f Mon Sep 17 00:00:00 2001 From: nanaya Date: Wed, 17 Jan 2024 19:56:08 +0900 Subject: [PATCH 148/203] Fix attribute name --- app/Transformers/ScoreTransformer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index 8c2c91efacd..59020ca4c23 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -135,7 +135,7 @@ public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) $ret['ended_at'] = $score->ended_at_json; $ret['has_replay'] = $score->has_replay; $ret['legacy_total_score'] = $score->legacy_total_score; - $ret['max_combo'] = $score->maxcombo; + $ret['max_combo'] = $score->max_combo; $ret['pp'] = $score->pp; $ret['ruleset_id'] = $score->ruleset_id; $ret['started_at'] = $score->started_at_json; From b45fb921a73e08707db426751829cb7bae9eebfb Mon Sep 17 00:00:00 2001 From: nanaya Date: Wed, 17 Jan 2024 20:04:32 +0900 Subject: [PATCH 149/203] Update docs --- app/Models/Solo/Score.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index dcbf8231951..941eb15bbbc 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -23,15 +23,26 @@ use Storage; /** + * @property float $accuracy * @property int $beatmap_id - * @property \Carbon\Carbon|null $created_at - * @property string|null $created_at_json + * @property int $build_id * @property ScoreData $data + * @property \Carbon\Carbon|null $ended_at + * @property string|null $ended_at_json * @property bool $has_replay * @property int $id + * @property int $legacy_score_id + * @property int $legacy_total_score + * @property int $max_combo + * @property bool $passed + * @property float $pp * @property bool $preserve + * @property string $rank * @property bool $ranked * @property int $ruleset_id + * @property \Carbon\Carbon|null $started_at + * @property string|null $started_at_json + * @property int $total_score * @property int $unix_updated_at * @property User $user * @property int $user_id From 9206986a4bdcbe481589db7c106a02aee60f4120 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 18 Jan 2024 14:43:31 +0900 Subject: [PATCH 150/203] Add more missing attributes --- app/Transformers/ScoreTransformer.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index 59020ca4c23..a852d37a90a 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -116,6 +116,7 @@ public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) $ret['statistics'] = $score->statistics(); $ret['total_score'] = $score->score; + $ret['legacy_score_id'] = $score->getKey(); $ret['legacy_total_score'] = $ret['total_score']; } else { if ($score instanceof MultiplayerScoreLink) { @@ -134,8 +135,10 @@ public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) $ret['build_id'] = $score->build_id; $ret['ended_at'] = $score->ended_at_json; $ret['has_replay'] = $score->has_replay; + $ret['legacy_score_id'] = $score->legacy_score_id; $ret['legacy_total_score'] = $score->legacy_total_score; $ret['max_combo'] = $score->max_combo; + $ret['passed'] = $score->passed; $ret['pp'] = $score->pp; $ret['ruleset_id'] = $score->ruleset_id; $ret['started_at'] = $score->started_at_json; From a86ff1558e292a9c91a25f86bc795d37e76c5e46 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 18 Jan 2024 14:48:03 +0900 Subject: [PATCH 151/203] Update score queue for processing logic It expects database row structure so just give it database row structure. --- .../Rooms/Playlist/ScoresController.php | 6 ++--- .../Controllers/Solo/ScoresController.php | 5 ++-- app/Models/Solo/Score.php | 27 +++++-------------- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php b/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php index a55dc88afac..55f901f220a 100644 --- a/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php +++ b/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php @@ -202,15 +202,13 @@ public function update($roomId, $playlistItemId, $tokenId) }); $score = $scoreLink->score; - $transformer = ScoreTransformer::newSolo(); if ($score->wasRecentlyCreated) { - $scoreJson = json_item($score, $transformer); - $score::queueForProcessing($scoreJson); + $score->queueForProcessing(); } return json_item( $scoreLink, - $transformer, + ScoreTransformer::newSolo(); [ ...ScoreTransformer::MULTIPLAYER_BASE_INCLUDES, 'position', diff --git a/app/Http/Controllers/Solo/ScoresController.php b/app/Http/Controllers/Solo/ScoresController.php index 6800c80051d..f5509401a63 100644 --- a/app/Http/Controllers/Solo/ScoresController.php +++ b/app/Http/Controllers/Solo/ScoresController.php @@ -41,11 +41,10 @@ public function store($beatmapId, $tokenId) return $score; }); - $scoreJson = json_item($score, new ScoreTransformer(ScoreTransformer::TYPE_SOLO)); if ($score->wasRecentlyCreated) { - $score::queueForProcessing($scoreJson); + $score->queueForProcessing(); } - return $scoreJson; + return json_item($score, new ScoreTransformer(ScoreTransformer::TYPE_SOLO)); } } diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index 941eb15bbbc..e520b600ca7 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -122,26 +122,6 @@ public static function extractParams(array $rawParams, ScoreToken|MultiplayerSco return $params; } - /** - * Queue the item for score processing - * - * @param array $scoreJson JSON of the score generated using ScoreTransformer of type Solo - */ - public static function queueForProcessing(array $scoreJson): void - { - LaravelRedis::lpush(static::PROCESSING_QUEUE, json_encode([ - 'Score' => [ - 'beatmap_id' => $scoreJson['beatmap_id'], - 'id' => $scoreJson['id'], - 'ruleset_id' => $scoreJson['ruleset_id'], - 'user_id' => $scoreJson['user_id'], - // TODO: processor is currently order dependent and requires - // this to be located at the end - 'data' => json_encode($scoreJson), - ], - ])); - } - public function beatmap() { return $this->belongsTo(Beatmap::class, 'beatmap_id'); @@ -305,6 +285,13 @@ public function makeLegacyEntry(): LegacyScore\Model return $score; } + public function queueForProcessing(): void + { + LaravelRedis::lpush(static::PROCESSING_QUEUE, json_encode([ + 'Score' => $this->getAttributes(), + ])); + } + public function trashed(): bool { return false; From 5b944f9dff038c3c32f3282da8627500368b072c Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 18 Jan 2024 14:56:57 +0900 Subject: [PATCH 152/203] Fix syntax error --- .../Controllers/Multiplayer/Rooms/Playlist/ScoresController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php b/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php index 55f901f220a..df41471a29b 100644 --- a/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php +++ b/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php @@ -208,7 +208,7 @@ public function update($roomId, $playlistItemId, $tokenId) return json_item( $scoreLink, - ScoreTransformer::newSolo(); + ScoreTransformer::newSolo(), [ ...ScoreTransformer::MULTIPLAYER_BASE_INCLUDES, 'position', From 6e00db0aa3ef7167f2fb2f2ebcad09b743eb9c26 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 18 Jan 2024 17:52:31 +0900 Subject: [PATCH 153/203] Simplify score transformer Add new score attributes to legacy model. --- app/Models/Score/Best/Model.php | 6 +- app/Models/Score/Model.php | 32 +++++++++- app/Models/Solo/Score.php | 2 + app/Transformers/ScoreTransformer.php | 89 +++++++++------------------ 4 files changed, 66 insertions(+), 63 deletions(-) diff --git a/app/Models/Score/Best/Model.php b/app/Models/Score/Best/Model.php index b656d04067b..a8bc4b743d0 100644 --- a/app/Models/Score/Best/Model.php +++ b/app/Models/Score/Best/Model.php @@ -83,14 +83,18 @@ public function getAttribute($key) 'date_json' => $this->getJsonTimeFast($key), 'best' => $this, - 'data' => $this->getData(), 'enabled_mods' => $this->getEnabledModsAttribute($this->getRawAttribute('enabled_mods')), 'pass' => true, + 'best_id' => $this->getKey(), + 'has_replay' => $this->replay, + 'beatmap', 'replayViewCount', 'reportedIn', 'user' => $this->getRelationValue($key), + + default => $this->getNewScoreAttribute($key), }; } diff --git a/app/Models/Score/Model.php b/app/Models/Score/Model.php index 16e636361fc..7f48e9991a8 100644 --- a/app/Models/Score/Model.php +++ b/app/Models/Score/Model.php @@ -10,6 +10,7 @@ use App\Libraries\Mods; use App\Models\Beatmap; use App\Models\Model as BaseModel; +use App\Models\Solo\ScoreData; use App\Models\Traits\Scoreable; use App\Models\User; @@ -146,13 +147,36 @@ public function getAttribute($key) 'date_json' => $this->getJsonTimeFast($key), - 'data' => $this->getData(), 'enabled_mods' => $this->getEnabledModsAttribute($this->getRawAttribute('enabled_mods')), + 'best_id' => $this->getRawAttribute('high_score_id'), + 'has_replay' => $this->best?->replay, + 'pp' => $this->best?->pp, + 'beatmap', 'best', 'replayViewCount', 'user' => $this->getRelationValue($key), + + default => $this->getNewScoreAttribute($key), + }; + } + + public function getNewScoreAttribute(string $key) + { + return match ($key) { + 'accuracy' => $this->accuracy(), + 'build_id' => null, + 'data' => $this->getData(), + 'ended_at_json' => $this->date_json, + 'legacy_perfect' => $this->perfect, + 'legacy_score_id' => $this->getKey(), + 'legacy_total_score' => $this->score, + 'max_combo' => $this->maxcombo, + 'passed' => $this->pass, + 'ruleset_id' => Ruleset::tryFromName($this->getMode())->value, + 'started_at_json' => null, + 'total_score' => $this->score, }; } @@ -161,8 +185,10 @@ public function getMode(): string return snake_case(get_class_basename(static::class)); } - public function statistics(): array + public function getData(): ScoreData { + $mods = array_map(fn ($m) => ['acronym' => $m, 'settings' => []], $this->enabled_mods); + $statistics = [ 'miss' => $this->countmiss, 'great' => $this->count300, @@ -189,6 +215,6 @@ public function statistics(): array break; } - return $statistics; + return new ScoreData(compact('mods', 'statistics')); } } diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index e520b600ca7..a533abe6199 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -179,6 +179,8 @@ public function getAttribute($key) 'ended_at_json', 'started_at_json' => $this->getJsonTimeFast($key), + 'legacy_perfect' => null, + 'beatmap', 'performance', 'reportedIn', diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index a852d37a90a..87b918f9b62 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -7,7 +7,6 @@ namespace App\Transformers; -use App\Enums\Ruleset; use App\Models\Beatmap; use App\Models\DeletedUser; use App\Models\LegacyMatch; @@ -91,68 +90,40 @@ public function transform(LegacyMatch\Score|MultiplayerScoreLink|ScoreModel|Solo public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) { - $ret = [ - 'best_id' => null, - 'build_id' => null, - 'has_replay' => false, - 'legacy_perfect' => null, - 'pp' => null, - ]; - if ($score instanceof ScoreModel) { - $best = $score->best; - if ($best !== null) { - $ret['best_id'] = $best->getKey(); - $ret['has_replay'] = $best->replay; - $ret['pp'] = $best->pp; - } - - $ret['accuracy'] = $score->accuracy(); - $ret['ended_at'] = $score->date_json; - $ret['legacy_perfect'] = $score->perfect; - $ret['max_combo'] = $score->maxcombo; - $ret['mods'] = array_map(fn ($m) => ['acronym' => $m, 'settings' => []], $score->enabled_mods); - $ret['passed'] = $score->pass; - $ret['ruleset_id'] = Ruleset::tryFromName($score->getMode())->value; - $ret['statistics'] = $score->statistics(); - $ret['total_score'] = $score->score; - - $ret['legacy_score_id'] = $score->getKey(); - $ret['legacy_total_score'] = $ret['total_score']; - } else { - if ($score instanceof MultiplayerScoreLink) { - $ret['playlist_item_id'] = $score->playlist_item_id; - $ret['room_id'] = $score->playlistItem->room_id; - $ret['solo_score_id'] = $score->score_id; - $score = $score->score; - } + $extraAttributes = []; - $data = $score->data; - $ret['maximum_statistics'] = $data->maximumStatistics; - $ret['mods'] = $data->mods; - $ret['statistics'] = $data->statistics; - - $ret['accuracy'] = $score->accuracy; - $ret['build_id'] = $score->build_id; - $ret['ended_at'] = $score->ended_at_json; - $ret['has_replay'] = $score->has_replay; - $ret['legacy_score_id'] = $score->legacy_score_id; - $ret['legacy_total_score'] = $score->legacy_total_score; - $ret['max_combo'] = $score->max_combo; - $ret['passed'] = $score->passed; - $ret['pp'] = $score->pp; - $ret['ruleset_id'] = $score->ruleset_id; - $ret['started_at'] = $score->started_at_json; - $ret['total_score'] = $score->total_score; + if ($score instanceof MultiplayerScoreLink) { + $extraAttributes['playlist_item_id'] = $score->playlist_item_id; + $extraAttributes['room_id'] = $score->playlistItem->room_id; + $extraAttributes['solo_score_id'] = $score->score_id; + $score = $score->score; } - $ret['beatmap_id'] = $score->beatmap_id; - $ret['id'] = $score->getKey(); - $ret['rank'] = $score->rank; - $ret['type'] = $score->getMorphClass(); - $ret['user_id'] = $score->user_id; + $hasReplay = $score->has_replay; - // TODO: remove this redundant field sometime after 2024-02 - $ret['replay'] = $ret['has_replay']; + return [ + ...$extraAttributes, + ...$score->data->jsonSerialize(), + 'beatmap_id' => $score->beatmap_id, + 'id' => $score->getKey(), + 'rank' => $score->rank, + 'type' => $score->getMorphClass(), + 'user_id' => $score->user_id, + 'accuracy' => $score->accuracy, + 'build_id' => $score->build_id, + 'ended_at' => $score->ended_at_json, + 'has_replay' => $hasReplay, + 'legacy_score_id' => $score->legacy_score_id, + 'legacy_total_score' => $score->legacy_total_score, + 'max_combo' => $score->max_combo, + 'passed' => $score->passed, + 'pp' => $score->pp, + 'ruleset_id' => $score->ruleset_id, + 'started_at' => $score->started_at_json, + 'total_score' => $score->total_score, + // TODO: remove this redundant field sometime after 2024-02 + 'replay' => $hasReplay, + ]; return $ret; } From 47b5938a9caa221f0baf45668d83adf95a6058ec Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 18 Jan 2024 18:26:46 +0900 Subject: [PATCH 154/203] Add missing legacy attributes --- app/Models/Solo/Score.php | 1 + app/Transformers/ScoreTransformer.php | 2 ++ 2 files changed, 3 insertions(+) diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index a533abe6199..b4884978306 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -179,6 +179,7 @@ public function getAttribute($key) 'ended_at_json', 'started_at_json' => $this->getJsonTimeFast($key), + 'best_id' => null, 'legacy_perfect' => null, 'beatmap', diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index 87b918f9b62..e6ccdd177f9 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -105,6 +105,7 @@ public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) ...$extraAttributes, ...$score->data->jsonSerialize(), 'beatmap_id' => $score->beatmap_id, + 'best_id' => $score->best_id, 'id' => $score->getKey(), 'rank' => $score->rank, 'type' => $score->getMorphClass(), @@ -113,6 +114,7 @@ public function transformSolo(MultiplayerScoreLink|ScoreModel|SoloScore $score) 'build_id' => $score->build_id, 'ended_at' => $score->ended_at_json, 'has_replay' => $hasReplay, + 'legacy_perfect' => $score->legacy_perfect, 'legacy_score_id' => $score->legacy_score_id, 'legacy_total_score' => $score->legacy_total_score, 'max_combo' => $score->max_combo, From 697f943579218a22c076c065aa5e304efcdd0583 Mon Sep 17 00:00:00 2001 From: nanaya Date: Fri, 19 Jan 2024 20:24:11 +0900 Subject: [PATCH 155/203] Legacy total score isn't nullable anymore --- resources/js/interfaces/solo-score-json.ts | 2 +- resources/js/utils/score-helper.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/resources/js/interfaces/solo-score-json.ts b/resources/js/interfaces/solo-score-json.ts index d33708bdaa1..fa83b534ceb 100644 --- a/resources/js/interfaces/solo-score-json.ts +++ b/resources/js/interfaces/solo-score-json.ts @@ -34,7 +34,7 @@ type SoloScoreJsonDefaultAttributes = { has_replay: boolean; id: number; legacy_score_id: number | null; - legacy_total_score: number | null; + legacy_total_score: number; max_combo: number; mods: ScoreModJson[]; passed: boolean; diff --git a/resources/js/utils/score-helper.ts b/resources/js/utils/score-helper.ts index d258d63722d..9f3bf6d3fd4 100644 --- a/resources/js/utils/score-helper.ts +++ b/resources/js/utils/score-helper.ts @@ -123,5 +123,7 @@ export function scoreUrl(score: SoloScoreJson) { } export function totalScore(score: SoloScoreJson) { - return score.legacy_total_score ?? score.total_score; + return score.legacy_score_id == null + ? score.total_score + : score.legacy_total_score; } From 2b9b7015242f9f012005fd05a17db337ef61e223 Mon Sep 17 00:00:00 2001 From: nanaya Date: Fri, 26 Jan 2024 05:43:12 +0900 Subject: [PATCH 156/203] Updated --- docker-compose.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 297936f5e18..40ce3c0bcc1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -145,8 +145,7 @@ services: - "${NGINX_PORT:-8080}:80" score-indexer: - # TODO: switch to main image once updated with support for new score structure - image: nanaya/osu-elastic-indexer:v44 + image: pppy/osu-elastic-indexer:master command: ["queue", "watch"] depends_on: redis: @@ -160,8 +159,7 @@ services: SCHEMA: "${SCHEMA:-1}" score-indexer-test: - # TODO: switch to main image once updated with support for new score structure - image: nanaya/osu-elastic-indexer:v44 + image: pppy/osu-elastic-indexer:master command: ["queue", "watch"] depends_on: redis: From f27cd8a53c5801f18724facff86a4183518300eb Mon Sep 17 00:00:00 2001 From: nanaya Date: Wed, 24 Jan 2024 16:06:11 +0900 Subject: [PATCH 157/203] Updated (again, as the previous one was overwritten by another commit) --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c2e1ddab56a..6e7fa320e9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -154,7 +154,7 @@ services: - "${NGINX_PORT:-8080}:80" score-indexer: - image: pppy/osu-elastic-indexer:99cd549c5c5c959ff6b2728b76af603dda4c85cb + image: pppy/osu-elastic-indexer:master command: ["queue", "watch"] depends_on: redis: @@ -168,7 +168,7 @@ services: SCHEMA: "${SCHEMA:-1}" score-indexer-test: - image: pppy/osu-elastic-indexer:99cd549c5c5c959ff6b2728b76af603dda4c85cb + image: pppy/osu-elastic-indexer:master command: ["queue", "watch"] depends_on: redis: From e2794198a7e9d3faa55c9ed89aafecde0785f25a Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 28 Dec 2023 19:12:24 +0900 Subject: [PATCH 158/203] Add client hash check --- .../ClientCheckParseTokenException.php | 12 +++ .../Rooms/Playlist/ScoresController.php | 9 +- .../Controllers/ScoreTokensController.php | 8 +- .../Controllers/Solo/ScoresController.php | 8 +- app/Http/Controllers/UsersController.php | 9 +- app/Libraries/ClientCheck.php | 102 ++++++++++++++---- app/Models/Build.php | 10 ++ config/osu.php | 10 ++ database/factories/BuildFactory.php | 2 +- phpunit.xml | 1 + .../Rooms/Playlist/ScoresControllerTest.php | 11 +- .../Controllers/ScoreTokensControllerTest.php | 30 ++++-- tests/Libraries/ClientCheckTest.php | 77 ++++--------- tests/TestCase.php | 9 ++ 14 files changed, 193 insertions(+), 105 deletions(-) create mode 100644 app/Exceptions/ClientCheckParseTokenException.php diff --git a/app/Exceptions/ClientCheckParseTokenException.php b/app/Exceptions/ClientCheckParseTokenException.php new file mode 100644 index 00000000000..f5e6f800572 --- /dev/null +++ b/app/Exceptions/ClientCheckParseTokenException.php @@ -0,0 +1,12 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Exceptions; + +class ClientCheckParseTokenException extends \Exception +{ +} diff --git a/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php b/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php index df41471a29b..cfb8c9d067f 100644 --- a/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php +++ b/app/Http/Controllers/Multiplayer/Rooms/Playlist/ScoresController.php @@ -166,10 +166,10 @@ public function store($roomId, $playlistId) $room = Room::findOrFail($roomId); $playlistItem = $room->playlist()->where('id', $playlistId)->firstOrFail(); $user = auth()->user(); - $params = request()->all(); + $request = \Request::instance(); + $params = $request->all(); - $buildId = ClientCheck::findBuild($user, $params)?->getKey() - ?? $GLOBALS['cfg']['osu']['client']['default_build_id']; + $buildId = ClientCheck::parseToken($request)['buildId']; $scoreToken = $room->startPlay($user, $playlistItem, $buildId); @@ -181,6 +181,8 @@ public function store($roomId, $playlistId) */ public function update($roomId, $playlistItemId, $tokenId) { + $request = \Request::instance(); + $clientTokenData = ClientCheck::parseToken($request); $scoreLink = \DB::transaction(function () use ($roomId, $playlistItemId, $tokenId) { $room = Room::findOrFail($roomId); @@ -203,6 +205,7 @@ public function update($roomId, $playlistItemId, $tokenId) $score = $scoreLink->score; if ($score->wasRecentlyCreated) { + ClientCheck::queueToken($clientTokenData, $score->getKey()); $score->queueForProcessing(); } diff --git a/app/Http/Controllers/ScoreTokensController.php b/app/Http/Controllers/ScoreTokensController.php index b746f9f113b..946996736d0 100644 --- a/app/Http/Controllers/ScoreTokensController.php +++ b/app/Http/Controllers/ScoreTokensController.php @@ -24,8 +24,8 @@ public function store($beatmapId) { $beatmap = Beatmap::increasesStatistics()->findOrFail($beatmapId); $user = auth()->user(); - $rawParams = request()->all(); - $params = get_params($rawParams, null, [ + $request = \Request::instance(); + $params = get_params($request->all(), null, [ 'beatmap_hash', 'ruleset_id:int', ]); @@ -43,12 +43,12 @@ public function store($beatmapId) } } - $build = ClientCheck::findBuild($user, $rawParams); + $buildId = ClientCheck::parseToken($request)['buildId']; try { $scoreToken = ScoreToken::create([ 'beatmap_id' => $beatmap->getKey(), - 'build_id' => $build?->getKey() ?? $GLOBALS['cfg']['osu']['client']['default_build_id'], + 'build_id' => $buildId, 'ruleset_id' => $params['ruleset_id'], 'user_id' => $user->getKey(), ]); diff --git a/app/Http/Controllers/Solo/ScoresController.php b/app/Http/Controllers/Solo/ScoresController.php index f5509401a63..046c11ec08b 100644 --- a/app/Http/Controllers/Solo/ScoresController.php +++ b/app/Http/Controllers/Solo/ScoresController.php @@ -6,6 +6,7 @@ namespace App\Http\Controllers\Solo; use App\Http\Controllers\Controller as BaseController; +use App\Libraries\ClientCheck; use App\Models\ScoreToken; use App\Models\Solo\Score; use App\Transformers\ScoreTransformer; @@ -20,7 +21,9 @@ public function __construct() public function store($beatmapId, $tokenId) { - $score = DB::transaction(function () use ($beatmapId, $tokenId) { + $request = \Request::instance(); + $clientTokenData = ClientCheck::parseToken($request); + $score = DB::transaction(function () use ($beatmapId, $request, $tokenId) { $user = auth()->user(); $scoreToken = ScoreToken::where([ 'beatmap_id' => $beatmapId, @@ -29,7 +32,7 @@ public function store($beatmapId, $tokenId) // return existing score otherwise (assuming duplicated submission) if ($scoreToken->score_id === null) { - $params = Score::extractParams(\Request::all(), $scoreToken); + $params = Score::extractParams($request->all(), $scoreToken); $score = Score::createFromJsonOrExplode($params); $score->createLegacyEntryOrExplode(); $scoreToken->fill(['score_id' => $score->getKey()])->saveOrExplode(); @@ -42,6 +45,7 @@ public function store($beatmapId, $tokenId) }); if ($score->wasRecentlyCreated) { + ClientCheck::queueToken($clientTokenData, $score->getKey()); $score->queueForProcessing(); } diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index b92a8bd65e1..47f56b6d019 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -9,6 +9,7 @@ use App\Exceptions\UserProfilePageLookupException; use App\Exceptions\ValidationException; use App\Http\Middleware\RequestCost; +use App\Libraries\ClientCheck; use App\Libraries\RateLimiter; use App\Libraries\Search\ForumSearch; use App\Libraries\Search\ForumSearchRequestParams; @@ -217,11 +218,15 @@ public function store() ], 403); } - if (!starts_with(Request::header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) { + $request = \Request::instance(); + + if (!starts_with($request->header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) { return error_popup(osu_trans('users.store.from_client'), 403); } - return $this->storeUser(request()->all()); + ClientCheck::parseToken($request); + + return $this->storeUser($request->all()); } public function storeWeb() diff --git a/app/Libraries/ClientCheck.php b/app/Libraries/ClientCheck.php index f195067fd4c..914fc87afe8 100644 --- a/app/Libraries/ClientCheck.php +++ b/app/Libraries/ClientCheck.php @@ -3,39 +3,101 @@ // 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. +declare(strict_types=1); + namespace App\Libraries; +use App\Exceptions\ClientCheckParseTokenException; use App\Models\Build; +use Illuminate\Http\Request; class ClientCheck { - public static function findBuild($user, $params): ?Build + public static function parseToken(Request $request): array { - $assertValid = $GLOBALS['cfg']['osu']['client']['check_version'] && $user->findUserGroup(app('groups')->byIdentifier('admin'), true) === null; - - $clientHash = presence(get_string($params['version_hash'] ?? null)); - if ($clientHash === null) { - if ($assertValid) { - abort(422, 'missing client version'); - } else { - return null; + $token = $request->header('x-token'); + $assertValid = $GLOBALS['cfg']['osu']['client']['check_version']; + $ret = [ + 'buildId' => $GLOBALS['cfg']['osu']['client']['default_build_id'], + 'token' => null, + ]; + + try { + if ($token === null) { + throw new ClientCheckParseTokenException('missing token header'); + } + + $input = static::splitToken($token); + + $build = Build::firstWhere([ + 'hash' => $input['clientHash'], + 'allow_ranking' => true, + ]); + + if ($build === null) { + throw new ClientCheckParseTokenException('invalid client hash'); + } + + $ret['buildId'] = $build->getKey(); + + $computed = hash_hmac( + 'sha1', + $input['clientData'], + static::getKey($build), + true, + ); + + if (!hash_equals($computed, $input['expected'])) { + throw new ClientCheckParseTokenException('invalid verification hash'); } - } - // temporary measure to allow android builds to submit without access to the underlying dll to hash - if (strlen($clientHash) !== 32) { - $clientHash = md5($clientHash); + $now = time(); + static $maxTime = 15 * 60; + if (abs($now - $input['clientTime']) > $maxTime) { + throw new ClientCheckParseTokenException('expired token'); + } + + $ret['token'] = $token; + } catch (ClientCheckParseTokenException $e) { + abort_if($assertValid, 422, $e->getMessage()); } - $build = Build::firstWhere([ - 'hash' => hex2bin($clientHash), - 'allow_ranking' => true, - ]); + return $ret; + } - if ($build === null && $assertValid) { - abort(422, 'invalid client hash'); + public static function queueToken(?array $tokenData, int $scoreId): void + { + if ($tokenData['token'] === null) { + return; } - return $build; + \LaravelRedis::lpush($GLOBALS['cfg']['osu']['client']['token_queue'], json_encode([ + 'id' => $scoreId, + 'token' => $tokenData['token'], + ])); + } + + private static function getKey(Build $build): string + { + return $GLOBALS['cfg']['osu']['client']['token_keys'][$build->platform()] + ?? $GLOBALS['cfg']['osu']['client']['token_keys']['default'] + ?? ''; + } + + private static function splitToken(string $token): array + { + $data = substr($token, -82); + $clientTimeHex = substr($data, 32, 8); + $clientTime = strlen($clientTimeHex) === 8 + ? unpack('V', hex2bin($clientTimeHex))[1] + : 0; + + return [ + 'clientData' => substr($data, 0, 40), + 'clientHash' => hex2bin(substr($data, 0, 32)), + 'clientTime' => $clientTime, + 'expected' => hex2bin(substr($data, 40, 40)), + 'version' => substr($data, 80, 2), + ]; } } diff --git a/app/Models/Build.php b/app/Models/Build.php index 6b02a3fdbb5..ee20be84fb5 100644 --- a/app/Models/Build.php +++ b/app/Models/Build.php @@ -198,6 +198,16 @@ public function notificationCover() // no image } + public function platform(): string + { + $version = $this->version; + $suffixPos = strpos($version, '-'); + + return $suffixPos === false + ? '' + : substr($version, $suffixPos + 1); + } + public function url() { return build_url($this); diff --git a/config/osu.php b/config/osu.php index 33af7ae8d4a..e4b35c8a942 100644 --- a/config/osu.php +++ b/config/osu.php @@ -5,6 +5,14 @@ $profileScoresNotice = markdown_plain($profileScoresNotice); } +$clientTokenKeys = []; +foreach (explode(',', env('CLIENT_TOKEN_KEYS') ?? '') as $entry) { + if ($entry !== '') { + [$platform, $encodedKey] = explode('=', $entry, 2); + $clientTokenKeys[$platform] = hex2bin($encodedKey); + } +} + // osu config~ return [ 'achievement' => [ @@ -93,6 +101,8 @@ 'client' => [ 'check_version' => get_bool(env('CLIENT_CHECK_VERSION')) ?? true, 'default_build_id' => get_int(env('DEFAULT_BUILD_ID')) ?? 0, + 'token_keys' => $clientTokenKeys, + 'token_queue' => env('CLIENT_TOKEN_QUEUE') ?? 'token-queue', 'user_agent' => env('CLIENT_USER_AGENT', 'osu!'), ], 'elasticsearch' => [ diff --git a/database/factories/BuildFactory.php b/database/factories/BuildFactory.php index 2bf74f338c1..fb5559fed59 100644 --- a/database/factories/BuildFactory.php +++ b/database/factories/BuildFactory.php @@ -16,7 +16,7 @@ public function definition(): array { return [ 'date' => fn () => $this->faker->dateTimeBetween('-5 years'), - 'hash' => fn () => md5($this->faker->word(), true), + 'hash' => fn () => md5(rand(), true), 'stream_id' => fn () => array_rand_val($GLOBALS['cfg']['osu']['changelog']['update_streams']), 'users' => rand(100, 10000), diff --git a/phpunit.xml b/phpunit.xml index 02c1458af45..2464da35285 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,6 +21,7 @@ + diff --git a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php index d42526b930c..657a9e97735 100644 --- a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php +++ b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php @@ -103,15 +103,18 @@ public function testShow() */ public function testStore($allowRanking, $hashParam, $status) { + $origClientCheckVersion = $GLOBALS['cfg']['osu']['client']['check_version']; + config_set('osu.client.check_version', true); $user = User::factory()->create(); $playlistItem = PlaylistItem::factory()->create(); $build = Build::factory()->create(['allow_ranking' => $allowRanking]); $this->actAsScopedUser($user, ['*']); - $params = []; if ($hashParam !== null) { - $params['version_hash'] = $hashParam ? bin2hex($build->hash) : md5('invalid_'); + $this->withHeaders([ + 'x-token' => $hashParam ? static::createClientToken($build) : strtoupper(md5('invalid_')), + ]); } $countDiff = ((string) $status)[0] === '2' ? 1 : 0; @@ -120,7 +123,9 @@ public function testStore($allowRanking, $hashParam, $status) $this->json('POST', route('api.rooms.playlist.scores.store', [ 'room' => $playlistItem->room_id, 'playlist' => $playlistItem->getKey(), - ]), $params)->assertStatus($status); + ]))->assertStatus($status); + + config_set('osu.client.check_version', $origClientCheckVersion); } /** diff --git a/tests/Controllers/ScoreTokensControllerTest.php b/tests/Controllers/ScoreTokensControllerTest.php index 93910a7c6ca..f52a960e938 100644 --- a/tests/Controllers/ScoreTokensControllerTest.php +++ b/tests/Controllers/ScoreTokensControllerTest.php @@ -29,10 +29,8 @@ public function testStore(string $beatmapState, int $status): void 'beatmap' => $beatmap->getKey(), 'ruleset_id' => $beatmap->playmode, ]; - $bodyParams = [ - 'beatmap_hash' => $beatmap->checksum, - 'version_hash' => bin2hex($this->build->hash), - ]; + $bodyParams = ['beatmap_hash' => $beatmap->checksum]; + $this->withHeaders(['x-token' => static::createClientToken($this->build)]); $this->expectCountChange(fn () => ScoreToken::count(), $status >= 200 && $status < 300 ? 1 : 0); @@ -49,6 +47,8 @@ public function testStore(string $beatmapState, int $status): void */ public function testStoreInvalidParameter(string $paramKey, ?string $paramValue, int $status): void { + $origClientCheckVersion = $GLOBALS['cfg']['osu']['client']['check_version']; + config_set('osu.client.check_version', true); $beatmap = Beatmap::factory()->ranked()->create(); $this->actAsScopedUser($this->user, ['*']); @@ -56,10 +56,17 @@ public function testStoreInvalidParameter(string $paramKey, ?string $paramValue, $params = [ 'beatmap' => $beatmap->getKey(), 'ruleset_id' => $beatmap->playmode, - 'version_hash' => bin2hex($this->build->hash), 'beatmap_hash' => $beatmap->checksum, ]; - $params[$paramKey] = $paramValue; + $this->withHeaders([ + 'x-token' => $paramKey === 'client_token' + ? $paramValue + : static::createClientToken($this->build), + ]); + + if ($paramKey !== 'client_token') { + $params[$paramKey] = $paramValue; + } $routeParams = [ 'beatmap' => $params['beatmap'], @@ -67,16 +74,15 @@ public function testStoreInvalidParameter(string $paramKey, ?string $paramValue, ]; $bodyParams = [ 'beatmap_hash' => $params['beatmap_hash'], - 'version_hash' => $params['version_hash'], ]; $this->expectCountChange(fn () => ScoreToken::count(), 0); $errorMessage = $paramValue === null ? 'missing' : 'invalid'; $errorMessage .= ' '; - $errorMessage .= $paramKey === 'version_hash' + $errorMessage .= $paramKey === 'client_token' ? ($paramValue === null - ? 'client version' + ? 'token header' : 'client hash' ) : $paramKey; @@ -88,6 +94,8 @@ public function testStoreInvalidParameter(string $paramKey, ?string $paramValue, ->assertJson([ 'error' => $errorMessage, ]); + + config_set('osu.client.check_version', $origClientCheckVersion); } public static function dataProviderForTestStore(): array @@ -104,8 +112,8 @@ public static function dataProviderForTestStore(): array public static function dataProviderForTestStoreInvalidParameter(): array { return [ - 'invalid build hash' => ['version_hash', md5('invalid_'), 422], - 'missing build hash' => ['version_hash', null, 422], + 'invalid client token' => ['client_token', md5('invalid_'), 422], + 'missing client token' => ['client_token', null, 422], 'invalid ruleset id' => ['ruleset_id', '5', 422], 'missing ruleset id' => ['ruleset_id', null, 422], diff --git a/tests/Libraries/ClientCheckTest.php b/tests/Libraries/ClientCheckTest.php index 3ffede7b3b1..40a23107f8c 100644 --- a/tests/Libraries/ClientCheckTest.php +++ b/tests/Libraries/ClientCheckTest.php @@ -7,84 +7,43 @@ use App\Libraries\ClientCheck; use App\Models\Build; -use App\Models\User; use Tests\TestCase; class ClientCheckTest extends TestCase { - public function testFindBuild() + public function testParseToken(): void { - $user = User::factory()->withGroup('default')->create(); $build = Build::factory()->create(['allow_ranking' => true]); + $request = \Request::instance(); + $request->headers->set('x-token', static::createClientToken($build)); - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => bin2hex($build->hash)]); + $parsed = ClientCheck::parseToken($request); - $this->assertSame($build->getKey(), $foundBuild->getKey()); + $this->assertSame($build->getKey(), $parsed['buildId']); + $this->assertNotNull($parsed['token']); } - public function testFindBuildAsAdmin() + public function testParseTokenExpired() { - $user = User::factory()->withGroup('admin')->create(); $build = Build::factory()->create(['allow_ranking' => true]); + $request = \Request::instance(); + $request->headers->set('x-token', static::createClientToken($build, 0)); - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => bin2hex($build->hash)]); + $parsed = ClientCheck::parseToken($request); - $this->assertSame($build->getKey(), $foundBuild->getKey()); + $this->assertSame($build->getKey(), $parsed['buildId']); + $this->assertNull($parsed['token']); } - public function testFindBuildDisallowedRanking() + public function testParseTokenNonRankedBuild(): void { - $user = User::factory()->withGroup('default')->create(); $build = Build::factory()->create(['allow_ranking' => false]); + $request = \Request::instance(); + $request->headers->set('x-token', static::createClientToken($build)); - $this->expectExceptionMessage('invalid client hash'); - ClientCheck::findBuild($user, ['version_hash' => bin2hex($build->hash)]); - } - - public function testFindBuildMissingParam() - { - $user = User::factory()->withGroup('default')->create(); - - $this->expectExceptionMessage('missing client version'); - ClientCheck::findBuild($user, []); - } - - public function testFindBuildNonexistent() - { - $user = User::factory()->withGroup('default')->create(); - - $this->expectExceptionMessage('invalid client hash'); - ClientCheck::findBuild($user, ['version_hash' => 'stuff']); - } - - public function testFindBuildNonexistentAsAdmin() - { - $user = User::factory()->withGroup('admin')->create(); - - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => 'stuff']); - - $this->assertNull($foundBuild); - } - - public function testFindBuildNonexistentWithDisabledAssertion() - { - config_set('osu.client.check_version', false); - - $user = User::factory()->withGroup('default')->create(); - - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => 'stuff']); - - $this->assertNull($foundBuild); - } - - public function testFindBuildStringHash() - { - $user = User::factory()->withGroup('default')->create(); - $hashString = 'hello'; - $build = Build::factory()->create(['allow_ranking' => true, 'hash' => md5($hashString, true)]); - - $foundBuild = ClientCheck::findBuild($user, ['version_hash' => $hashString]); + $parsed = ClientCheck::parseToken($request); - $this->assertSame($build->getKey(), $foundBuild->getKey()); + $this->assertSame($GLOBALS['cfg']['osu']['client']['default_build_id'], $parsed['buildId']); + $this->assertNull($parsed['token']); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 7fe0f8462e2..ce5e00cd300 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -11,6 +11,7 @@ use App\Libraries\Search\ScoreSearch; use App\Libraries\Session\Store as SessionStore; use App\Models\Beatmapset; +use App\Models\Build; use App\Models\OAuth\Client; use App\Models\User; use Artisan; @@ -40,6 +41,14 @@ public static function withDbAccess(callable $callback): void static::resetAppDb($db); } + protected static function createClientToken(Build $build, ?int $clientTime = null): string + { + $data = strtoupper(bin2hex($build->hash).bin2hex(pack('V', $clientTime ?? time()))); + $expected = hash_hmac('sha1', $data, ''); + + return strtoupper(bin2hex(random_bytes(40)).$data.$expected.'00'); + } + protected static function fileList($path, $suffix) { return array_map( From 9a1b80676e8863ff2ebee7b684295fe33b9eeaa7 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 25 Jan 2024 10:03:47 +0900 Subject: [PATCH 159/203] Test token queue --- .../Multiplayer/Rooms/Playlist/ScoresControllerTest.php | 7 +++++++ tests/Controllers/Solo/ScoresControllerTest.php | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php index 657a9e97735..62f8de1ed9a 100644 --- a/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php +++ b/tests/Controllers/Multiplayer/Rooms/Playlist/ScoresControllerTest.php @@ -139,6 +139,13 @@ public function testUpdate($bodyParams, $status) $build = Build::factory()->create(['allow_ranking' => true]); $scoreToken = $room->startPlay($user, $playlistItem, 0); + $this->withHeaders(['x-token' => static::createClientToken($build)]); + + $this->expectCountChange( + fn () => \LaravelRedis::llen($GLOBALS['cfg']['osu']['client']['token_queue']), + $status === 200 ? 1 : 0, + ); + $this->actAsScopedUser($user, ['*']); $url = route('api.rooms.playlist.scores.update', [ diff --git a/tests/Controllers/Solo/ScoresControllerTest.php b/tests/Controllers/Solo/ScoresControllerTest.php index a5dc22bebd1..93e91976b13 100644 --- a/tests/Controllers/Solo/ScoresControllerTest.php +++ b/tests/Controllers/Solo/ScoresControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\Controllers\Solo; +use App\Models\Build; use App\Models\Score as LegacyScore; use App\Models\ScoreToken; use App\Models\Solo\Score; @@ -16,13 +17,19 @@ class ScoresControllerTest extends TestCase { public function testStore() { - $scoreToken = ScoreToken::factory()->create(); + $build = Build::factory()->create(['allow_ranking' => true]); + $scoreToken = ScoreToken::factory()->create(['build_id' => $build]); $legacyScoreClass = LegacyScore\Model::getClassByRulesetId($scoreToken->beatmap->playmode); $this->expectCountChange(fn () => Score::count(), 1); $this->expectCountChange(fn () => $legacyScoreClass::count(), 1); $this->expectCountChange(fn () => $this->processingQueueCount(), 1); + $this->expectCountChange( + fn () => \LaravelRedis::llen($GLOBALS['cfg']['osu']['client']['token_queue']), + 1, + ); + $this->withHeaders(['x-token' => static::createClientToken($build)]); $this->actAsScopedUser($scoreToken->user, ['*']); $this->json( From 758332d88ef70816f44007ab2689c50131bd24bf Mon Sep 17 00:00:00 2001 From: nanaya Date: Wed, 21 Jun 2023 17:13:14 +0900 Subject: [PATCH 160/203] Use solo score index for user beatmap scores --- app/Http/Controllers/BeatmapsController.php | 39 ++++++++++++----- routes/web.php | 4 +- .../BeatmapsControllerSoloScoresTest.php | 43 +++++++++++++++++++ 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/app/Http/Controllers/BeatmapsController.php b/app/Http/Controllers/BeatmapsController.php index 097852301e7..9043b2583af 100644 --- a/app/Http/Controllers/BeatmapsController.php +++ b/app/Http/Controllers/BeatmapsController.php @@ -10,6 +10,9 @@ use App\Jobs\Notifications\BeatmapOwnerChange; use App\Libraries\BeatmapDifficultyAttributes; use App\Libraries\Score\BeatmapScores; +use App\Libraries\Score\UserRank; +use App\Libraries\Search\ScoreSearch; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; use App\Models\BeatmapsetEvent; use App\Models\Score\Best\Model as BestModel; @@ -481,13 +484,25 @@ public function userScore($beatmapId, $userId) $mode = presence($params['mode'] ?? null, $beatmap->mode); $mods = array_values(array_filter($params['mods'] ?? [])); - $score = static::baseScoreQuery($beatmap, $mode, $mods) - ->visibleUsers() - ->where('user_id', $userId) - ->firstOrFail(); + $baseParams = ScoreSearchParams::fromArray([ + 'beatmap_ids' => [$beatmap->getKey()], + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), + 'limit' => 1, + 'mods' => $mods, + 'ruleset_id' => Beatmap::MODES[$mode], + 'sort' => 'score_desc', + 'user_id' => (int) $userId, + ]); + $score = (new ScoreSearch($baseParams))->records()->first(); + abort_if($score === null, 404); + + $rankParams = clone $baseParams; + $rankParams->beforeScore = $score; + $rankParams->userId = null; + $rank = UserRank::getRank($rankParams); return [ - 'position' => $score->userRank(compact('mods')), + 'position' => $rank, 'score' => json_item( $score, new ScoreTransformer(), @@ -518,12 +533,14 @@ public function userScoreAll($beatmapId, $userId) { $beatmap = Beatmap::scoreable()->findOrFail($beatmapId); $mode = presence(get_string(request('mode'))) ?? $beatmap->mode; - $scores = BestModel::getClass($mode) - ::default() - ->where([ - 'beatmap_id' => $beatmap->getKey(), - 'user_id' => $userId, - ])->get(); + $params = ScoreSearchParams::fromArray([ + 'beatmap_ids' => [$beatmap->getKey()], + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), + 'ruleset_id' => Beatmap::MODES[$mode], + 'sort' => 'score_desc', + 'user_id' => (int) $userId, + ]); + $scores = (new ScoreSearch($params))->records(); return [ 'scores' => json_collection($scores, new ScoreTransformer()), diff --git a/routes/web.php b/routes/web.php index cdf21216f54..a4a7fefadd4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -409,8 +409,8 @@ Route::apiResource('packs', 'BeatmapPacksController', ['only' => ['index', 'show']]); Route::group(['prefix' => '{beatmap}'], function () { - Route::get('scores/users/{user}', 'BeatmapsController@userScore'); - Route::get('scores/users/{user}/all', 'BeatmapsController@userScoreAll'); + Route::get('scores/users/{user}', 'BeatmapsController@userScore')->name('user.score'); + Route::get('scores/users/{user}/all', 'BeatmapsController@userScoreAll')->name('user.scores'); Route::get('scores', 'BeatmapsController@scores')->name('scores'); Route::get('solo-scores', 'BeatmapsController@soloScores')->name('solo-scores'); diff --git a/tests/Controllers/BeatmapsControllerSoloScoresTest.php b/tests/Controllers/BeatmapsControllerSoloScoresTest.php index c632fe41952..5e5824dbded 100644 --- a/tests/Controllers/BeatmapsControllerSoloScoresTest.php +++ b/tests/Controllers/BeatmapsControllerSoloScoresTest.php @@ -14,6 +14,7 @@ use App\Models\Genre; use App\Models\Group; use App\Models\Language; +use App\Models\OAuth; use App\Models\Solo\Score as SoloScore; use App\Models\User; use App\Models\UserGroup; @@ -172,6 +173,8 @@ public static function tearDownAfterClass(): void Country::truncate(); Genre::truncate(); Language::truncate(); + OAuth\Client::truncate(); + OAuth\Token::truncate(); SoloScore::select()->delete(); // TODO: revert to truncate after the table is actually renamed User::truncate(); UserGroup::truncate(); @@ -209,6 +212,46 @@ public function testQuery(array $scoreKeys, array $params) } } + /** + * @group RequiresScoreIndexer + */ + public function testUserScore() + { + $url = route('api.beatmaps.user.score', [ + 'beatmap' => static::$beatmap->getKey(), + 'mods' => ['DT', 'HD'], + 'user' => static::$user->getKey(), + ]); + $this->actAsScopedUser(static::$user); + $this + ->json('GET', $url) + ->assertJsonPath('score.id', static::$scores['legacy:userMods']->getKey()); + } + + /** + * @group RequiresScoreIndexer + */ + public function testUserScoreAll() + { + $url = route('api.beatmaps.user.scores', [ + 'beatmap' => static::$beatmap->getKey(), + 'user' => static::$user->getKey(), + ]); + $this->actAsScopedUser(static::$user); + $this + ->json('GET', $url) + ->assertJsonCount(4, 'scores') + ->assertJsonPath( + 'scores.*.id', + array_map(fn (string $key): int => static::$scores[$key]->getKey(), [ + 'legacy:user', + 'legacy:userMods', + 'legacy:userModsNC', + 'legacy:userModsLowerScore', + ]) + ); + } + public static function dataProviderForTestQuery(): array { return [ From 90133cdc2905f420feefb191c3bdaa0111bc6b1e Mon Sep 17 00:00:00 2001 From: nanaya Date: Fri, 28 Oct 2022 21:56:47 +0900 Subject: [PATCH 161/203] Use solo score for profile page (except recent plays) --- app/Http/Controllers/UsersController.php | 15 ++++- app/Models/Solo/Score.php | 5 ++ app/Models/Solo/ScoreLegacyIdMap.php | 34 ++++++++++ app/Models/Traits/UserScoreable.php | 80 +++++++++--------------- 4 files changed, 79 insertions(+), 55 deletions(-) create mode 100644 app/Models/Solo/ScoreLegacyIdMap.php diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index b92a8bd65e1..b7268ac858c 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -19,6 +19,8 @@ use App\Models\Country; use App\Models\IpBan; use App\Models\Log; +use App\Models\Solo\Score as SoloScore; +use App\Models\Solo\ScoreLegacyIdMap; use App\Models\User; use App\Models\UserAccountHistory; use App\Models\UserNotFound; @@ -793,9 +795,16 @@ private function getExtra($page, array $options, int $perPage = 10, int $offset case 'scoresFirsts': $transformer = new ScoreTransformer(); $includes = ScoreTransformer::USER_PROFILE_INCLUDES; - $query = $this->user->scoresFirst($this->mode, true) - ->visibleUsers() - ->reorderBy('score_id', 'desc') + $scoreQuery = $this->user->scoresFirst($this->mode, true)->unorder(); + $userFirstsQuery = $scoreQuery->select($scoreQuery->qualifyColumn('score_id')); + $soloMappingQuery = ScoreLegacyIdMap + ::where('ruleset_id', Beatmap::MODES[$this->mode]) + ->whereIn('old_score_id', $userFirstsQuery) + ->select('score_id'); + $query = SoloScore + ::whereIn('id', $soloMappingQuery) + ->default() + ->reorderBy('id', 'desc') ->with(ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); $userRelationColumn = 'user'; break; diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index b4884978306..aac957a92d2 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -147,6 +147,11 @@ public function scopeIndexable(Builder $query): Builder ->whereHas('user', fn (Builder $q): Builder => $q->default()); } + public function scopeDefault(Builder $query): Builder + { + return $query->whereHas('beatmap'); + } + public function getAttribute($key) { return match ($key) { diff --git a/app/Models/Solo/ScoreLegacyIdMap.php b/app/Models/Solo/ScoreLegacyIdMap.php new file mode 100644 index 00000000000..156de063d15 --- /dev/null +++ b/app/Models/Solo/ScoreLegacyIdMap.php @@ -0,0 +1,34 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +namespace App\Models\Solo; + +use App\Models\Model; + +/** + * @property int $ruleset_id + * @property int $old_score_id + * @property int $score_id + */ +class ScoreLegacyIdMap extends Model +{ + public $incrementing = false; + public $timestamps = false; + + protected $primaryKey = ':composite'; + protected $primaryKeys = ['ruleset_id', 'old_score_id']; + protected $table = 'solo_scores_legacy_id_map'; + + public function getAttribute($key) + { + return match ($key) { + 'ruleset_id', + 'old_score_id', + 'score_id' => $this->getRawAttribute($key), + }; + } +} diff --git a/app/Models/Traits/UserScoreable.php b/app/Models/Traits/UserScoreable.php index e04a9bacccf..6c494e43036 100644 --- a/app/Models/Traits/UserScoreable.php +++ b/app/Models/Traits/UserScoreable.php @@ -5,53 +5,26 @@ namespace App\Models\Traits; -use App\Libraries\Elasticsearch\BoolQuery; -use App\Libraries\Elasticsearch\SearchResponse; -use App\Libraries\Search\BasicSearch; -use App\Models\Score\Best; +use App\Libraries\Score\FetchDedupedScores; +use App\Libraries\Search\ScoreSearchParams; +use App\Models\Beatmap; +use App\Models\Solo\Score; +use Illuminate\Database\Eloquent\Collection; trait UserScoreable { - private $beatmapBestScoreIds = []; + private array $beatmapBestScoreIds = []; + private array $beatmapBestScores = []; - public function aggregatedScoresBest(string $mode, int $size): SearchResponse + public function aggregatedScoresBest(string $mode, int $size): array { - $index = $GLOBALS['cfg']['osu']['elasticsearch']['prefix']."high_scores_{$mode}"; - - $search = new BasicSearch($index, "aggregatedScoresBest_{$mode}"); - $search->connectionName = 'scores'; - $search - ->size(0) // don't care about hits - ->query( - (new BoolQuery()) - ->filter(['term' => ['user_id' => $this->getKey()]]) - ) - ->setAggregations([ - 'by_beatmaps' => [ - 'terms' => [ - 'field' => 'beatmap_id', - // sort by sub-aggregation max_pp, with score_id as tie breaker - 'order' => [['max_pp' => 'desc'], ['min_score_id' => 'asc']], - 'size' => $size, - ], - 'aggs' => [ - 'top_scores' => [ - 'top_hits' => [ - 'size' => 1, - 'sort' => [['pp' => ['order' => 'desc']]], - ], - ], - // top_hits aggregation is not useable for sorting, so we need an extra aggregation to sort on. - 'max_pp' => ['max' => ['field' => 'pp']], - 'min_score_id' => ['min' => ['field' => 'score_id']], - ], - ], - ]); - - $response = $search->response(); - $search->assertNoError(); - - return $response; + return (new FetchDedupedScores('beatmap_id', ScoreSearchParams::fromArray([ + 'is_legacy' => true, + 'limit' => $size, + 'ruleset_id' => Beatmap::MODES[$mode], + 'sort' => 'pp_desc', + 'user_id' => $this->getKey(), + ])))->all(); } public function beatmapBestScoreIds(string $mode) @@ -60,16 +33,13 @@ public function beatmapBestScoreIds(string $mode) // aggregations do not support regular pagination. // always fetching 100 to cache; we're not supporting beyond 100, either. $this->beatmapBestScoreIds[$mode] = cache_remember_mutexed( - "search-cache:beatmapBestScores:{$this->getKey()}:{$mode}", + "search-cache:beatmapBestScoresSolo:{$this->getKey()}:{$mode}", $GLOBALS['cfg']['osu']['scores']['es_cache_duration'], [], function () use ($mode) { - // FIXME: should return some sort of error on error - $buckets = $this->aggregatedScoresBest($mode, 100)->aggregations('by_beatmaps')['buckets'] ?? []; + $this->beatmapBestScores[$mode] = $this->aggregatedScoresBest($mode, 100); - return array_map(function ($bucket) { - return array_get($bucket, 'top_scores.hits.hits.0._id'); - }, $buckets); + return array_column($this->beatmapBestScores[$mode], 'id'); }, function () { // TODO: propagate a more useful message back to the client @@ -82,12 +52,18 @@ function () { return $this->beatmapBestScoreIds[$mode]; } - public function beatmapBestScores(string $mode, int $limit, int $offset = 0, $with = []) + public function beatmapBestScores(string $mode, int $limit, int $offset = 0, $with = []): Collection { - $ids = array_slice($this->beatmapBestScoreIds($mode), $offset, $limit); - $clazz = Best\Model::getClass($mode); + $ids = $this->beatmapBestScoreIds($mode); + + if (isset($this->beatmapBestScores[$mode])) { + $results = new Collection(array_slice($this->beatmapBestScores[$mode], $offset, $limit)); + } else { + $ids = array_slice($ids, $offset, $limit); + $results = Score::whereKey($ids)->orderByField('id', $ids)->get(); + } - $results = $clazz::whereIn('score_id', $ids)->orderByField('score_id', $ids)->with($with)->get(); + $results->load($with); // fill in positions for weighting // also preload the user relation From df33f34332a4911f506a0d9cdb1d0be050a0f0d8 Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 31 Oct 2022 21:36:22 +0900 Subject: [PATCH 162/203] Order alphabetically --- app/Models/Solo/Score.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index aac957a92d2..b4884978306 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -147,11 +147,6 @@ public function scopeIndexable(Builder $query): Builder ->whereHas('user', fn (Builder $q): Builder => $q->default()); } - public function scopeDefault(Builder $query): Builder - { - return $query->whereHas('beatmap'); - } - public function getAttribute($key) { return match ($key) { From d73736a1261572e619f6f4eccd96f280c9b5d781 Mon Sep 17 00:00:00 2001 From: nanaya Date: Wed, 14 Dec 2022 13:39:41 +0900 Subject: [PATCH 163/203] Actually support sorting scores by pp --- app/Libraries/Search/ScoreSearchParams.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/Libraries/Search/ScoreSearchParams.php b/app/Libraries/Search/ScoreSearchParams.php index 13496e13df6..6651442d556 100644 --- a/app/Libraries/Search/ScoreSearchParams.php +++ b/app/Libraries/Search/ScoreSearchParams.php @@ -99,6 +99,12 @@ public function setSort(?string $sort): void new Sort('id', 'asc'), ]; break; + case 'pp_desc': + $this->sorts = [ + new Sort('pp', 'desc'), + new Sort('id', 'asc'), + ]; + break; case null: $this->sorts = []; break; From ee241c28087f3fbdcb5d13b1b9393651eeda2ed7 Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 20 Dec 2022 17:28:02 +0900 Subject: [PATCH 164/203] Tag scores best search --- app/Libraries/Elasticsearch/Search.php | 4 +--- app/Libraries/Score/FetchDedupedScores.php | 8 ++++++-- app/Models/Traits/UserScoreable.php | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/Libraries/Elasticsearch/Search.php b/app/Libraries/Elasticsearch/Search.php index 2f507589456..3e36086e175 100644 --- a/app/Libraries/Elasticsearch/Search.php +++ b/app/Libraries/Elasticsearch/Search.php @@ -22,10 +22,8 @@ abstract class Search extends HasSearch implements Queryable /** * A tag to use when logging timing of fetches. * FIXME: context-based tagging would be nicer. - * - * @var string|null */ - public $loggingTag; + public ?string $loggingTag; protected $aggregations; protected $index; diff --git a/app/Libraries/Score/FetchDedupedScores.php b/app/Libraries/Score/FetchDedupedScores.php index 4e9f1ce2813..198d6d821af 100644 --- a/app/Libraries/Score/FetchDedupedScores.php +++ b/app/Libraries/Score/FetchDedupedScores.php @@ -15,8 +15,11 @@ class FetchDedupedScores private int $limit; private array $result; - public function __construct(private string $dedupeColumn, private ScoreSearchParams $params) - { + public function __construct( + private string $dedupeColumn, + private ScoreSearchParams $params, + private ?string $searchLoggingTag = null + ) { $this->limit = $this->params->size; } @@ -24,6 +27,7 @@ public function all(): array { $this->params->size = $this->limit + 50; $search = new ScoreSearch($this->params); + $search->loggingTag = $this->searchLoggingTag; $nextCursor = null; $hasNext = true; diff --git a/app/Models/Traits/UserScoreable.php b/app/Models/Traits/UserScoreable.php index 6c494e43036..220932527c7 100644 --- a/app/Models/Traits/UserScoreable.php +++ b/app/Models/Traits/UserScoreable.php @@ -24,7 +24,7 @@ public function aggregatedScoresBest(string $mode, int $size): array 'ruleset_id' => Beatmap::MODES[$mode], 'sort' => 'pp_desc', 'user_id' => $this->getKey(), - ])))->all(); + ]), "aggregatedScoresBest_{$mode}"))->all(); } public function beatmapBestScoreIds(string $mode) From bd2b27ad7d66c9cb8c762b997ec806b02f80c53d Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 3 Jan 2023 18:36:08 +0900 Subject: [PATCH 165/203] Filter out score without beatmap --- app/Models/Traits/UserScoreable.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Models/Traits/UserScoreable.php b/app/Models/Traits/UserScoreable.php index 220932527c7..6f42854125c 100644 --- a/app/Models/Traits/UserScoreable.php +++ b/app/Models/Traits/UserScoreable.php @@ -60,7 +60,7 @@ public function beatmapBestScores(string $mode, int $limit, int $offset = 0, $wi $results = new Collection(array_slice($this->beatmapBestScores[$mode], $offset, $limit)); } else { $ids = array_slice($ids, $offset, $limit); - $results = Score::whereKey($ids)->orderByField('id', $ids)->get(); + $results = Score::whereKey($ids)->orderByField('id', $ids)->default()->get(); } $results->load($with); From 8625c09802f28f5c0ab79a52a04dcdbf3faa9689 Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 26 Dec 2023 23:45:48 +0900 Subject: [PATCH 166/203] Apply user preference for displaying scores --- app/Http/Controllers/UsersController.php | 11 ++++++-- app/Models/Traits/UserScoreable.php | 31 +++++++++++---------- app/Transformers/UserCompactTransformer.php | 6 +++- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index b7268ac858c..71931ca4f4c 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -12,6 +12,7 @@ use App\Libraries\RateLimiter; use App\Libraries\Search\ForumSearch; use App\Libraries\Search\ForumSearchRequestParams; +use App\Libraries\Search\ScoreSearchParams; use App\Libraries\User\FindForProfilePage; use App\Libraries\UserRegistration; use App\Models\Beatmap; @@ -193,7 +194,7 @@ public function extraPages($_id, $page) return [ 'best' => $this->getExtraSection( 'scoresBest', - count($this->user->beatmapBestScoreIds($this->mode)) + count($this->user->beatmapBestScoreIds($this->mode, ScoreSearchParams::showLegacyForUser(\Auth::user()))) ), 'firsts' => $this->getExtraSection( 'scoresFirsts', @@ -789,7 +790,13 @@ private function getExtra($page, array $options, int $perPage = 10, int $offset case 'scoresBest': $transformer = new ScoreTransformer(); $includes = [...ScoreTransformer::USER_PROFILE_INCLUDES, 'weight']; - $collection = $this->user->beatmapBestScores($this->mode, $perPage, $offset, ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); + $collection = $this->user->beatmapBestScores( + $this->mode, + $perPage, + $offset, + ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD, + ScoreSearchParams::showLegacyForUser(\Auth::user()), + ); $userRelationColumn = 'user'; break; case 'scoresFirsts': diff --git a/app/Models/Traits/UserScoreable.php b/app/Models/Traits/UserScoreable.php index 6f42854125c..1afe54df899 100644 --- a/app/Models/Traits/UserScoreable.php +++ b/app/Models/Traits/UserScoreable.php @@ -16,10 +16,10 @@ trait UserScoreable private array $beatmapBestScoreIds = []; private array $beatmapBestScores = []; - public function aggregatedScoresBest(string $mode, int $size): array + public function aggregatedScoresBest(string $mode, null | true $legacyOnly, int $size): array { return (new FetchDedupedScores('beatmap_id', ScoreSearchParams::fromArray([ - 'is_legacy' => true, + 'is_legacy' => $legacyOnly, 'limit' => $size, 'ruleset_id' => Beatmap::MODES[$mode], 'sort' => 'pp_desc', @@ -27,19 +27,21 @@ public function aggregatedScoresBest(string $mode, int $size): array ]), "aggregatedScoresBest_{$mode}"))->all(); } - public function beatmapBestScoreIds(string $mode) + public function beatmapBestScoreIds(string $mode, null | true $legacyOnly) { - if (!isset($this->beatmapBestScoreIds[$mode])) { + $key = $mode.'-'.($legacyOnly ? '1' : '0'); + + if (!isset($this->beatmapBestScoreIds[$key])) { // aggregations do not support regular pagination. // always fetching 100 to cache; we're not supporting beyond 100, either. - $this->beatmapBestScoreIds[$mode] = cache_remember_mutexed( - "search-cache:beatmapBestScoresSolo:{$this->getKey()}:{$mode}", + $this->beatmapBestScoreIds[$key] = cache_remember_mutexed( + "search-cache:beatmapBestScoresSolo:{$this->getKey()}:{$key}", $GLOBALS['cfg']['osu']['scores']['es_cache_duration'], [], - function () use ($mode) { - $this->beatmapBestScores[$mode] = $this->aggregatedScoresBest($mode, 100); + function () use ($key, $legacyOnly, $mode) { + $this->beatmapBestScores[$key] = $this->aggregatedScoresBest($mode, $legacyOnly, 100); - return array_column($this->beatmapBestScores[$mode], 'id'); + return array_column($this->beatmapBestScores[$key], 'id'); }, function () { // TODO: propagate a more useful message back to the client @@ -49,15 +51,16 @@ function () { ); } - return $this->beatmapBestScoreIds[$mode]; + return $this->beatmapBestScoreIds[$key]; } - public function beatmapBestScores(string $mode, int $limit, int $offset = 0, $with = []): Collection + public function beatmapBestScores(string $mode, int $limit, int $offset, array $with, null | true $legacyOnly): Collection { - $ids = $this->beatmapBestScoreIds($mode); + $ids = $this->beatmapBestScoreIds($mode, $legacyOnly); + $key = $mode.'-'.($legacyOnly ? '1' : '0'); - if (isset($this->beatmapBestScores[$mode])) { - $results = new Collection(array_slice($this->beatmapBestScores[$mode], $offset, $limit)); + if (isset($this->beatmapBestScores[$key])) { + $results = new Collection(array_slice($this->beatmapBestScores[$key], $offset, $limit)); } else { $ids = array_slice($ids, $offset, $limit); $results = Score::whereKey($ids)->orderByField('id', $ids)->default()->get(); diff --git a/app/Transformers/UserCompactTransformer.php b/app/Transformers/UserCompactTransformer.php index b68fdb447d1..b142e1a1311 100644 --- a/app/Transformers/UserCompactTransformer.php +++ b/app/Transformers/UserCompactTransformer.php @@ -6,6 +6,7 @@ namespace App\Transformers; use App\Libraries\MorphMap; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; use App\Models\User; use App\Models\UserProfileCustomization; @@ -387,7 +388,10 @@ public function includeReplaysWatchedCounts(User $user) public function includeScoresBestCount(User $user) { - return $this->primitive(count($user->beatmapBestScoreIds($this->mode))); + return $this->primitive(count($user->beatmapBestScoreIds( + $this->mode, + ScoreSearchParams::showLegacyForUser(\Auth::user()), + ))); } public function includeScoresFirstCount(User $user) From 00480d10f612dd08bdfce8c7a01509f20d575e61 Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 16 Jan 2024 19:40:29 +0900 Subject: [PATCH 167/203] Update to match new score table structure --- app/Http/Controllers/UsersController.php | 8 ++---- app/Models/Solo/ScoreLegacyIdMap.php | 34 ------------------------ 2 files changed, 2 insertions(+), 40 deletions(-) delete mode 100644 app/Models/Solo/ScoreLegacyIdMap.php diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index 71931ca4f4c..f5647d9f5e1 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -21,7 +21,6 @@ use App\Models\IpBan; use App\Models\Log; use App\Models\Solo\Score as SoloScore; -use App\Models\Solo\ScoreLegacyIdMap; use App\Models\User; use App\Models\UserAccountHistory; use App\Models\UserNotFound; @@ -804,12 +803,9 @@ private function getExtra($page, array $options, int $perPage = 10, int $offset $includes = ScoreTransformer::USER_PROFILE_INCLUDES; $scoreQuery = $this->user->scoresFirst($this->mode, true)->unorder(); $userFirstsQuery = $scoreQuery->select($scoreQuery->qualifyColumn('score_id')); - $soloMappingQuery = ScoreLegacyIdMap - ::where('ruleset_id', Beatmap::MODES[$this->mode]) - ->whereIn('old_score_id', $userFirstsQuery) - ->select('score_id'); $query = SoloScore - ::whereIn('id', $soloMappingQuery) + ::whereIn('legacy_score_id', $userFirstsQuery) + ->where('ruleset_id', Beatmap::MODES[$this->mode]) ->default() ->reorderBy('id', 'desc') ->with(ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); diff --git a/app/Models/Solo/ScoreLegacyIdMap.php b/app/Models/Solo/ScoreLegacyIdMap.php deleted file mode 100644 index 156de063d15..00000000000 --- a/app/Models/Solo/ScoreLegacyIdMap.php +++ /dev/null @@ -1,34 +0,0 @@ -. Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -declare(strict_types=1); - -namespace App\Models\Solo; - -use App\Models\Model; - -/** - * @property int $ruleset_id - * @property int $old_score_id - * @property int $score_id - */ -class ScoreLegacyIdMap extends Model -{ - public $incrementing = false; - public $timestamps = false; - - protected $primaryKey = ':composite'; - protected $primaryKeys = ['ruleset_id', 'old_score_id']; - protected $table = 'solo_scores_legacy_id_map'; - - public function getAttribute($key) - { - return match ($key) { - 'ruleset_id', - 'old_score_id', - 'score_id' => $this->getRawAttribute($key), - }; - } -} From 3eafd670ff893328573c9afb09be9f95db99f317 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 16 Mar 2023 21:41:53 +0900 Subject: [PATCH 168/203] Use solo scores index for beatmap pack completion check --- .../Controllers/BeatmapPacksController.php | 8 +- app/Libraries/Search/ScoreSearch.php | 3 + app/Libraries/Search/ScoreSearchParams.php | 2 + app/Models/BeatmapPack.php | 110 ++++++++---------- .../Models/BeatmapPackUserCompletionTest.php | 108 ++++++++++++----- 5 files changed, 138 insertions(+), 93 deletions(-) diff --git a/app/Http/Controllers/BeatmapPacksController.php b/app/Http/Controllers/BeatmapPacksController.php index 9337dbd78c5..f8a368a3f3a 100644 --- a/app/Http/Controllers/BeatmapPacksController.php +++ b/app/Http/Controllers/BeatmapPacksController.php @@ -5,10 +5,10 @@ namespace App\Http\Controllers; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; use App\Models\BeatmapPack; use App\Transformers\BeatmapPackTransformer; -use Auth; /** * @group Beatmap Packs @@ -100,7 +100,11 @@ public function show($idOrTag) $pack = $query->where('tag', $idOrTag)->firstOrFail(); $mode = Beatmap::modeStr($pack->playmode ?? 0); $sets = $pack->beatmapsets; - $userCompletionData = $pack->userCompletionData(Auth::user()); + $currentUser = \Auth::user(); + $userCompletionData = $pack->userCompletionData( + $currentUser, + ScoreSearchParams::showLegacyForUser($currentUser), + ); if (is_api_request()) { return json_item( diff --git a/app/Libraries/Search/ScoreSearch.php b/app/Libraries/Search/ScoreSearch.php index 08f17c1e9e1..61559e39898 100644 --- a/app/Libraries/Search/ScoreSearch.php +++ b/app/Libraries/Search/ScoreSearch.php @@ -48,6 +48,9 @@ public function getQuery(): BoolQuery if ($this->params->userId !== null) { $query->filter(['term' => ['user_id' => $this->params->userId]]); } + if ($this->params->excludeConverts) { + $query->filter(['term' => ['convert' => false]]); + } if ($this->params->excludeMods !== null && count($this->params->excludeMods) > 0) { foreach ($this->params->excludeMods as $excludedMod) { $query->mustNot(['term' => ['mods' => $excludedMod]]); diff --git a/app/Libraries/Search/ScoreSearchParams.php b/app/Libraries/Search/ScoreSearchParams.php index 13496e13df6..09426a2d043 100644 --- a/app/Libraries/Search/ScoreSearchParams.php +++ b/app/Libraries/Search/ScoreSearchParams.php @@ -21,6 +21,7 @@ class ScoreSearchParams extends SearchParams public ?array $beatmapIds = null; public ?Score $beforeScore = null; public ?int $beforeTotalScore = null; + public bool $excludeConverts = false; public ?array $excludeMods = null; public ?bool $isLegacy = null; public ?array $mods = null; @@ -36,6 +37,7 @@ public static function fromArray(array $rawParams): static { $params = new static(); $params->beatmapIds = $rawParams['beatmap_ids'] ?? null; + $params->excludeConverts = $rawParams['exclude_converts'] ?? $params->excludeConverts; $params->excludeMods = $rawParams['exclude_mods'] ?? null; $params->isLegacy = $rawParams['is_legacy'] ?? null; $params->mods = $rawParams['mods'] ?? null; diff --git a/app/Models/BeatmapPack.php b/app/Models/BeatmapPack.php index 6bcbfe13507..84de26931fa 100644 --- a/app/Models/BeatmapPack.php +++ b/app/Models/BeatmapPack.php @@ -5,8 +5,10 @@ namespace App\Models; +use App\Libraries\Search\ScoreSearch; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Traits\WithDbCursorHelper; -use Exception; +use Ds\Set; /** * @property string $author @@ -92,69 +94,59 @@ public function getRouteKeyName(): string return 'tag'; } - public function userCompletionData($user) + public function userCompletionData($user, ?bool $isLegacy) { if ($user !== null) { $userId = $user->getKey(); - $beatmapsetIds = $this->items()->pluck('beatmapset_id')->all(); - $query = Beatmap::select('beatmapset_id')->distinct()->whereIn('beatmapset_id', $beatmapsetIds); - - if ($this->playmode === null) { - static $scoreRelations; - - // generate list of beatmap->score relation names for each modes - // store int mode as well as it'll be used for filtering the scores - if (!isset($scoreRelations)) { - $scoreRelations = []; - foreach (Beatmap::MODES as $modeStr => $modeInt) { - $scoreRelations[] = [ - 'playmode' => $modeInt, - 'relation' => camel_case("scores_best_{$modeStr}"), - ]; - } - } - - // outer where function - // The idea is SELECT ... WHERE ... AND ( OR OR ...). - $query->where(function ($q) use ($scoreRelations, $userId) { - foreach ($scoreRelations as $scoreRelation) { - // The scores> mentioned above is generated here. - // As it's "playmode = AND EXISTS (< score for user>)", - // wrap them so it's not flat "playmode = AND EXISTS ... OR playmode = AND EXISTS ...". - $q->orWhere(function ($qq) use ($scoreRelation, $userId) { - $qq - // this playmode filter ensures the scores are limited to non-convert maps - ->where('playmode', '=', $scoreRelation['playmode']) - ->whereHas($scoreRelation['relation'], function ($scoreQuery) use ($userId) { - $scoreQuery->where('user_id', '=', $userId); - - if ($this->no_diff_reduction) { - $scoreQuery->withoutMods(app('mods')->difficultyReductionIds->toArray()); - } - }); - }); - } - }); - } else { - $modeStr = Beatmap::modeStr($this->playmode); - - if ($modeStr === null) { - throw new Exception("beatmapset pack {$this->getKey()} has invalid playmode: {$this->playmode}"); - } - - $scoreRelation = camel_case("scores_best_{$modeStr}"); - - $query->whereHas($scoreRelation, function ($query) use ($userId) { - $query->where('user_id', '=', $userId); - - if ($this->no_diff_reduction) { - $query->withoutMods(app('mods')->difficultyReductionIds->toArray()); - } - }); + + $beatmaps = Beatmap + ::whereIn('beatmapset_id', $this->items()->select('beatmapset_id')) + ->select(['beatmap_id', 'beatmapset_id', 'playmode']) + ->get(); + $beatmapsetIdsByBeatmapId = []; + foreach ($beatmaps as $beatmap) { + $beatmapsetIdsByBeatmapId[$beatmap->beatmap_id] = $beatmap->beatmapset_id; + } + $params = [ + 'beatmap_ids' => array_keys($beatmapsetIdsByBeatmapId), + 'exclude_converts' => $this->playmode === null, + 'is_legacy' => $isLegacy, + 'limit' => 0, + 'ruleset_id' => $this->playmode, + 'user_id' => $userId, + ]; + if ($this->no_diff_reduction) { + $params['exclude_mods'] = app('mods')->difficultyReductionIds->toArray(); } - $completedBeatmapsetIds = $query->pluck('beatmapset_id')->all(); - $completed = count($completedBeatmapsetIds) === count($beatmapsetIds); + static $aggName = 'by_beatmap'; + + $search = new ScoreSearch(ScoreSearchParams::fromArray($params)); + $search->size(0); + $search->setAggregations([$aggName => [ + 'terms' => [ + 'field' => 'beatmap_id', + 'size' => max(1, count($params['beatmap_ids'])), + ], + 'aggs' => [ + 'scores' => [ + 'top_hits' => [ + 'size' => 1, + ], + ], + ], + ]]); + $response = $search->response(); + $search->assertNoError(); + $completedBeatmapIds = array_map( + fn (array $hit): int => (int) $hit['key'], + $response->aggregations($aggName)['buckets'], + ); + $completedBeatmapsetIds = (new Set(array_map( + fn (int $beatmapId): int => $beatmapsetIdsByBeatmapId[$beatmapId], + $completedBeatmapIds, + )))->toArray(); + $completed = count($completedBeatmapsetIds) === count(array_unique($beatmapsetIdsByBeatmapId)); } return [ diff --git a/tests/Models/BeatmapPackUserCompletionTest.php b/tests/Models/BeatmapPackUserCompletionTest.php index 253a0290951..ca81f9075a2 100644 --- a/tests/Models/BeatmapPackUserCompletionTest.php +++ b/tests/Models/BeatmapPackUserCompletionTest.php @@ -7,53 +7,97 @@ namespace Tests\Models; +use App\Libraries\Search\ScoreSearch; use App\Models\Beatmap; use App\Models\BeatmapPack; -use App\Models\Score\Best as ScoreBest; +use App\Models\BeatmapPackItem; +use App\Models\Beatmapset; +use App\Models\Country; +use App\Models\Genre; +use App\Models\Group; +use App\Models\Language; +use App\Models\Solo\Score; use App\Models\User; +use App\Models\UserGroup; +use App\Models\UserGroupEvent; use Tests\TestCase; +/** + * @group RequiresScoreIndexer + */ class BeatmapPackUserCompletionTest extends TestCase { + private static array $users; + private static BeatmapPack $pack; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + + static::withDbAccess(function () { + $beatmap = Beatmap::factory()->ranked()->state([ + 'playmode' => Beatmap::MODES['taiko'], + ])->create(); + static::$pack = BeatmapPack::factory()->create(); + static::$pack->items()->create(['beatmapset_id' => $beatmap->beatmapset_id]); + + static::$users = [ + 'convertOsu' => User::factory()->create(), + 'default' => User::factory()->create(), + 'null' => null, + 'unrelated' => User::factory()->create(), + ]; + + Score::factory()->create([ + 'beatmap_id' => $beatmap, + 'ruleset_id' => Beatmap::MODES['osu'], + 'preserve' => true, + 'user_id' => static::$users['convertOsu'], + ]); + Score::factory()->create([ + 'beatmap_id' => $beatmap, + 'preserve' => true, + 'user_id' => static::$users['default'], + ]); + + static::reindexScores(); + }); + } + + public static function tearDownAfterClass(): void + { + static::withDbAccess(function () { + Beatmap::truncate(); + BeatmapPack::truncate(); + BeatmapPackItem::truncate(); + Beatmapset::truncate(); + Country::truncate(); + Genre::truncate(); + Language::truncate(); + Score::select()->delete(); // TODO: revert to truncate after the table is actually renamed + User::truncate(); + UserGroup::truncate(); + UserGroupEvent::truncate(); + (new ScoreSearch())->deleteAll(); + }); + + parent::tearDownAfterClass(); + } + + protected $connectionsToTransact = []; + /** * @dataProvider dataProviderForTestBasic */ public function testBasic(string $userType, ?string $packRuleset, bool $completed): void { - $beatmap = Beatmap::factory()->ranked()->state([ - 'playmode' => Beatmap::MODES['taiko'], - ])->create(); - $pack = BeatmapPack::factory()->create(); - $pack->items()->create(['beatmapset_id' => $beatmap->beatmapset_id]); - - $scoreUser = User::factory()->create(); - $scoreClass = ScoreBest\Taiko::class; - switch ($userType) { - case 'convertOsu': - $checkUser = $scoreUser; - $scoreClass = ScoreBest\Osu::class; - break; - case 'default': - $checkUser = $scoreUser; - break; - case 'null': - $checkUser = null; - break; - case 'unrelated': - $checkUser = User::factory()->create(); - break; - } - - $scoreClass::factory()->create([ - 'beatmap_id' => $beatmap, - 'user_id' => $scoreUser->getKey(), - ]); + $user = static::$users[$userType]; $rulesetId = $packRuleset === null ? null : Beatmap::MODES[$packRuleset]; - $pack->update(['playmode' => $rulesetId]); - $pack->refresh(); + static::$pack->update(['playmode' => $rulesetId]); + static::$pack->refresh(); - $data = $pack->userCompletionData($checkUser); + $data = static::$pack->userCompletionData($user, null); $this->assertSame($completed ? 1 : 0, count($data['beatmapset_ids'])); $this->assertSame($completed, $data['completed']); } From 0639f3866e348de719fdc3f559ed568d00eabfce Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 31 Oct 2022 22:02:53 +0900 Subject: [PATCH 169/203] Use solo scores for profile page recent scores --- app/Http/Controllers/UsersController.php | 9 ++++++--- app/Models/Solo/Score.php | 12 ++++++++++++ app/Models/User.php | 18 ++++++++++++++++++ app/Transformers/UserCompactTransformer.php | 2 +- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index b92a8bd65e1..7b5114c6cc4 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -176,7 +176,7 @@ public function extraPages($_id, $page) 'monthly_playcounts' => json_collection($this->user->monthlyPlaycounts, new UserMonthlyPlaycountTransformer()), 'recent' => $this->getExtraSection( 'scoresRecent', - $this->user->scores($this->mode, true)->includeFails(false)->count() + $this->user->recentScoreCount($this->mode) ), 'replays_watched_counts' => json_collection($this->user->replaysWatchedCounts, new UserReplaysWatchedCountTransformer()), ]; @@ -814,9 +814,12 @@ private function getExtra($page, array $options, int $perPage = 10, int $offset case 'scoresRecent': $transformer = new ScoreTransformer(); $includes = ScoreTransformer::USER_PROFILE_INCLUDES; - $query = $this->user->scores($this->mode, true) + $query = $this->user->soloScores() + ->default() + ->forRuleset($this->mode) ->includeFails($options['includeFails'] ?? false) - ->with([...ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD, 'best']); + ->reorderBy('id', 'desc') + ->with(ScoreTransformer::USER_PROFILE_INCLUDES_PRELOAD); $userRelationColumn = 'user'; break; } diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index b4884978306..c3e775a9852 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -137,6 +137,18 @@ public function scopeDefault(Builder $query): Builder return $query->whereHas('beatmap.beatmapset'); } + public function scopeForRuleset(Builder $query, string $ruleset): Builder + { + return $query->where('ruleset_id', Beatmap::MODES[$ruleset]); + } + + public function scopeIncludeFails(Builder $query, bool $includeFails): Builder + { + return $includeFails + ? $query + : $query->where('passed', true); + } + /** * This should match the one used in osu-elastic-indexer. */ diff --git a/app/Models/User.php b/app/Models/User.php index ce03568fcb7..5b5865144e0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -916,6 +916,7 @@ public function getAttribute($key) 'scoresMania', 'scoresOsu', 'scoresTaiko', + 'soloScores', 'statisticsFruits', 'statisticsMania', 'statisticsMania4k', @@ -1447,6 +1448,11 @@ public function scoresBest(string $mode, bool $returnQuery = false) return $returnQuery ? $this->$relation() : $this->$relation; } + public function soloScores(): HasMany + { + return $this->hasMany(Solo\Score::class); + } + public function topicWatches() { return $this->hasMany(TopicWatch::class); @@ -1796,6 +1802,18 @@ public function authHash(): string return hash('sha256', $this->user_email).':'.hash('sha256', $this->user_password); } + public function recentScoreCount(string $ruleset): int + { + return $this->soloScores() + ->default() + ->forRuleset($ruleset) + ->includeFails(false) + ->select('id') + ->limit(100) + ->get() + ->count(); + } + public function resetSessions(?string $excludedSessionId = null): void { $userId = $this->getKey(); diff --git a/app/Transformers/UserCompactTransformer.php b/app/Transformers/UserCompactTransformer.php index b68fdb447d1..818d229c85a 100644 --- a/app/Transformers/UserCompactTransformer.php +++ b/app/Transformers/UserCompactTransformer.php @@ -402,7 +402,7 @@ public function includeScoresPinnedCount(User $user) public function includeScoresRecentCount(User $user) { - return $this->primitive($user->scores($this->mode, true)->includeFails(false)->count()); + return $this->primitive($user->recentScoreCount($this->mode)); } public function includeStatistics(User $user) From 44e44d259e97697c19009b28fd35176c25ece632 Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 18 Dec 2023 20:39:01 +0900 Subject: [PATCH 170/203] Use new table for user played filter on map search --- app/Libraries/Search/BeatmapsetSearch.php | 49 +++++++++++------------ 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/app/Libraries/Search/BeatmapsetSearch.php b/app/Libraries/Search/BeatmapsetSearch.php index ec69b4ec3c2..7cd61548832 100644 --- a/app/Libraries/Search/BeatmapsetSearch.php +++ b/app/Libraries/Search/BeatmapsetSearch.php @@ -13,8 +13,9 @@ use App\Models\Beatmap; use App\Models\Beatmapset; use App\Models\Follow; -use App\Models\Score; +use App\Models\Solo; use App\Models\User; +use Ds\Set; class BeatmapsetSearch extends RecordSearch { @@ -423,38 +424,36 @@ private function addTextFilter(BoolQuery $query, string $paramField, array $fiel private function getPlayedBeatmapIds(?array $rank = null) { - $unionQuery = null; + $query = Solo\Score + ::where('user_id', $this->params->user->getKey()) + ->whereIn('ruleset_id', $this->getSelectedModes()); - $select = $rank === null ? 'beatmap_id' : ['beatmap_id', 'score', 'rank']; + if ($rank === null) { + return $query->distinct('beatmap_id')->pluck('beatmap_id'); + } - foreach ($this->getSelectedModes() as $mode) { - $newQuery = Score\Best\Model::getClassByRulesetId($mode) - ::forUser($this->params->user) - ->select($select); + $topScores = []; + $scoreField = ScoreSearchParams::showLegacyForUser($this->params->user) + ? 'legacy_total_score' + : 'total_score'; + foreach ($query->get() as $score) { + $prevScore = $topScores[$score->beatmap_id] ?? null; - if ($unionQuery === null) { - $unionQuery = $newQuery; - } else { - $unionQuery->union($newQuery); + $scoreValue = $score->$scoreField; + if ($scoreValue !== null && ($prevScore === null || $prevScore->$scoreField < $scoreValue)) { + $topScores[$score->beatmap_id] = $score; } } - if ($rank === null) { - return model_pluck($unionQuery, 'beatmap_id'); - } else { - $allScores = $unionQuery->get(); - $beatmapRank = collect(); - - foreach ($allScores as $score) { - $prevScore = $beatmapRank[$score->beatmap_id] ?? null; - - if ($prevScore === null || $prevScore->score < $score->score) { - $beatmapRank[$score->beatmap_id] = $score; - } + $ret = []; + $rankSet = new Set($rank); + foreach ($topScores as $beatmapId => $score) { + if ($rankSet->contains($score->rank)) { + $ret[] = $beatmapId; } - - return $beatmapRank->whereInStrict('rank', $rank)->pluck('beatmap_id')->all(); } + + return $ret; } private function getSelectedModes() From 352c168a2bd406c7e9e3f89e948f3ab5e4fb84a4 Mon Sep 17 00:00:00 2001 From: nanaya Date: Wed, 24 Jan 2024 21:05:09 +0900 Subject: [PATCH 171/203] Use correct isLegacy parameter for score user rank --- app/Models/Solo/Score.php | 11 ++++++++--- app/Transformers/ScoreTransformer.php | 10 ++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index 1c4841b188c..283e9c7a25e 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -265,13 +265,18 @@ public function url(): string public function userRank(?array $params = null): int { - return UserRank::getRank(ScoreSearchParams::fromArray(array_merge($params ?? [], [ + // Non-legacy score always has its rank checked against all score types. + if (!$this->isLegacy()) { + $params['is_legacy'] = null; + } + + return UserRank::getRank(ScoreSearchParams::fromArray([ + ...($params ?? []), 'beatmap_ids' => [$this->beatmap_id], 'before_score' => $this, - 'is_legacy' => $this->isLegacy(), 'ruleset_id' => $this->ruleset_id, 'user' => $this->user, - ]))); + ])); } protected function newReportableExtraParams(): array diff --git a/app/Transformers/ScoreTransformer.php b/app/Transformers/ScoreTransformer.php index 8526ad7ea48..9bf46824601 100644 --- a/app/Transformers/ScoreTransformer.php +++ b/app/Transformers/ScoreTransformer.php @@ -7,6 +7,7 @@ namespace App\Transformers; +use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; use App\Models\DeletedUser; use App\Models\LegacyMatch; @@ -248,12 +249,17 @@ function ($item) use ($limit, $transformer) { public function includeRankCountry(ScoreBest|SoloScore $score) { - return $this->primitive($score->userRank(['type' => 'country'])); + return $this->primitive($score->userRank([ + 'type' => 'country', + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), + ])); } public function includeRankGlobal(ScoreBest|SoloScore $score) { - return $this->primitive($score->userRank([])); + return $this->primitive($score->userRank([ + 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), + ])); } public function includeUser(LegacyMatch\Score|MultiplayerScoreLink|ScoreModel|SoloScore $score) From ff1f7e8f2f828adde7461635c898505b24e1eaa2 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 25 Jan 2024 15:30:10 +0900 Subject: [PATCH 172/203] Add more details in queue --- app/Libraries/ClientCheck.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Libraries/ClientCheck.php b/app/Libraries/ClientCheck.php index 914fc87afe8..371552ebed4 100644 --- a/app/Libraries/ClientCheck.php +++ b/app/Libraries/ClientCheck.php @@ -58,6 +58,9 @@ public static function parseToken(Request $request): array } $ret['token'] = $token; + // to be included in queue + $ret['body'] = base64_encode($request->getContent()); + $ret['url'] = $request->getRequestUri(); } catch (ClientCheckParseTokenException $e) { abort_if($assertValid, 422, $e->getMessage()); } @@ -72,8 +75,10 @@ public static function queueToken(?array $tokenData, int $scoreId): void } \LaravelRedis::lpush($GLOBALS['cfg']['osu']['client']['token_queue'], json_encode([ + 'body' => $tokenData['body'], 'id' => $scoreId, 'token' => $tokenData['token'], + 'url' => $tokenData['url'], ])); } From 021c13da0433f5c88f601e59d7720a447f381a60 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 25 Jan 2024 20:11:05 +0900 Subject: [PATCH 173/203] Show legacy total score accordingly --- resources/js/interfaces/user-preferences-json.ts | 2 ++ resources/js/utils/score-helper.ts | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/resources/js/interfaces/user-preferences-json.ts b/resources/js/interfaces/user-preferences-json.ts index 06fcc5df47a..810154ec1d3 100644 --- a/resources/js/interfaces/user-preferences-json.ts +++ b/resources/js/interfaces/user-preferences-json.ts @@ -16,6 +16,7 @@ export const defaultUserPreferencesJson: UserPreferencesJson = { comments_show_deleted: false, comments_sort: 'new', forum_posts_show_deleted: true, + legacy_score_only: true, profile_cover_expanded: true, user_list_filter: 'all', user_list_sort: 'last_visit', @@ -33,6 +34,7 @@ export default interface UserPreferencesJson { comments_show_deleted: boolean; comments_sort: string; forum_posts_show_deleted: boolean; + legacy_score_only: boolean; profile_cover_expanded: boolean; user_list_filter: Filter; user_list_sort: SortMode; diff --git a/resources/js/utils/score-helper.ts b/resources/js/utils/score-helper.ts index 9f3bf6d3fd4..c4030e7392f 100644 --- a/resources/js/utils/score-helper.ts +++ b/resources/js/utils/score-helper.ts @@ -123,7 +123,9 @@ export function scoreUrl(score: SoloScoreJson) { } export function totalScore(score: SoloScoreJson) { - return score.legacy_score_id == null - ? score.total_score - : score.legacy_total_score; + if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) { + return score.total_score; + } + + return score.legacy_total_score; } From 5eddb0d2e030c72a3f712931919cdfab2a1bbe66 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 25 Jan 2024 17:32:30 +0900 Subject: [PATCH 174/203] Hide classic mod for legacy score on non-lazer view --- resources/js/beatmapsets-show/scoreboard/table-row.tsx | 4 ++-- resources/js/beatmapsets-show/scoreboard/top-card.tsx | 4 ++-- resources/js/mp-history/game-header.coffee | 1 + resources/js/profile-page/play-detail.tsx | 4 ++-- resources/js/scores-show/player.tsx | 4 ++-- resources/js/utils/score-helper.ts | 9 +++++++++ 6 files changed, 18 insertions(+), 8 deletions(-) diff --git a/resources/js/beatmapsets-show/scoreboard/table-row.tsx b/resources/js/beatmapsets-show/scoreboard/table-row.tsx index 267b85e75ad..8515be0d74d 100644 --- a/resources/js/beatmapsets-show/scoreboard/table-row.tsx +++ b/resources/js/beatmapsets-show/scoreboard/table-row.tsx @@ -16,7 +16,7 @@ import PpValue from 'scores/pp-value'; import { classWithModifiers, Modifiers } from 'utils/css'; import { formatNumber } from 'utils/html'; import { trans } from 'utils/lang'; -import { hasMenu, isPerfectCombo, modeAttributesMap, scoreUrl, totalScore } from 'utils/score-helper'; +import { filterMods, hasMenu, isPerfectCombo, modeAttributesMap, scoreUrl, totalScore } from 'utils/score-helper'; const bn = 'beatmap-scoreboard-table'; @@ -149,7 +149,7 @@ export default class ScoreboardTableRow extends React.Component {
    - {score.mods.map((mod) => )} + {filterMods(score).map((mod) => )}
    diff --git a/resources/js/beatmapsets-show/scoreboard/top-card.tsx b/resources/js/beatmapsets-show/scoreboard/top-card.tsx index b25f5f0cb8b..2c1d0d034b2 100644 --- a/resources/js/beatmapsets-show/scoreboard/top-card.tsx +++ b/resources/js/beatmapsets-show/scoreboard/top-card.tsx @@ -19,7 +19,7 @@ import { rulesetName, shouldShowPp } from 'utils/beatmap-helper'; import { classWithModifiers, Modifiers } from 'utils/css'; import { formatNumber } from 'utils/html'; import { trans } from 'utils/lang'; -import { isPerfectCombo, modeAttributesMap, scoreUrl, totalScore } from 'utils/score-helper'; +import { filterMods, isPerfectCombo, modeAttributesMap, scoreUrl, totalScore } from 'utils/score-helper'; interface Props { beatmap: BeatmapJson; @@ -182,7 +182,7 @@ export default class TopCard extends React.PureComponent { {trans('beatmapsets.show.scoreboard.headers.mods')}
    - {this.props.score.mods.map((mod) => )} + {filterMods(this.props.score).map((mod) => )}
    diff --git a/resources/js/mp-history/game-header.coffee b/resources/js/mp-history/game-header.coffee index 51ad2bc9633..1a226343adb 100644 --- a/resources/js/mp-history/game-header.coffee +++ b/resources/js/mp-history/game-header.coffee @@ -9,6 +9,7 @@ import * as React from 'react' import { div, a, span, h1, h2 } from 'react-dom-factories' import { getArtist, getTitle } from 'utils/beatmapset-helper' import { trans } from 'utils/lang' +import { filterMods } from 'utils/score-helper' el = React.createElement diff --git a/resources/js/profile-page/play-detail.tsx b/resources/js/profile-page/play-detail.tsx index da1bcb34b1e..3081575db09 100644 --- a/resources/js/profile-page/play-detail.tsx +++ b/resources/js/profile-page/play-detail.tsx @@ -13,7 +13,7 @@ import { getArtist, getTitle } from 'utils/beatmapset-helper'; import { classWithModifiers } from 'utils/css'; import { formatNumber } from 'utils/html'; import { trans } from 'utils/lang'; -import { hasMenu } from 'utils/score-helper'; +import { filterMods, hasMenu } from 'utils/score-helper'; import { beatmapUrl } from 'utils/url'; const bn = 'play-detail'; @@ -111,7 +111,7 @@ export default class PlayDetail extends React.PureComponent {
    - {score.mods.map((mod) => )} + {filterMods(score).map((mod) => )}
    diff --git a/resources/js/scores-show/player.tsx b/resources/js/scores-show/player.tsx index 2a267c67cc4..499637bef9c 100644 --- a/resources/js/scores-show/player.tsx +++ b/resources/js/scores-show/player.tsx @@ -7,7 +7,7 @@ import * as moment from 'moment'; import * as React from 'react'; import { formatNumber } from 'utils/html'; import { trans } from 'utils/lang'; -import { totalScore } from 'utils/score-helper'; +import { filterMods, totalScore } from 'utils/score-helper'; interface Props { score: SoloScoreJsonForShow; @@ -22,7 +22,7 @@ export default function Player(props: Props) {
    - {props.score.mods.map((mod) => ( + {filterMods(props.score).map((mod) => (
    diff --git a/resources/js/utils/score-helper.ts b/resources/js/utils/score-helper.ts index c4030e7392f..7e1381cd8c6 100644 --- a/resources/js/utils/score-helper.ts +++ b/resources/js/utils/score-helper.ts @@ -14,6 +14,15 @@ export function canBeReported(score: SoloScoreJson) { && score.user_id !== core.currentUser.id; } +// Removes CL mod on legacy score if user has lazer mode disabled +export function filterMods(score: SoloScoreJson) { + if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) { + return score.mods; + } + + return score.mods.filter((mod) => mod.acronym !== 'CL'); +} + // TODO: move to application state repository thingy later export function hasMenu(score: SoloScoreJson) { return canBeReported(score) || hasReplay(score) || hasShow(score) || core.scorePins.canBePinned(score); From d063db8a30285b3b1b8917c1fc52a98130d188b4 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 25 Jan 2024 20:06:10 +0900 Subject: [PATCH 175/203] Calculate legacy score attributes The ones in the json field are for lazer view. --- resources/js/profile-page/play-detail.tsx | 9 +- resources/js/scores-show/info.tsx | 5 +- resources/js/utils/legacy-score-helper.ts | 152 ++++++++++++++++++++++ resources/js/utils/score-helper.ts | 17 +++ 4 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 resources/js/utils/legacy-score-helper.ts diff --git a/resources/js/profile-page/play-detail.tsx b/resources/js/profile-page/play-detail.tsx index 3081575db09..0fbe9dc190a 100644 --- a/resources/js/profile-page/play-detail.tsx +++ b/resources/js/profile-page/play-detail.tsx @@ -13,7 +13,7 @@ import { getArtist, getTitle } from 'utils/beatmapset-helper'; import { classWithModifiers } from 'utils/css'; import { formatNumber } from 'utils/html'; import { trans } from 'utils/lang'; -import { filterMods, hasMenu } from 'utils/score-helper'; +import { accuracy, filterMods, hasMenu, rank } from 'utils/score-helper'; import { beatmapUrl } from 'utils/url'; const bn = 'play-detail'; @@ -52,13 +52,14 @@ export default class PlayDetail extends React.PureComponent { } const scoreWeight = this.props.showPpWeight ? score.weight : null; + const scoreRank = rank(score); return (
    {this.renderPinSortableHandle()}
    -
    +
    @@ -86,12 +87,12 @@ export default class PlayDetail extends React.PureComponent {
    -
    +
    - {formatNumber(score.accuracy * 100, 2)}% + {formatNumber(accuracy(score) * 100, 2)}% {scoreWeight != null && ( diff --git a/resources/js/scores-show/info.tsx b/resources/js/scores-show/info.tsx index 2adf0d7129a..e598a775c44 100644 --- a/resources/js/scores-show/info.tsx +++ b/resources/js/scores-show/info.tsx @@ -5,6 +5,7 @@ import BeatmapsetCover from 'components/beatmapset-cover'; import { SoloScoreJsonForShow } from 'interfaces/solo-score-json'; import * as React from 'react'; import { rulesetName } from 'utils/beatmap-helper'; +import { accuracy, rank } from 'utils/score-helper'; import Buttons from './buttons'; import Dial from './dial'; import Player from './player'; @@ -22,11 +23,11 @@ export default function Info({ score }: Props) {
    - +
    - +
    diff --git a/resources/js/utils/legacy-score-helper.ts b/resources/js/utils/legacy-score-helper.ts new file mode 100644 index 00000000000..177302f4de7 --- /dev/null +++ b/resources/js/utils/legacy-score-helper.ts @@ -0,0 +1,152 @@ +// 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 Rank from 'interfaces/rank'; +import SoloScoreJson from 'interfaces/solo-score-json'; + +interface CacheEntry { + accuracy: number; + rank: Rank; +} +const cache: Partial> = {}; + +function shouldHaveHiddenRank(score: SoloScoreJson) { + return score.mods.some((mod) => mod.acronym === 'FL' || mod.acronym === 'HD'); +} + +export function legacyAccuracyAndRank(score: SoloScoreJson) { + const key = `${score.type}:${score.id}`; + let cached = cache[key]; + + if (cached == null) { + const countMiss = score.statistics.miss ?? 0; + const countGreat = score.statistics.great ?? 0; + + let accuracy: number; + let rank: Rank; + + switch (score.ruleset_id) { + // osu + case 0: { + const countMeh = score.statistics.meh ?? 0; + const countOk = score.statistics.ok ?? 0; + + const totalHits = countMeh + countOk + countGreat + countMiss; + accuracy = totalHits > 0 + ? (countMeh * 50 + countOk * 100 + countGreat * 300) / (totalHits * 300) + : 1; + + const ratioGreat = totalHits > 0 ? countGreat / totalHits : 1; + const ratioMeh = totalHits > 0 ? countMeh / totalHits : 1; + + if (score.rank === 'F') { + rank = 'F'; + } else if (ratioGreat === 1) { + rank = shouldHaveHiddenRank(score) ? 'XH' : 'X'; + } else if (ratioGreat > 0.9 && ratioMeh <= 0.01 && countMiss === 0) { + rank = shouldHaveHiddenRank(score) ? 'SH' : 'S'; + } else if ((ratioGreat > 0.8 && countMiss === 0) || ratioGreat > 0.9) { + rank = 'A'; + } else if ((ratioGreat > 0.7 && countMiss === 0) || ratioGreat > 0.8) { + rank = 'B'; + } else if (ratioGreat > 0.6) { + rank = 'C'; + } else { + rank = 'D'; + } + break; + } + // taiko + case 1: { + const countOk = score.statistics.ok ?? 0; + + const totalHits = countOk + countGreat + countMiss; + accuracy = totalHits > 0 + ? (countOk * 150 + countGreat * 300) / (totalHits * 300) + : 1; + + const ratioGreat = totalHits > 0 ? countGreat / totalHits : 1; + + if (score.rank === 'F') { + rank = 'F'; + } else if (ratioGreat === 1) { + rank = shouldHaveHiddenRank(score) ? 'XH' : 'X'; + } else if (ratioGreat > 0.9 && countMiss === 0) { + rank = shouldHaveHiddenRank(score) ? 'SH' : 'S'; + } else if ((ratioGreat > 0.8 && countMiss === 0) || ratioGreat > 0.9) { + rank = 'A'; + } else if ((ratioGreat > 0.7 && countMiss === 0) || ratioGreat > 0.8) { + rank = 'B'; + } else if (ratioGreat > 0.6) { + rank = 'C'; + } else { + rank = 'D'; + } + break; + } + // catch + case 2: { + const countLargeTickHit = score.statistics.large_tick_hit ?? 0; + const countSmallTickHit = score.statistics.small_tick_hit ?? 0; + const countSmallTickMiss = score.statistics.small_tick_miss ?? 0; + + const totalHits = countSmallTickHit + countLargeTickHit + countGreat + countMiss + countSmallTickMiss; + accuracy = totalHits > 0 + ? (countSmallTickHit + countLargeTickHit + countGreat) / totalHits + : 1; + + if (score.rank === 'F') { + rank = 'F'; + } else if (accuracy === 1) { + rank = shouldHaveHiddenRank(score) ? 'XH' : 'X'; + } else if (accuracy > 0.98) { + rank = shouldHaveHiddenRank(score) ? 'SH' : 'S'; + } else if (accuracy > 0.94) { + rank = 'A'; + } else if (accuracy > 0.9) { + rank = 'B'; + } else if (accuracy > 0.85) { + rank = 'C'; + } else { + rank = 'D'; + } + break; + } + // mania + case 3: { + const countPerfect = score.statistics.perfect ?? 0; + const countGood = score.statistics.good ?? 0; + const countOk = score.statistics.ok ?? 0; + const countMeh = score.statistics.meh ?? 0; + + const totalHits = countPerfect + countGood + countOk + countMeh + countGreat + countMiss; + accuracy = totalHits > 0 + ? ((countGreat + countPerfect) * 300 + countGood * 200 + countOk * 100 + countMeh * 50) / (totalHits * 300) + : 1; + + if (score.rank === 'F') { + rank = 'F'; + } else if (accuracy === 1) { + rank = shouldHaveHiddenRank(score) ? 'XH' : 'X'; + } else if (accuracy > 0.95) { + rank = shouldHaveHiddenRank(score) ? 'SH' : 'S'; + } else if (accuracy > 0.9) { + rank = 'A'; + } else if (accuracy > 0.8) { + rank = 'B'; + } else if (accuracy > 0.7) { + rank = 'C'; + } else { + rank = 'D'; + } + break; + } + default: + throw new Error('unknown score ruleset'); + } + + cached = cache[key] = { accuracy, rank }; + } + + return cached; +} diff --git a/resources/js/utils/score-helper.ts b/resources/js/utils/score-helper.ts index 7e1381cd8c6..7e157c2d735 100644 --- a/resources/js/utils/score-helper.ts +++ b/resources/js/utils/score-helper.ts @@ -7,6 +7,15 @@ import { route } from 'laroute'; import core from 'osu-core-singleton'; import { rulesetName } from './beatmap-helper'; import { trans } from './lang'; +import { legacyAccuracyAndRank } from './legacy-score-helper'; + +export function accuracy(score: SoloScoreJson) { + if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) { + return score.accuracy; + } + + return legacyAccuracyAndRank(score).accuracy; +} export function canBeReported(score: SoloScoreJson) { return (score.best_id != null || score.type === 'solo_score') @@ -101,6 +110,14 @@ export const modeAttributesMap: Record = { ], }; +export function rank(score: SoloScoreJson) { + if (score.legacy_score_id == null || !core.userPreferences.get('legacy_score_only')) { + return score.rank; + } + + return legacyAccuracyAndRank(score).rank; +} + export function scoreDownloadUrl(score: SoloScoreJson) { if (score.type === 'solo_score') { return route('scores.download', { score: score.id }); From 891ac197d9662dfee644c45b8aeb6094449c78d3 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 16 Mar 2023 21:36:54 +0900 Subject: [PATCH 176/203] Use new index for score leaderboard --- app/Http/Controllers/BeatmapsController.php | 178 +++++------- app/Libraries/Search/ScoreSearch.php | 10 +- app/Libraries/Search/ScoreSearchParams.php | 33 ++- database/factories/Solo/ScoreFactory.php | 2 + .../BeatmapsControllerSoloScoresTest.php | 177 ++++++------ tests/Controllers/BeatmapsControllerTest.php | 270 +----------------- 6 files changed, 201 insertions(+), 469 deletions(-) diff --git a/app/Http/Controllers/BeatmapsController.php b/app/Http/Controllers/BeatmapsController.php index 097852301e7..e16fd2d4a33 100644 --- a/app/Http/Controllers/BeatmapsController.php +++ b/app/Http/Controllers/BeatmapsController.php @@ -51,6 +51,66 @@ private static function baseScoreQuery(Beatmap $beatmap, $mode, $mods, $type = n return $query; } + private static function beatmapScores(string $id, ?string $scoreTransformerType, ?bool $isLegacy): array + { + $beatmap = Beatmap::findOrFail($id); + if ($beatmap->approved <= 0) { + return ['scores' => []]; + } + + $params = get_params(request()->all(), null, [ + 'limit:int', + 'mode', + 'mods:string[]', + 'type:string', + ], ['null_missing' => true]); + + if ($params['mode'] !== null) { + $rulesetId = Beatmap::MODES[$params['mode']] ?? null; + if ($rulesetId === null) { + throw new InvariantException('invalid mode specified'); + } + } + $rulesetId ??= $beatmap->playmode; + $mods = array_values(array_filter($params['mods'] ?? [])); + $type = presence($params['type'], 'global'); + $currentUser = \Auth::user(); + + static::assertSupporterOnlyOptions($currentUser, $type, $mods); + + $esFetch = new BeatmapScores([ + 'beatmap_ids' => [$beatmap->getKey()], + 'is_legacy' => $isLegacy, + 'limit' => $params['limit'], + 'mods' => $mods, + 'ruleset_id' => $rulesetId, + 'type' => $type, + 'user' => $currentUser, + ]); + $scores = $esFetch->all()->loadMissing(['beatmap', 'user.country', 'user.userProfileCustomization']); + $userScore = $esFetch->userBest(); + $scoreTransformer = new ScoreTransformer($scoreTransformerType); + + $results = [ + 'scores' => json_collection( + $scores, + $scoreTransformer, + static::DEFAULT_SCORE_INCLUDES + ), + ]; + + if (isset($userScore)) { + $results['user_score'] = [ + 'position' => $esFetch->rank($userScore), + 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), + ]; + // TODO: remove this old camelCased json field + $results['userScore'] = $results['user_score']; + } + + return $results; + } + public function __construct() { parent::__construct(); @@ -280,7 +340,7 @@ public function show($id) /** * Get Beatmap scores * - * Returns the top scores for a beatmap + * Returns the top scores for a beatmap. Depending on user preferences, this may only show legacy scores. * * --- * @@ -296,60 +356,18 @@ public function show($id) */ public function scores($id) { - $beatmap = Beatmap::findOrFail($id); - if ($beatmap->approved <= 0) { - return ['scores' => []]; - } - - $params = get_params(request()->all(), null, [ - 'limit:int', - 'mode:string', - 'mods:string[]', - 'type:string', - ], ['null_missing' => true]); - - $mode = presence($params['mode']) ?? $beatmap->mode; - $mods = array_values(array_filter($params['mods'] ?? [])); - $type = presence($params['type']) ?? 'global'; - $currentUser = auth()->user(); - - static::assertSupporterOnlyOptions($currentUser, $type, $mods); - - $query = static::baseScoreQuery($beatmap, $mode, $mods, $type); - - if ($currentUser !== null) { - // own score shouldn't be filtered by visibleUsers() - $userScore = (clone $query)->where('user_id', $currentUser->user_id)->first(); - } - - $scoreTransformer = new ScoreTransformer(); - - $results = [ - 'scores' => json_collection( - $query->visibleUsers()->forListing($params['limit']), - $scoreTransformer, - static::DEFAULT_SCORE_INCLUDES - ), - ]; - - if (isset($userScore)) { - $results['user_score'] = [ - 'position' => $userScore->userRank(compact('type', 'mods')), - 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), - ]; - // TODO: remove this old camelCased json field - $results['userScore'] = $results['user_score']; - } - - return $results; + return static::beatmapScores( + $id, + null, + // TODO: change to imported name after merge with other PRs + \App\Libraries\Search\ScoreSearchParams::showLegacyForUser(\Auth::user()), + ); } /** - * Get Beatmap scores (temp) + * Get Beatmap scores (non-legacy) * - * Returns the top scores for a beatmap from newer client. - * - * This is a temporary endpoint. + * Returns the top scores for a beatmap. * * --- * @@ -359,68 +377,14 @@ public function scores($id) * * @urlParam beatmap integer required Id of the [Beatmap](#beatmap). * + * @queryParam legacy_only Set to true to only return legacy scores. Example: 0 * @queryParam mode The [Ruleset](#ruleset) to get scores for. * @queryParam mods An array of matching Mods, or none // TODO. * @queryParam type Beatmap score ranking type // TODO. */ public function soloScores($id) { - $beatmap = Beatmap::findOrFail($id); - if ($beatmap->approved <= 0) { - return ['scores' => []]; - } - - $params = get_params(request()->all(), null, [ - 'limit:int', - 'mode', - 'mods:string[]', - 'type:string', - ], ['null_missing' => true]); - - if ($params['mode'] !== null) { - $rulesetId = Beatmap::MODES[$params['mode']] ?? null; - if ($rulesetId === null) { - throw new InvariantException('invalid mode specified'); - } - } - $rulesetId ??= $beatmap->playmode; - $mods = array_values(array_filter($params['mods'] ?? [])); - $type = presence($params['type'], 'global'); - $currentUser = auth()->user(); - - static::assertSupporterOnlyOptions($currentUser, $type, $mods); - - $esFetch = new BeatmapScores([ - 'beatmap_ids' => [$beatmap->getKey()], - 'is_legacy' => false, - 'limit' => $params['limit'], - 'mods' => $mods, - 'ruleset_id' => $rulesetId, - 'type' => $type, - 'user' => $currentUser, - ]); - $scores = $esFetch->all()->loadMissing(['beatmap', 'user.country', 'user.userProfileCustomization']); - $userScore = $esFetch->userBest(); - $scoreTransformer = new ScoreTransformer(ScoreTransformer::TYPE_SOLO); - - $results = [ - 'scores' => json_collection( - $scores, - $scoreTransformer, - static::DEFAULT_SCORE_INCLUDES - ), - ]; - - if (isset($userScore)) { - $results['user_score'] = [ - 'position' => $esFetch->rank($userScore), - 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), - ]; - // TODO: remove this old camelCased json field - $results['userScore'] = $results['user_score']; - } - - return $results; + return static::beatmapScores($id, ScoreTransformer::TYPE_SOLO, null); } public function updateOwner($id) diff --git a/app/Libraries/Search/ScoreSearch.php b/app/Libraries/Search/ScoreSearch.php index 08f17c1e9e1..5d65baba0b3 100644 --- a/app/Libraries/Search/ScoreSearch.php +++ b/app/Libraries/Search/ScoreSearch.php @@ -67,19 +67,20 @@ public function getQuery(): BoolQuery $beforeTotalScore = $this->params->beforeTotalScore; if ($beforeTotalScore === null && $this->params->beforeScore !== null) { - $beforeTotalScore = $this->params->beforeScore->isLegacy() + $beforeTotalScore = $this->params->isLegacy ? $this->params->beforeScore->legacy_total_score : $this->params->beforeScore->total_score; } if ($beforeTotalScore !== null) { $scoreQuery = (new BoolQuery())->shouldMatch(1); + $scoreField = $this->params->isLegacy ? 'legacy_total_score' : 'total_score'; $scoreQuery->should((new BoolQuery())->filter(['range' => [ - 'total_score' => ['gt' => $beforeTotalScore], + $scoreField => ['gt' => $beforeTotalScore], ]])); if ($this->params->beforeScore !== null) { $scoreQuery->should((new BoolQuery()) ->filter(['range' => ['id' => ['lt' => $this->params->beforeScore->getKey()]]]) - ->filter(['term' => ['total_score' => $beforeTotalScore]])); + ->filter(['term' => [$scoreField => $beforeTotalScore]])); } $query->must($scoreQuery); @@ -142,7 +143,8 @@ private function addModsFilter(BoolQuery $query): void $allMods = $this->params->rulesetId === null ? $modsHelper->allIds : new Set(array_keys($modsHelper->mods[$this->params->rulesetId])); - $allMods->remove('PF', 'SD', 'MR'); + // CL is currently considered a "preference" mod + $allMods->remove('CL', 'PF', 'SD', 'MR'); $allSearchMods = []; foreach ($mods as $mod) { diff --git a/app/Libraries/Search/ScoreSearchParams.php b/app/Libraries/Search/ScoreSearchParams.php index 13496e13df6..4a96d15ee10 100644 --- a/app/Libraries/Search/ScoreSearchParams.php +++ b/app/Libraries/Search/ScoreSearchParams.php @@ -55,10 +55,33 @@ public static function fromArray(array $rawParams): static } /** - * This returns value for isLegacy based on user preference + * This returns value for isLegacy based on user preference, request type, and `legacy_only` parameter */ - public static function showLegacyForUser(?User $user): null | true - { + public static function showLegacyForUser( + ?User $user = null, + ?bool $legacyOnly = null, + ?bool $isApiRequest = null + ): null | true { + $isApiRequest ??= is_api_request(); + // `null` is actual parameter value for the other two parameters so + // only try filling them up if not passed at all. + $argLen = func_num_args(); + if ($argLen < 2) { + $legacyOnly = get_bool(Request('legacy_only')); + + if ($argLen < 1) { + $user = \Auth::user(); + } + } + + if ($legacyOnly !== null) { + return $legacyOnly ? true : null; + } + + if ($isApiRequest) { + return null; + } + return $user?->userProfileCustomization?->legacy_score_only ?? UserProfileCustomization::DEFAULT_LEGACY_ONLY_ATTRIBUTE ? true : null; @@ -93,9 +116,9 @@ public function setSort(?string $sort): void { switch ($sort) { case 'score_desc': + $sortColumn = $this->isLegacy ? 'legacy_total_score' : 'total_score'; $this->sorts = [ - new Sort('is_legacy', 'asc'), - new Sort('total_score', 'desc'), + new Sort($sortColumn, 'desc'), new Sort('id', 'asc'), ]; break; diff --git a/database/factories/Solo/ScoreFactory.php b/database/factories/Solo/ScoreFactory.php index de75879064f..96be7f78004 100644 --- a/database/factories/Solo/ScoreFactory.php +++ b/database/factories/Solo/ScoreFactory.php @@ -33,6 +33,8 @@ public function definition(): array // depends on all other attributes 'data' => fn (array $attr): array => $this->makeData()($attr), + + 'legacy_total_score' => fn (array $attr): int => isset($attr['legacy_score_id']) ? $attr['total_score'] : 0, ]; } diff --git a/tests/Controllers/BeatmapsControllerSoloScoresTest.php b/tests/Controllers/BeatmapsControllerSoloScoresTest.php index c632fe41952..215e97430e2 100644 --- a/tests/Controllers/BeatmapsControllerSoloScoresTest.php +++ b/tests/Controllers/BeatmapsControllerSoloScoresTest.php @@ -40,104 +40,119 @@ public static function setUpBeforeClass(): void $countryAcronym = static::$user->country_acronym; + $otherUser2 = User::factory()->create(['country_acronym' => Country::factory()]); + $otherUser3SameCountry = User::factory()->create(['country_acronym' => $countryAcronym]); + static::$scores = []; $scoreFactory = SoloScore::factory()->state(['build_id' => 0]); - foreach (['solo' => null, 'legacy' => 1] as $type => $legacyScoreId) { + foreach (['solo' => false, 'legacy' => true] as $type => $isLegacy) { $scoreFactory = $scoreFactory->state([ - 'legacy_score_id' => $legacyScoreId, + 'legacy_score_id' => $isLegacy ? 1 : null, ]); + $makeMods = fn (array $modNames): array => array_map( + fn (string $modName): array => [ + 'acronym' => $modName, + 'settings' => [], + ], + [...$modNames, ...($isLegacy ? ['CL'] : [])], + ); + + $makeTotalScores = fn (int $totalScore): array => [ + 'legacy_total_score' => $totalScore * ($isLegacy ? 1 : 0), + 'total_score' => $totalScore + ($isLegacy ? -1 : 0), + ]; static::$scores = [ ...static::$scores, - "{$type}:user" => $scoreFactory->create([ + "{$type}:userModsLowerScore" => $scoreFactory->withData([ + 'mods' => $makeMods(['DT', 'HD']), + ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1100, 'user_id' => static::$user, ]), - "{$type}:userMods" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['DT', 'HD']), + "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData([ + 'mods' => $makeMods(['NC', 'PF']), ])->create([ + ...$makeTotalScores(1010), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1050, - 'user_id' => static::$user, + 'user_id' => static::$otherUser, ]), - "{$type}:userModsNC" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['NC']), + "{$type}:userMods" => $scoreFactory->withData([ + 'mods' => $makeMods(['DT', 'HD']), ])->create([ + ...$makeTotalScores(1050), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1050, 'user_id' => static::$user, ]), - "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['NC', 'PF']), + "{$type}:userModsNC" => $scoreFactory->withData([ + 'mods' => $makeMods(['NC']), ])->create([ + ...$makeTotalScores(1050), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1010, - 'user_id' => static::$otherUser, + 'user_id' => static::$user, ]), - "{$type}:userModsLowerScore" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['DT', 'HD']), - ])->create([ + "{$type}:user" => $scoreFactory->create([ + ...$makeTotalScores(1100), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, 'user_id' => static::$user, ]), "{$type}:friend" => $scoreFactory->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, 'user_id' => $friend, ]), // With preference mods "{$type}:otherUser" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['PF']), + 'mods' => $makeMods(['PF']), ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, 'user_id' => static::$otherUser, ]), "{$type}:otherUserMods" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['HD', 'PF', 'NC']), + 'mods' => $makeMods(['HD', 'PF', 'NC']), ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, 'user_id' => static::$otherUser, ]), "{$type}:otherUserModsExtraNonPreferences" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['DT', 'HD', 'HR']), + 'mods' => $makeMods(['DT', 'HD', 'HR']), ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, 'user_id' => static::$otherUser, ]), "{$type}:otherUserModsUnrelated" => $scoreFactory->withData([ - 'mods' => static::defaultMods(['FL']), + 'mods' => $makeMods(['FL']), ])->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, 'user_id' => static::$otherUser, ]), // Same total score but achieved later so it should come up after earlier score "{$type}:otherUser2Later" => $scoreFactory->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, - 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]), + 'user_id' => $otherUser2, ]), "{$type}:otherUser3SameCountry" => $scoreFactory->create([ + ...$makeTotalScores(1000), 'beatmap_id' => static::$beatmap, 'preserve' => true, - 'total_score' => 1000, - 'user_id' => User::factory()->state(['country_acronym' => $countryAcronym]), + 'user_id' => $otherUser3SameCountry, ]), // Non-preserved score should be filtered out "{$type}:nonPreserved" => $scoreFactory->create([ @@ -181,25 +196,14 @@ public static function tearDownAfterClass(): void }); } - private static function defaultMods(array $modNames): array - { - return array_map( - fn ($modName) => [ - 'acronym' => $modName, - 'settings' => [], - ], - $modNames, - ); - } - /** * @dataProvider dataProviderForTestQuery * @group RequiresScoreIndexer */ - public function testQuery(array $scoreKeys, array $params) + public function testQuery(array $scoreKeys, array $params, string $route) { $resp = $this->actingAs(static::$user) - ->json('GET', route('beatmaps.solo-scores', static::$beatmap), $params) + ->json('GET', route("beatmaps.{$route}", static::$beatmap), $params) ->assertSuccessful(); $json = json_decode($resp->getContent(), true); @@ -211,44 +215,49 @@ public function testQuery(array $scoreKeys, array $params) public static function dataProviderForTestQuery(): array { - return [ - 'no parameters' => [[ - 'solo:user', - 'solo:otherUserModsNCPFHigherScore', - 'solo:friend', - 'solo:otherUser2Later', - 'solo:otherUser3SameCountry', - ], []], - 'by country' => [[ - 'solo:user', - 'solo:otherUser3SameCountry', - ], ['type' => 'country']], - 'by friend' => [[ - 'solo:user', - 'solo:friend', - ], ['type' => 'friend']], - 'mods filter' => [[ - 'solo:userMods', - 'solo:otherUserMods', - ], ['mods' => ['DT', 'HD']]], - 'mods with implied filter' => [[ - 'solo:userModsNC', - 'solo:otherUserModsNCPFHigherScore', - ], ['mods' => ['NC']]], - 'mods with nomods' => [[ - 'solo:user', - 'solo:otherUserModsNCPFHigherScore', - 'solo:friend', - 'solo:otherUser2Later', - 'solo:otherUser3SameCountry', - ], ['mods' => ['NC', 'NM']]], - 'nomods filter' => [[ - 'solo:user', - 'solo:friend', - 'solo:otherUser', - 'solo:otherUser2Later', - 'solo:otherUser3SameCountry', - ], ['mods' => ['NM']]], - ]; + $ret = []; + foreach (['solo' => 'solo-scores', 'legacy' => 'scores'] as $type => $route) { + $ret = array_merge($ret, [ + "{$type}: no parameters" => [[ + "{$type}:user", + "{$type}:otherUserModsNCPFHigherScore", + "{$type}:friend", + "{$type}:otherUser2Later", + "{$type}:otherUser3SameCountry", + ], [], $route], + "{$type}: by country" => [[ + "{$type}:user", + "{$type}:otherUser3SameCountry", + ], ['type' => 'country'], $route], + "{$type}: by friend" => [[ + "{$type}:user", + "{$type}:friend", + ], ['type' => 'friend'], $route], + "{$type}: mods filter" => [[ + "{$type}:userMods", + "{$type}:otherUserMods", + ], ['mods' => ['DT', 'HD']], $route], + "{$type}: mods with implied filter" => [[ + "{$type}:userModsNC", + "{$type}:otherUserModsNCPFHigherScore", + ], ['mods' => ['NC']], $route], + "{$type}: mods with nomods" => [[ + "{$type}:user", + "{$type}:otherUserModsNCPFHigherScore", + "{$type}:friend", + "{$type}:otherUser2Later", + "{$type}:otherUser3SameCountry", + ], ['mods' => ['NC', 'NM']], $route], + "{$type}: nomods filter" => [[ + "{$type}:user", + "{$type}:friend", + "{$type}:otherUser", + "{$type}:otherUser2Later", + "{$type}:otherUser3SameCountry", + ], ['mods' => ['NM']], $route], + ]); + } + + return $ret; } } diff --git a/tests/Controllers/BeatmapsControllerTest.php b/tests/Controllers/BeatmapsControllerTest.php index 629272e79fd..17e97270780 100644 --- a/tests/Controllers/BeatmapsControllerTest.php +++ b/tests/Controllers/BeatmapsControllerTest.php @@ -10,12 +10,8 @@ use App\Models\Beatmap; use App\Models\Beatmapset; use App\Models\BeatmapsetEvent; -use App\Models\Country; -use App\Models\Score\Best\Model as ScoreBest; use App\Models\User; -use App\Models\UserRelation; use Illuminate\Testing\Fluent\AssertableJson; -use Illuminate\Testing\TestResponse; use Tests\TestCase; class BeatmapsControllerTest extends TestCase @@ -106,7 +102,7 @@ public function testInvalidMode() { $this->json('GET', route('beatmaps.scores', $this->beatmap), [ 'mode' => 'nope', - ])->assertStatus(404); + ])->assertStatus(422); } /** @@ -177,261 +173,6 @@ public function testScoresNonGeneralSupporter() ])->assertStatus(200); } - public function testScores() - { - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1100, - 'user_id' => $this->user, - ]), - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1000, - ]), - // Same total score but achieved later so it should come up after earlier score - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1000, - ]), - ]; - // Hidden score should be filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'hidden' => true, - 'score' => 800, - ]); - // Another score from scores[0] user (should be filtered out) - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 800, - 'user_id' => $this->user, - ]); - // Unrelated score - ScoreBest::getClass(array_rand(Beatmap::MODES))::factory()->create(); - - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', $this->beatmap)) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresByCountry() - { - $countryAcronym = $this->user->country_acronym; - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'country_acronym' => $countryAcronym, - 'score' => 1100, - 'user_id' => $this->user, - ]), - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1000, - 'country_acronym' => $countryAcronym, - 'user_id' => User::factory()->state(['country_acronym' => $countryAcronym]), - ]), - ]; - $otherCountry = Country::factory()->create(); - $otherCountryAcronym = $otherCountry->acronym; - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'country_acronym' => $otherCountryAcronym, - 'user_id' => User::factory()->state(['country_acronym' => $otherCountryAcronym]), - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'type' => 'country'])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresByFriend() - { - $friend = User::factory()->create(); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1100, - 'user_id' => $friend, - ]), - // Own score is included - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1000, - 'user_id' => $this->user, - ]), - ]; - UserRelation::create([ - 'friend' => true, - 'user_id' => $this->user->getKey(), - 'zebra_id' => $friend->getKey(), - ]); - // Non-friend score is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'type' => 'friend'])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresModsFilter() - { - $modsHelper = app('mods'); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD']), - 'score' => 1500, - ]), - // Score with preference mods is included - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD', 'NC', 'PF']), - 'score' => 1100, - 'user_id' => $this->user, - ]), - ]; - // No mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => 0, - ]); - // Unrelated mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['FL']), - ]); - // Extra non-preference mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD', 'HR']), - ]); - // From same user but lower score is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'HD']), - 'score' => 1000, - 'user_id' => $this->user, - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['DT', 'HD']])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresModsWithImpliedFilter() - { - $modsHelper = app('mods'); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC']), - 'score' => 1500, - ]), - // Score with preference mods is included - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC', 'PF']), - 'score' => 1100, - 'user_id' => $this->user, - ]), - ]; - // No mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => 0, - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['NC']])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresModsWithNomodsFilter() - { - $modsHelper = app('mods'); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC']), - 'score' => 1500, - ]), - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => 0, - 'score' => 1100, - 'user_id' => $this->user, - ]), - ]; - // With unrelated mod - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT', 'NC', 'HD']), - 'score' => 1500, - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['DT', 'NC', 'NM']])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - - public function testScoresNomodsFilter() - { - $modsHelper = app('mods'); - $scoreClass = ScoreBest::getClassByRulesetId($this->beatmap->playmode); - $scores = [ - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1500, - 'enabled_mods' => 0, - ]), - // Preference mod is included - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'score' => 1100, - 'user_id' => $this->user, - 'enabled_mods' => $modsHelper->idsToBitset(['PF']), - ]), - ]; - // Non-preference mod is filtered out - $scoreClass::factory()->create([ - 'beatmap_id' => $this->beatmap, - 'enabled_mods' => $modsHelper->idsToBitset(['DT']), - ]); - - $this->user->update(['osu_subscriber' => true]); - $resp = $this->actingAs($this->user) - ->json('GET', route('beatmaps.scores', ['beatmap' => $this->beatmap, 'mods' => ['NM']])) - ->assertSuccessful(); - - $this->assertSameScoresFromResponse($scores, $resp); - } - public function testShowForApi() { $beatmap = Beatmap::factory()->create(); @@ -621,15 +362,6 @@ protected function setUp(): void $this->beatmap = Beatmap::factory()->qualified()->create(); } - private function assertSameScoresFromResponse(array $scores, TestResponse $response): void - { - $json = json_decode($response->getContent(), true); - $this->assertSame(count($scores), count($json['scores'])); - foreach ($json['scores'] as $i => $jsonScore) { - $this->assertSame($scores[$i]->getKey(), $jsonScore['id']); - } - } - private function createExistingFruitsBeatmap() { return Beatmap::factory()->create([ From e42d84aa69a061bea813fc0dbd3245664064ac57 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 25 Jan 2024 21:25:40 +0900 Subject: [PATCH 177/203] Reset cache on navigation Otherwise it'll have unbounded growth --- resources/js/utils/legacy-score-helper.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/resources/js/utils/legacy-score-helper.ts b/resources/js/utils/legacy-score-helper.ts index 177302f4de7..876c833c81a 100644 --- a/resources/js/utils/legacy-score-helper.ts +++ b/resources/js/utils/legacy-score-helper.ts @@ -8,7 +8,12 @@ interface CacheEntry { accuracy: number; rank: Rank; } -const cache: Partial> = {}; +let cache: Partial> = {}; + +// reset cache on navigation +document.addEventListener('turbolinks:load', () => { + cache = {}; +}); function shouldHaveHiddenRank(score: SoloScoreJson) { return score.mods.some((mod) => mod.acronym === 'FL' || mod.acronym === 'HD'); From 892f0462590a37cf722e169dd2da9d7ebb2b7974 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 25 Jan 2024 22:47:03 +0900 Subject: [PATCH 178/203] Limit test to legacy scores for now --- tests/Controllers/BeatmapsControllerSoloScoresTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Controllers/BeatmapsControllerSoloScoresTest.php b/tests/Controllers/BeatmapsControllerSoloScoresTest.php index 5e5824dbded..3541c903609 100644 --- a/tests/Controllers/BeatmapsControllerSoloScoresTest.php +++ b/tests/Controllers/BeatmapsControllerSoloScoresTest.php @@ -219,6 +219,7 @@ public function testUserScore() { $url = route('api.beatmaps.user.score', [ 'beatmap' => static::$beatmap->getKey(), + 'legacy_only' => 1, 'mods' => ['DT', 'HD'], 'user' => static::$user->getKey(), ]); @@ -235,6 +236,7 @@ public function testUserScoreAll() { $url = route('api.beatmaps.user.scores', [ 'beatmap' => static::$beatmap->getKey(), + 'legacy_only' => 1, 'user' => static::$user->getKey(), ]); $this->actAsScopedUser(static::$user); From 1a3986d79c4de578cbfb13c19433c7de6c57b276 Mon Sep 17 00:00:00 2001 From: nanaya Date: Thu, 25 Jan 2024 23:20:44 +0900 Subject: [PATCH 179/203] Add reference url --- resources/js/utils/legacy-score-helper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/js/utils/legacy-score-helper.ts b/resources/js/utils/legacy-score-helper.ts index 876c833c81a..937c3bb83c7 100644 --- a/resources/js/utils/legacy-score-helper.ts +++ b/resources/js/utils/legacy-score-helper.ts @@ -30,6 +30,7 @@ export function legacyAccuracyAndRank(score: SoloScoreJson) { let accuracy: number; let rank: Rank; + // Reference: https://github.com/ppy/osu/blob/e3ffea1b127cbd3171010972588a8b07cf049ba0/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs#L170-L274 switch (score.ruleset_id) { // osu case 0: { From 6502da34d8f6fdbc9d3c046e7deddb134335157c Mon Sep 17 00:00:00 2001 From: nanaya Date: Fri, 26 Jan 2024 05:53:11 +0900 Subject: [PATCH 180/203] Add option to disable score submission --- .env.example | 1 + app/Http/Controllers/ScoreTokensController.php | 4 ++++ config/osu.php | 2 ++ 3 files changed, 7 insertions(+) diff --git a/.env.example b/.env.example index a6b68b781f0..de3aaeb1683 100644 --- a/.env.example +++ b/.env.example @@ -307,6 +307,7 @@ CLIENT_CHECK_VERSION=false # SCORES_ES_CACHE_DURATION= # SCORES_EXPERIMENTAL_RANK_AS_DEFAULT=false # SCORES_EXPERIMENTAL_RANK_AS_EXTRA=false +# SCORES_SUBMISSION_ENABLED=1 # SCORES_RANK_CACHE_LOCAL_SERVER=0 # SCORES_RANK_CACHE_MIN_USERS=35000 # SCORES_RANK_CACHE_SERVER_URL= diff --git a/app/Http/Controllers/ScoreTokensController.php b/app/Http/Controllers/ScoreTokensController.php index b746f9f113b..7cfbcffc78d 100644 --- a/app/Http/Controllers/ScoreTokensController.php +++ b/app/Http/Controllers/ScoreTokensController.php @@ -22,6 +22,10 @@ public function __construct() public function store($beatmapId) { + if (!$GLOBALS['cfg']['osu']['scores']['submission_enabled']) { + abort(422, 'score submission is disabled'); + } + $beatmap = Beatmap::increasesStatistics()->findOrFail($beatmapId); $user = auth()->user(); $rawParams = request()->all(); diff --git a/config/osu.php b/config/osu.php index 33af7ae8d4a..653053bd9f3 100644 --- a/config/osu.php +++ b/config/osu.php @@ -172,6 +172,8 @@ 'es_cache_duration' => 60 * (get_float(env('SCORES_ES_CACHE_DURATION')) ?? 0.5), // in minutes, converted to seconds 'experimental_rank_as_default' => get_bool(env('SCORES_EXPERIMENTAL_RANK_AS_DEFAULT')) ?? false, 'experimental_rank_as_extra' => get_bool(env('SCORES_EXPERIMENTAL_RANK_AS_EXTRA')) ?? false, + 'submission_enabled' => get_bool(env('SCORES_SUBMISSION_ENABLED')) ?? true, + 'rank_cache' => [ 'local_server' => get_bool(env('SCORES_RANK_CACHE_LOCAL_SERVER')) ?? false, 'min_users' => get_int(env('SCORES_RANK_CACHE_MIN_USERS')) ?? 35000, From 006687971e2ea3377d6cbb6de346c1e725cae317 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 26 Jan 2024 18:40:00 +0900 Subject: [PATCH 181/203] skip destructuring --- .../beatmap-discussions/discussions-state.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/resources/js/beatmap-discussions/discussions-state.ts b/resources/js/beatmap-discussions/discussions-state.ts index de3abe2de22..c4ebe5df34c 100644 --- a/resources/js/beatmap-discussions/discussions-state.ts +++ b/resources/js/beatmap-discussions/discussions-state.ts @@ -417,22 +417,16 @@ export default class DiscussionsState { @action update(options: Partial) { - const { - beatmap_discussion_post_ids, - beatmapset, - watching, - } = options; - - if (beatmap_discussion_post_ids != null) { - this.markAsRead(beatmap_discussion_post_ids); + if (options.beatmap_discussion_post_ids != null) { + this.markAsRead(options.beatmap_discussion_post_ids); } - if (beatmapset != null) { - this.store.beatmapset = beatmapset; + if (options.beatmapset != null) { + this.store.beatmapset = options.beatmapset; } - if (watching != null) { - this.beatmapset.current_user_attributes.is_watching = watching; + if (options.watching != null) { + this.beatmapset.current_user_attributes.is_watching = options.watching; } } } From fc7f00750ad668f91b015b341fc2ab322d1821e1 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sat, 27 Jan 2024 00:51:25 +0900 Subject: [PATCH 182/203] Update translations from crowdin --- resources/lang/ar/password_reset.php | 3 +++ resources/lang/be/password_reset.php | 3 +++ resources/lang/bg/password_reset.php | 3 +++ resources/lang/ca/password_reset.php | 3 +++ resources/lang/cs/password_reset.php | 3 +++ resources/lang/da/password_reset.php | 3 +++ resources/lang/de/password_reset.php | 3 +++ resources/lang/el/password_reset.php | 3 +++ resources/lang/es/password_reset.php | 3 +++ resources/lang/fa-IR/password_reset.php | 3 +++ resources/lang/fi/beatmaps.php | 2 +- resources/lang/fi/beatmapsets.php | 2 +- resources/lang/fi/community.php | 6 +++--- resources/lang/fi/layout.php | 2 +- resources/lang/fi/password_reset.php | 3 +++ resources/lang/fi/users.php | 2 +- resources/lang/fil/legacy_api_key.php | 4 ++-- resources/lang/fil/legacy_irc_key.php | 20 ++++++++++---------- resources/lang/fil/password_reset.php | 3 +++ resources/lang/fil/store.php | 4 ++-- resources/lang/fr/chat.php | 4 ++-- resources/lang/fr/password_reset.php | 3 +++ resources/lang/he/password_reset.php | 3 +++ resources/lang/hr-HR/password_reset.php | 3 +++ resources/lang/hu/password_reset.php | 3 +++ resources/lang/id/artist.php | 2 +- resources/lang/id/authorization.php | 2 +- resources/lang/id/beatmap_discussions.php | 2 +- resources/lang/id/follows.php | 2 +- resources/lang/id/forum.php | 2 +- resources/lang/id/home.php | 2 +- resources/lang/id/legacy_api_key.php | 2 +- resources/lang/id/notifications.php | 14 +++++++------- resources/lang/id/password_reset.php | 3 +++ resources/lang/id/store.php | 6 +++--- resources/lang/id/wiki.php | 2 +- resources/lang/it/password_reset.php | 3 +++ resources/lang/it/store.php | 2 +- resources/lang/ja/password_reset.php | 3 +++ resources/lang/kk-KZ/password_reset.php | 3 +++ resources/lang/ko/password_reset.php | 3 +++ resources/lang/lt/password_reset.php | 3 +++ resources/lang/lv-LV/password_reset.php | 3 +++ resources/lang/ms-MY/password_reset.php | 3 +++ resources/lang/nl/password_reset.php | 3 +++ resources/lang/no/password_reset.php | 3 +++ resources/lang/pl/password_reset.php | 3 +++ resources/lang/pt-br/password_reset.php | 3 +++ resources/lang/pt/password_reset.php | 3 +++ resources/lang/ro/beatmapset_events.php | 4 ++-- resources/lang/ro/beatmapsets.php | 4 ++-- resources/lang/ro/password_reset.php | 3 +++ resources/lang/ru/password_reset.php | 3 +++ resources/lang/ru/store.php | 2 +- resources/lang/si-LK/password_reset.php | 3 +++ resources/lang/sk/password_reset.php | 3 +++ resources/lang/sl/password_reset.php | 3 +++ resources/lang/sr/password_reset.php | 3 +++ resources/lang/sv/password_reset.php | 3 +++ resources/lang/tg-TJ/password_reset.php | 3 +++ resources/lang/tg-TJ/scores.php | 2 +- resources/lang/tg-TJ/store.php | 14 +++++++------- resources/lang/tg-TJ/supporter_tag.php | 6 +++--- resources/lang/th/password_reset.php | 3 +++ resources/lang/tr/accounts.php | 8 ++++---- resources/lang/tr/notifications.php | 6 +++--- resources/lang/tr/password_reset.php | 3 +++ resources/lang/tr/store.php | 10 +++++----- resources/lang/uk/password_reset.php | 3 +++ resources/lang/vi/password_reset.php | 3 +++ resources/lang/zh-tw/password_reset.php | 3 +++ resources/lang/zh/password_reset.php | 3 +++ 72 files changed, 199 insertions(+), 70 deletions(-) diff --git a/resources/lang/ar/password_reset.php b/resources/lang/ar/password_reset.php index 53eeb12b106..923e67f01da 100644 --- a/resources/lang/ar/password_reset.php +++ b/resources/lang/ar/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'أدخل اسم المستخدم أو عنوان البريد الإلكتروني', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'تحتاج دعم في المستقبل؟ تواصل معنا على :button.', 'button' => 'نظام الدعم', diff --git a/resources/lang/be/password_reset.php b/resources/lang/be/password_reset.php index 4356149825a..7a34748971a 100644 --- a/resources/lang/be/password_reset.php +++ b/resources/lang/be/password_reset.php @@ -37,6 +37,9 @@ 'starting' => [ 'username' => 'Увядзіце эл. пошту або імя карыстальніка', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Патрэбна дадатковая дапамога? Звяжыцеся з намі :button.', 'button' => 'сістэма падтрымкі', diff --git a/resources/lang/bg/password_reset.php b/resources/lang/bg/password_reset.php index 85e741061aa..029af01dc8a 100644 --- a/resources/lang/bg/password_reset.php +++ b/resources/lang/bg/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Въведете имейл или потребителско име', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Нуждаете се от допълнителна помощ? Свържете се с нашата :button.', 'button' => 'поддръжка', diff --git a/resources/lang/ca/password_reset.php b/resources/lang/ca/password_reset.php index 2dcb3c6da42..5a3fe4ca034 100644 --- a/resources/lang/ca/password_reset.php +++ b/resources/lang/ca/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Adreça electrònica o nom d\'usuari', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Necessites més assistència? Contacta\'ns a través del nostre :button.', 'button' => 'sistema de suport', diff --git a/resources/lang/cs/password_reset.php b/resources/lang/cs/password_reset.php index f7bcc132a84..a6b18ca1eb1 100644 --- a/resources/lang/cs/password_reset.php +++ b/resources/lang/cs/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Zadejte Vaši e-mailovou adresu nebo uživatelské jméno', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Potřebujete další pomoc? Kontaktujte nás prostřednictvím :button.', 'button' => 'systém podpory', diff --git a/resources/lang/da/password_reset.php b/resources/lang/da/password_reset.php index b7baef2d069..e8d33329c42 100644 --- a/resources/lang/da/password_reset.php +++ b/resources/lang/da/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Indtast email-adresse eller brugernavn', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Har du brug for yderligere assistance? Kontakt os via vores :button.', 'button' => 'support system', diff --git a/resources/lang/de/password_reset.php b/resources/lang/de/password_reset.php index e160e18b947..c900c05bd06 100644 --- a/resources/lang/de/password_reset.php +++ b/resources/lang/de/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Benutzername oder E-Mail eingeben', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Benötigst du weitere Hilfe? Kontaktiere uns über unser :button.', 'button' => 'Supportsystem', diff --git a/resources/lang/el/password_reset.php b/resources/lang/el/password_reset.php index f35077e7d7e..44a0c46afc4 100644 --- a/resources/lang/el/password_reset.php +++ b/resources/lang/el/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Εισάγετε τη διεύθυνση ηλεκτρονικού ταχυδρομείου ή το όνομα χρήστη', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Χρειάζεστε περαιτέρω βοήθεια? Επικοινωνήστε μαζί μας μέσω :button.', 'button' => 'σύστημα υποστήριξης', diff --git a/resources/lang/es/password_reset.php b/resources/lang/es/password_reset.php index 1e261ad1362..7b1a4fbf5dd 100644 --- a/resources/lang/es/password_reset.php +++ b/resources/lang/es/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Ingrese correo o nombre de usuario', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '¿Necesita asistencia? Contáctenos a través de nuestro :button.', 'button' => 'sistema de soporte', diff --git a/resources/lang/fa-IR/password_reset.php b/resources/lang/fa-IR/password_reset.php index ece9d05648a..5fd9ed7fcbb 100644 --- a/resources/lang/fa-IR/password_reset.php +++ b/resources/lang/fa-IR/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'ایمیل یا نام کاربری را وارد کتید', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'نیاز به کمک بیشتر دارید؟ با ما توسط :button تماس بگیرید.', 'button' => 'سیستم پشتیبانی', diff --git a/resources/lang/fi/beatmaps.php b/resources/lang/fi/beatmaps.php index 87c1ab69a9d..2211fd1edcc 100644 --- a/resources/lang/fi/beatmaps.php +++ b/resources/lang/fi/beatmaps.php @@ -293,7 +293,7 @@ 'pending' => 'Vireillä', 'wip' => 'Työn alla', 'qualified' => 'Kelpuutettu', - 'ranked' => 'Pisteytetty', + 'ranked' => 'Rankattu', ], 'genre' => [ 'any' => 'Kaikki', diff --git a/resources/lang/fi/beatmapsets.php b/resources/lang/fi/beatmapsets.php index e5c088fc4cd..d0f22c69046 100644 --- a/resources/lang/fi/beatmapsets.php +++ b/resources/lang/fi/beatmapsets.php @@ -185,7 +185,7 @@ 'friend' => 'Kukaan kavereistasi ei vielä ole saanut tulosta tässä mapissa!', 'global' => 'Tuloksia ei ole. Voisit hankkia niitä.', 'loading' => 'Ladataan tuloksia...', - 'unranked' => 'Pisteyttämätön rytmikartta.', + 'unranked' => 'Rankkaamaton rytmikartta.', ], 'score' => [ 'first' => 'Johdossa', diff --git a/resources/lang/fi/community.php b/resources/lang/fi/community.php index 8de5c09f624..9162986bd85 100644 --- a/resources/lang/fi/community.php +++ b/resources/lang/fi/community.php @@ -32,12 +32,12 @@ 'description' => 'Lahjoituksesi auttavat pitämään pelin itsenäisenä ja täysin vapaana mainoksista ja ulkopuolisista sponsoreista.', ], 'tournaments' => [ - 'title' => 'Virallisiin turnauksiin', - 'description' => 'Auta osu! maailmancup -turnausten ylläpidon (sekä palkintojen) rahoituksessa.', + 'title' => 'Viralliset turnaukset', + 'description' => 'Auta osu!-maailmancup -turnausten ylläpidon (sekä palkintojen) rahoituksessa.', 'link_text' => 'Selaa turnauksia »', ], 'bounty-program' => [ - 'title' => 'Avoimen lähdekoodin palkkio -ohjelmaan', + 'title' => 'Avoimen lähdekoodin palkkio -ohjelma', 'description' => 'Tue yhteisön osallistujia, jotka ovat käyttäneet aikaansa ja vaivaansa tekemään osu!sta paremman.', 'link_text' => 'Lue lisää »', ], diff --git a/resources/lang/fi/layout.php b/resources/lang/fi/layout.php index 9fc19b95199..e6369bc3541 100644 --- a/resources/lang/fi/layout.php +++ b/resources/lang/fi/layout.php @@ -75,7 +75,7 @@ ], 'help' => [ '_' => 'apua', - 'getAbuse' => 'ilmoita häirinnästä', + 'getAbuse' => 'ilmoita väärinkäytöstä', 'getFaq' => 'usein kysytyt', 'getRules' => 'säännöt', 'getSupport' => 'tarvitsen siis oikeasti apua!', diff --git a/resources/lang/fi/password_reset.php b/resources/lang/fi/password_reset.php index d6789d54c55..78ff6802e42 100644 --- a/resources/lang/fi/password_reset.php +++ b/resources/lang/fi/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Anna sähköposti tai käyttäjänimi', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Tarvitsetko lisäapua? Ota yhteyttä meihin: :button.', 'button' => 'tukijärjestelmä', diff --git a/resources/lang/fi/users.php b/resources/lang/fi/users.php index c8557653dbf..b4abe01f160 100644 --- a/resources/lang/fi/users.php +++ b/resources/lang/fi/users.php @@ -159,7 +159,7 @@ ], 'options' => [ - 'cheating' => 'Väärin pelaaminen / Huijaaminen', + 'cheating' => 'Huijaaminen', 'multiple_accounts' => 'Käyttää useita tilejä', 'insults' => 'Loukkaa minua / muita', 'spam' => 'Spämmii', diff --git a/resources/lang/fil/legacy_api_key.php b/resources/lang/fil/legacy_api_key.php index 562fed20b55..55398e7efb3 100644 --- a/resources/lang/fil/legacy_api_key.php +++ b/resources/lang/fil/legacy_api_key.php @@ -4,8 +4,8 @@ // See the LICENCE file in the repository root for full licence text. return [ - 'new' => '', - 'none' => '', + 'new' => 'Bagong Legacy API Key', + 'none' => 'Walang key.', 'docs' => [ '_' => '', diff --git a/resources/lang/fil/legacy_irc_key.php b/resources/lang/fil/legacy_irc_key.php index 412fea986d8..e37bb3bb568 100644 --- a/resources/lang/fil/legacy_irc_key.php +++ b/resources/lang/fil/legacy_irc_key.php @@ -4,20 +4,20 @@ // See the LICENCE file in the repository root for full licence text. return [ - 'confirm_new' => '', - 'new' => '', - 'none' => '', + 'confirm_new' => 'Gumawa ng bagong IRC password?', + 'new' => 'Bagong Legacy IRC Password', + 'none' => 'Hindi na-set ang IRC Password.', 'form' => [ - 'server_host' => '', - 'server_port' => '', - 'token' => '', - 'username' => '', + 'server_host' => 'server', + 'server_port' => 'port', + 'token' => 'server password', + 'username' => 'username', ], 'view' => [ - 'hide' => '', - 'show' => '', - 'delete' => '', + 'hide' => 'Itago ang password', + 'show' => 'Ipakita ang password', + 'delete' => 'Burahin', ], ]; diff --git a/resources/lang/fil/password_reset.php b/resources/lang/fil/password_reset.php index 397217e31d8..66101d3eeef 100644 --- a/resources/lang/fil/password_reset.php +++ b/resources/lang/fil/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Itala ang email address o username', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Kailangan pa ng tulong? Makipag-usap sa amin sa :button.', 'button' => 'support system', diff --git a/resources/lang/fil/store.php b/resources/lang/fil/store.php index 8784207c02c..97c88a996a4 100644 --- a/resources/lang/fil/store.php +++ b/resources/lang/fil/store.php @@ -6,7 +6,7 @@ return [ 'cart' => [ 'checkout' => 'Checkout', - 'empty_cart' => '', + 'empty_cart' => 'Tanggalin lahat ng items sa cart', 'info' => ':count_delimited pirasong item sa kariton ($:subtotal)|:count_delimited pirasong mga item sa kariton ($:subtotal)', 'more_goodies' => 'Gusto kong tingnan ang higit pang mga goodies bago makumpleto ang order', 'shipping_fees' => 'mga bayarin sa pagpapadala', @@ -49,7 +49,7 @@ ], 'discount' => 'makatipid ng :percent%', - 'free' => '', + 'free' => 'free!', 'invoice' => [ 'contact' => '', diff --git a/resources/lang/fr/chat.php b/resources/lang/fr/chat.php index a21442c8690..32562116111 100644 --- a/resources/lang/fr/chat.php +++ b/resources/lang/fr/chat.php @@ -23,7 +23,7 @@ 'title' => [ 'ANNOUNCE' => 'Annonces', 'GROUP' => 'Groupes', - 'PM' => 'Messages directs', + 'PM' => 'Messages privés', 'PUBLIC' => 'Canaux', ], ], @@ -49,7 +49,7 @@ 'input' => [ 'create' => 'Créer', - 'disabled' => 'impossible d\'envoyer un message...', + 'disabled' => 'impossible d’envoyer le message...', 'disconnected' => 'Déconnecté', 'placeholder' => 'saisissez votre message...', 'send' => 'Envoyer', diff --git a/resources/lang/fr/password_reset.php b/resources/lang/fr/password_reset.php index 722a851e7b2..7260190d31b 100644 --- a/resources/lang/fr/password_reset.php +++ b/resources/lang/fr/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Entrez une adresse e-mail ou un nom d\'utilisateur', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Vous avez besoin d\'aide supplémentaire ? Contactez-nous via notre :button.', 'button' => 'système de support', diff --git a/resources/lang/he/password_reset.php b/resources/lang/he/password_reset.php index 041f953221e..a609260b7eb 100644 --- a/resources/lang/he/password_reset.php +++ b/resources/lang/he/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'הכנס אימייל או שם משתמש', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'צריך עזרה נוספת? צור איתנו קשר דרך ה:button שלנו.', 'button' => 'מערכת תמיכה', diff --git a/resources/lang/hr-HR/password_reset.php b/resources/lang/hr-HR/password_reset.php index 3fa5e25c982..e49e2c9cf47 100644 --- a/resources/lang/hr-HR/password_reset.php +++ b/resources/lang/hr-HR/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Unesi svoju adresu e-pošte ili korisničko ime', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Trebaš dodatnu pomoć? Kontaktiraj nas putem naše :button.', 'button' => 'sistema za podršku', diff --git a/resources/lang/hu/password_reset.php b/resources/lang/hu/password_reset.php index af35a9391e4..3c925c3e705 100644 --- a/resources/lang/hu/password_reset.php +++ b/resources/lang/hu/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Add meg az e-mail címed vagy felhasználóneved', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Segítség kéne? Lépj kapcsolatba velünk itt :botton.', 'button' => 'támogatói rendszer', diff --git a/resources/lang/id/artist.php b/resources/lang/id/artist.php index 8b024e3d69d..f5643577d2f 100644 --- a/resources/lang/id/artist.php +++ b/resources/lang/id/artist.php @@ -4,7 +4,7 @@ // See the LICENCE file in the repository root for full licence text. return [ - 'page_description' => 'Featured artist di osu!', + 'page_description' => 'Featured Artist di osu!', 'title' => 'Featured Artist', 'admin' => [ diff --git a/resources/lang/id/authorization.php b/resources/lang/id/authorization.php index af0c4695da9..44d02290fa0 100644 --- a/resources/lang/id/authorization.php +++ b/resources/lang/id/authorization.php @@ -48,7 +48,7 @@ 'edit' => [ 'not_owner' => 'Hanya pemilik topik yang diperbolehkan untuk menyunting kiriman.', 'resolved' => 'Kamu tidak dapat menyunting postingan pada topik diskusi yang telah terjawab.', - 'system_generated' => 'Post yang dihasilkan secara otomatis tidak dapat disunting.', + 'system_generated' => 'Postingan yang dihasilkan secara otomatis tidak dapat disunting.', ], ], diff --git a/resources/lang/id/beatmap_discussions.php b/resources/lang/id/beatmap_discussions.php index b9d4b56d3d0..ab8b15fc175 100644 --- a/resources/lang/id/beatmap_discussions.php +++ b/resources/lang/id/beatmap_discussions.php @@ -13,7 +13,7 @@ ], 'events' => [ - 'empty' => 'Belum ada hal apapun yang terjadi... hingga saat ini.', + 'empty' => 'Belum ada hal apa pun yang terjadi... hingga saat ini.', ], 'index' => [ diff --git a/resources/lang/id/follows.php b/resources/lang/id/follows.php index b6884672293..26ae0593d0f 100644 --- a/resources/lang/id/follows.php +++ b/resources/lang/id/follows.php @@ -24,7 +24,7 @@ ], 'mapping' => [ - 'empty' => 'Kamu tidak sedang mengikuti siapapun.', + 'empty' => 'Kamu tidak sedang mengikuti siapa pun.', 'followers' => 'pengikut mapping', 'page_title' => 'mapper yang diikuti', 'title' => 'mapper', diff --git a/resources/lang/id/forum.php b/resources/lang/id/forum.php index 86d7ef75eca..f695440ae63 100644 --- a/resources/lang/id/forum.php +++ b/resources/lang/id/forum.php @@ -33,7 +33,7 @@ ], 'topics' => [ - 'empty' => 'Tidak ada topik!', + 'empty' => 'Tidak ada topik apa pun di sini!', ], ], diff --git a/resources/lang/id/home.php b/resources/lang/id/home.php index f40930047cd..f5418e317c2 100644 --- a/resources/lang/id/home.php +++ b/resources/lang/id/home.php @@ -7,7 +7,7 @@ 'landing' => [ 'download' => 'Unduh sekarang', 'online' => 'dengan :players pemain yang saat ini terhubung dalam :games ruang permainan', - 'peak' => 'Jumlah pengguna online terbanyak: :count', + 'peak' => 'Puncak aktivitas: :count pengguna online', 'players' => ':count pengguna terdaftar', 'title' => 'selamat datang', 'see_more_news' => 'lihat lebih banyak berita', diff --git a/resources/lang/id/legacy_api_key.php b/resources/lang/id/legacy_api_key.php index 24f7bc40a20..51f7a1d7256 100644 --- a/resources/lang/id/legacy_api_key.php +++ b/resources/lang/id/legacy_api_key.php @@ -23,7 +23,7 @@ ], 'warning' => [ - 'line1' => 'Jangan berikan informasi ini pada siapapun.', + 'line1' => 'Jangan berikan informasi ini kepada siapa pun.', 'line2' => "Ini sama halnya membagikan akunmu pada yang lain.", 'line3' => 'Harap untuk tidak membagikan informasi ini.', ], diff --git a/resources/lang/id/notifications.php b/resources/lang/id/notifications.php index c882bfa332a..6d15bd5a6b8 100644 --- a/resources/lang/id/notifications.php +++ b/resources/lang/id/notifications.php @@ -45,15 +45,15 @@ 'beatmapset_discussion' => [ '_' => 'Laman diskusi beatmap', - 'beatmapset_discussion_lock' => 'Diskusi untuk beatmap ":title" telah ditutup.', + 'beatmapset_discussion_lock' => 'Diskusi pada beatmap ":title" telah dikunci', 'beatmapset_discussion_lock_compact' => 'Diskusi beatmap telah dikunci', 'beatmapset_discussion_post_new' => 'Postingan baru pada ":title" oleh :username: ":content"', 'beatmapset_discussion_post_new_empty' => 'Postingan baru pada ":title" oleh :username', 'beatmapset_discussion_post_new_compact' => 'Postingan baru oleh :username: ":content"', 'beatmapset_discussion_post_new_compact_empty' => 'Postingan baru oleh :username', - 'beatmapset_discussion_review_new' => 'Terdapat ulasan baru pada ":title" oleh :username yang menyinggung seputar masalah: :problems, saran: :suggestions, dan pujian berupa: :praises', - 'beatmapset_discussion_review_new_compact' => 'Terdapat ulasan baru oleh :username yang menyinggung seputar masalah: :problems, saran: :suggestions, dan pujian berupa: :praises', - 'beatmapset_discussion_unlock' => 'Diskusi untuk beatmap ":title" telah dibuka kembali.', + 'beatmapset_discussion_review_new' => 'Kajian baru pada ":title" oleh :username yang mengandung :review_counts', + 'beatmapset_discussion_review_new_compact' => 'Kajian baru oleh :username yang mengandung :review_counts', + 'beatmapset_discussion_unlock' => 'Diskusi pada beatmap ":title" telah kembali dibuka', 'beatmapset_discussion_unlock_compact' => 'Diskusi beatmap telah dibuka', 'review_count' => [ @@ -80,12 +80,12 @@ 'beatmapset_nominate' => '":title" telah dinominasikan', 'beatmapset_nominate_compact' => 'Beatmap telah dinominasikan', 'beatmapset_qualify' => '":title" telah memperoleh jumlah nominasi yang dibutuhkan untuk dapat memasuki antrian ranking', - 'beatmapset_qualify_compact' => 'Beatmap telah memasuki antrian ranking', + 'beatmapset_qualify_compact' => 'Beatmap memasuki antrian ranking', 'beatmapset_rank' => '":title" telah berstatus Ranked', 'beatmapset_rank_compact' => 'Beatmap telah berstatus Ranked', 'beatmapset_remove_from_loved' => '":title" telah dilepas dari Loved', 'beatmapset_remove_from_loved_compact' => 'Beatmap telah dilepas dari Loved', - 'beatmapset_reset_nominations' => 'Masalah yang dikemukakan oleh :username menganulir nominasi sebelumnya pada beatmap ":title" ', + 'beatmapset_reset_nominations' => 'Nominasi pada beatmap ":title" telah dianulir', 'beatmapset_reset_nominations_compact' => 'Nominasi beatmap dianulir', ], @@ -207,7 +207,7 @@ 'beatmapset_qualify' => '":title" telah memperoleh jumlah nominasi yang dibutuhkan untuk dapat memasuki antrian ranking', 'beatmapset_rank' => '":title" telah berstatus Ranked', 'beatmapset_remove_from_loved' => ':title telah dilepas dari Loved', - 'beatmapset_reset_nominations' => 'Status nominasi pada ":title" telah dianulir', + 'beatmapset_reset_nominations' => 'Nominasi pada beatmap ":title" telah dianulir', ], 'comment' => [ diff --git a/resources/lang/id/password_reset.php b/resources/lang/id/password_reset.php index 951afcc11d6..7caa17126ea 100644 --- a/resources/lang/id/password_reset.php +++ b/resources/lang/id/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Masukkan alamat email atau nama pengguna', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Butuh bantuan lebih lanjut? Hubungi :button kami.', 'button' => 'layanan dukungan', diff --git a/resources/lang/id/store.php b/resources/lang/id/store.php index f6ebc45b10b..cedefdd7b4f 100644 --- a/resources/lang/id/store.php +++ b/resources/lang/id/store.php @@ -15,7 +15,7 @@ 'errors_no_checkout' => [ 'line_1' => 'Uh-oh, terdapat masalah dengan keranjangmu yang menghalangi proses checkout!', - 'line_2' => 'Hapus atau perbarui item-item di atas untuk melanjutkan.', + 'line_2' => 'Hapus atau perbarui rangkaian item di atas untuk melanjutkan.', ], 'empty' => [ @@ -43,7 +43,7 @@ ], 'pending_checkout' => [ - 'line_1' => 'Transaksi sebelumnya belum dituntaskan.', + 'line_1' => 'Terdapat transaksi terdahulu yang belum dituntaskan.', 'line_2' => 'Lanjutkan pembayaranmu dengan memilih metode pembayaran.', ], ], @@ -77,7 +77,7 @@ ], ], 'prepared' => [ - 'title' => 'Pesananmu sedang disiapkan!', + 'title' => 'Pesananmu sedang dipersiapkan!', 'line_1' => 'Harap tunggu sedikit lebih lama untuk pengiriman. Informasi pelacakan akan muncul di sini setelah pesanan telah diolah dan dikirim. Ini bisa perlu sampai 5 hari (tetapi biasanya lebih cepat!) tergantung kesibukan kami.', 'line_2' => 'Kami mengirim seluruh pesanan dari Jepang dengan berbagai macam layanan pengiriman tergantung berat dan nilai. Bagian ini akan diperbarui dengan perincian setelah kami mengirimkan pesanan.', ], diff --git a/resources/lang/id/wiki.php b/resources/lang/id/wiki.php index 27a44708550..05db0075a0e 100644 --- a/resources/lang/id/wiki.php +++ b/resources/lang/id/wiki.php @@ -21,7 +21,7 @@ ], 'translation' => [ - 'legal' => 'Terjemahan ini diberikan semata-mata hanya untuk memudahkan. :default dari artikel ini merupakan satu-satunya versi artikel yang mengikat secara hukum.', + 'legal' => 'Terjemahan ini diberikan semata-mata untuk memudahkan. :default dari artikel ini merupakan satu-satunya versi artikel yang mengikat secara hukum.', 'outdated' => 'Laman ini mengandung terjemahan yang telah kedaluwarsa dari artikel aslinya. Mohon periksa :default dari artikel ini untuk mendapatkan informasi yang paling akurat (dan apabila kamu berkenan, mohon bantu kami untuk memperbarui terjemahan ini)!', 'default' => 'Versi Bahasa Inggris', diff --git a/resources/lang/it/password_reset.php b/resources/lang/it/password_reset.php index cd343cc0fe0..ee2f35dd445 100644 --- a/resources/lang/it/password_reset.php +++ b/resources/lang/it/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Inserisci l\'indirizzo email o il nome utente', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Hai bisogno di ulteriore assistenza? Contattaci col nostro :button.', 'button' => 'sistema di supporto', diff --git a/resources/lang/it/store.php b/resources/lang/it/store.php index b8fccca3cff..281a0d9e8cd 100644 --- a/resources/lang/it/store.php +++ b/resources/lang/it/store.php @@ -93,7 +93,7 @@ 'title' => 'Il tuo ordine è stato spedito!', 'tracking_details' => '', 'no_tracking_details' => [ - '_' => "Non disponiamo dei dettagli di tracciabilità poiché abbiamo inviato il tuo pacco tramite posta aerea, ma puoi aspettarti di riceverlo entro 1-3 settimane. Per l'Europa, a volte la dogana può ritardare l'ordine senza il nostro controllo. Se hai qualche dubbio, rispondi all'e-mail di conferma dell'ordine che hai ricevuto :link.", + '_' => "Non disponiamo dei dettagli di tracciabilità poiché abbiamo inviato il tuo pacco tramite posta aerea, ma puoi aspettarti di riceverlo entro 1-3 settimane. Per l'Europa, a volte la dogana può ritardare l'ordine senza il nostro controllo. Se hai qualche dubbio, rispondi all'e-mail di conferma dell'ordine che hai ricevuto (o :link).", 'link_text' => 'inviaci un\'email', ], ], diff --git a/resources/lang/ja/password_reset.php b/resources/lang/ja/password_reset.php index 411520ebf30..f3df6f00418 100644 --- a/resources/lang/ja/password_reset.php +++ b/resources/lang/ja/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'メールアドレスまたはユーザー名を入力してください', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'さらにサポートが必要ですか? :buttonからお問い合わせください。', 'button' => 'サポートシステム', diff --git a/resources/lang/kk-KZ/password_reset.php b/resources/lang/kk-KZ/password_reset.php index 97f1d2f2703..c5e6f4be919 100644 --- a/resources/lang/kk-KZ/password_reset.php +++ b/resources/lang/kk-KZ/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => '', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '', 'button' => '', diff --git a/resources/lang/ko/password_reset.php b/resources/lang/ko/password_reset.php index 29678acfbff..ff90611696d 100644 --- a/resources/lang/ko/password_reset.php +++ b/resources/lang/ko/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => '아이디나 이메일 주소를 입력하세요.', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '도움이 필요하신가요? :button을 통해 문의해보세요.', 'button' => '지원 시스템', diff --git a/resources/lang/lt/password_reset.php b/resources/lang/lt/password_reset.php index 007a0aa6014..726419ba141 100644 --- a/resources/lang/lt/password_reset.php +++ b/resources/lang/lt/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Įrašykite el. pašto adresą arba naudotojo vardą', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Reikia tolimesnės pagalbos? Susisiekite su mumis per :button.', 'button' => 'pagalbos sistemą', diff --git a/resources/lang/lv-LV/password_reset.php b/resources/lang/lv-LV/password_reset.php index 96ae7eb4319..9d58873905c 100644 --- a/resources/lang/lv-LV/password_reset.php +++ b/resources/lang/lv-LV/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Ievadiet e-pasta adresi vai lietotājvārdu', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Nepieciešams tālāks atbalsts? Sazinieties ar mums caur :button.', 'button' => 'atbalsta sistēma', diff --git a/resources/lang/ms-MY/password_reset.php b/resources/lang/ms-MY/password_reset.php index ca44bd408e9..a1b58ecb95b 100644 --- a/resources/lang/ms-MY/password_reset.php +++ b/resources/lang/ms-MY/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Masukkan alamat e-mel atau nama pengguna', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Perlukan bantuan lebih lanjut? Hubungi :button kami.', 'button' => 'layanan dukungan', diff --git a/resources/lang/nl/password_reset.php b/resources/lang/nl/password_reset.php index f93d6eeb7a9..ad8bd037481 100644 --- a/resources/lang/nl/password_reset.php +++ b/resources/lang/nl/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Vul e-mail adres of gebruikersnaam in', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Meer hulp nodig? Neem contact met ons op via onze :button.', 'button' => 'ondersteuningssysteem', diff --git a/resources/lang/no/password_reset.php b/resources/lang/no/password_reset.php index c6b46bdcd20..9ebb27fa546 100644 --- a/resources/lang/no/password_reset.php +++ b/resources/lang/no/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Skriv inn e-postadresse eller brukernavn', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Trenger du mer hjelp? Kontakt oss via vårt :button.', 'button' => 'støttesystem', diff --git a/resources/lang/pl/password_reset.php b/resources/lang/pl/password_reset.php index 56956a40c15..8c7bc5b80e1 100644 --- a/resources/lang/pl/password_reset.php +++ b/resources/lang/pl/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Wprowadź e-mail lub nazwę użytkownika', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Potrzebujesz pomocy? Skontaktuj się z :button.', 'button' => 'pomocą techniczną', diff --git a/resources/lang/pt-br/password_reset.php b/resources/lang/pt-br/password_reset.php index 77dcc2bfa4d..10743010f2a 100644 --- a/resources/lang/pt-br/password_reset.php +++ b/resources/lang/pt-br/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Insira endereço de email ou nome de usuário', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Precisa de mais assistência? Entre em contato conosco através do nosso :button.', 'button' => 'sistema de suporte', diff --git a/resources/lang/pt/password_reset.php b/resources/lang/pt/password_reset.php index d4ac6386434..9d0909eb359 100644 --- a/resources/lang/pt/password_reset.php +++ b/resources/lang/pt/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Introduz um endereço de email ou um nome de utilizador', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Precisas de mais assistência? Contacta-nos a partir do nosso :button.', 'button' => 'sistema de suporte', diff --git a/resources/lang/ro/beatmapset_events.php b/resources/lang/ro/beatmapset_events.php index b5abe3b38e7..8653975c049 100644 --- a/resources/lang/ro/beatmapset_events.php +++ b/resources/lang/ro/beatmapset_events.php @@ -30,7 +30,7 @@ 'nomination_reset' => 'O problemă nouă :discussion (:text) a declanșat reluarea unei nominalizări.', 'nomination_reset_received' => 'Nominalizarea de :user a fost resetată de către :source_user (:text)', 'nomination_reset_received_profile' => 'Nominalizarea a fost resetată de :user (:text)', - 'offset_edit' => 'Offset-ul online schimbat din :old la :new.', + 'offset_edit' => 'Decalaj online schimbat din :old la :new.', 'qualify' => 'Acest beatmap a atins numărul limită de nominalizări și s-a calificat.', 'rank' => 'Clasat.', 'remove_from_loved' => 'Eliminat din Iubit de :user. (:text)', @@ -79,7 +79,7 @@ 'nomination_reset' => 'Resetarea nominalizărilor', 'nomination_reset_received' => 'Resetare a nominalizării primită', 'nsfw_toggle' => 'Marcaj obscen', - 'offset_edit' => 'Editare offset', + 'offset_edit' => 'Editare decalaj', 'qualify' => 'Calificare', 'rank' => 'Clasament', 'remove_from_loved' => 'Scoaterea din Iubit', diff --git a/resources/lang/ro/beatmapsets.php b/resources/lang/ro/beatmapsets.php index c28df21719b..cc0fd2b41dc 100644 --- a/resources/lang/ro/beatmapsets.php +++ b/resources/lang/ro/beatmapsets.php @@ -136,7 +136,7 @@ 'no_scores' => 'Încă se calculează datele...', 'nominators' => 'Nominalizatori', 'nsfw' => 'Conținut obscen', - 'offset' => 'Offset online', + 'offset' => 'Decalaj online', 'points-of-failure' => 'Puncte de eșec', 'source' => 'Sursă', 'storyboard' => 'Acest beatmap conține un storyboard', @@ -209,7 +209,7 @@ 'bpm' => 'BPM', 'count_circles' => 'Număr Cercuri', 'count_sliders' => 'Număr Slidere', - 'offset' => 'Offset online: :offset', + 'offset' => 'Decalaj online: :offset', 'user-rating' => 'Rating Utilizatori', 'rating-spread' => 'Grafic Rating-uri', 'nominations' => 'Nominalizări', diff --git a/resources/lang/ro/password_reset.php b/resources/lang/ro/password_reset.php index cc69b485810..9e0ee4aeff9 100644 --- a/resources/lang/ro/password_reset.php +++ b/resources/lang/ro/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Introduceți adresa de e-mail sau numele de utilizator', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Aveți nevoie de asistență suplimentară? Contactați-ne prin intermediul :button.', 'button' => 'sistem de ajutor', diff --git a/resources/lang/ru/password_reset.php b/resources/lang/ru/password_reset.php index 03424d90f94..a5a6d6d727a 100644 --- a/resources/lang/ru/password_reset.php +++ b/resources/lang/ru/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Введите почту или ник', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Нужна дополнительная помощь? Свяжитесь с нами через :button.', 'button' => 'систему поддержки', diff --git a/resources/lang/ru/store.php b/resources/lang/ru/store.php index fec6def83f6..f9621a0c0fe 100644 --- a/resources/lang/ru/store.php +++ b/resources/lang/ru/store.php @@ -93,7 +93,7 @@ 'title' => 'Ваш заказ отправлен!', 'tracking_details' => 'Подробности отслеживания:', 'no_tracking_details' => [ - '_' => "У нас нет данных отслеживания, поскольку мы отправили ваш заказ авиапочтой, однако вы можете рассчитывать на их получение в течение 1-3 недель. Иногда таможня в Европе может задержать заказ вне нашего контроля. Если у вас остались вопросы, ответьте на полученное вами письмо с подтверждением заказа :link.", + '_' => "У нас нет данных отслеживания, поскольку мы отправили ваш заказ авиапочтой, однако вы можете рассчитывать на их получение в течение 1-3 недель. Иногда таможня в Европе может задержать заказ вне нашего контроля. Если у вас остались вопросы, ответьте на полученное вами письмо с подтверждением заказа (или :link).", 'link_text' => 'отправьте нам письмо', ], ], diff --git a/resources/lang/si-LK/password_reset.php b/resources/lang/si-LK/password_reset.php index 7d1682ca277..d5c45b72e08 100644 --- a/resources/lang/si-LK/password_reset.php +++ b/resources/lang/si-LK/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => '', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '', 'button' => '', diff --git a/resources/lang/sk/password_reset.php b/resources/lang/sk/password_reset.php index d74fff28b3e..dffd250a16a 100644 --- a/resources/lang/sk/password_reset.php +++ b/resources/lang/sk/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Zadajte e-mailovú adresu alebo užívateľské meno', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '', 'button' => '', diff --git a/resources/lang/sl/password_reset.php b/resources/lang/sl/password_reset.php index 7b71cf5274e..90db330edc4 100644 --- a/resources/lang/sl/password_reset.php +++ b/resources/lang/sl/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Vnesi e-poštni naslov ali uporabniško ime', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Potrebuješ nadaljno pomoč? Kontaktiraj nas preko našega :button.', 'button' => 'sistema podpore', diff --git a/resources/lang/sr/password_reset.php b/resources/lang/sr/password_reset.php index 9d4b12e8e53..70def082186 100644 --- a/resources/lang/sr/password_reset.php +++ b/resources/lang/sr/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Унесите адресу е-поште или корисничко име', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Треба Вам додатна помоћ? Ступите у контакт преко нашег :button.', 'button' => 'систем за подршку', diff --git a/resources/lang/sv/password_reset.php b/resources/lang/sv/password_reset.php index bd05f598937..0da9a25e61b 100644 --- a/resources/lang/sv/password_reset.php +++ b/resources/lang/sv/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Fyll i din e-postadress eller ditt användarnamn', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Behöver du mer hjälp? Kontakta oss via vår :button.', 'button' => 'supportsystem', diff --git a/resources/lang/tg-TJ/password_reset.php b/resources/lang/tg-TJ/password_reset.php index 97f1d2f2703..c5e6f4be919 100644 --- a/resources/lang/tg-TJ/password_reset.php +++ b/resources/lang/tg-TJ/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => '', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '', 'button' => '', diff --git a/resources/lang/tg-TJ/scores.php b/resources/lang/tg-TJ/scores.php index e8319f084b3..5e13cd1d042 100644 --- a/resources/lang/tg-TJ/scores.php +++ b/resources/lang/tg-TJ/scores.php @@ -8,7 +8,7 @@ 'title' => '', 'beatmap' => [ - 'by' => '', + 'by' => 'аз ҷониби :artist', ], 'player' => [ diff --git a/resources/lang/tg-TJ/store.php b/resources/lang/tg-TJ/store.php index e776a4912bc..54a21aff169 100644 --- a/resources/lang/tg-TJ/store.php +++ b/resources/lang/tg-TJ/store.php @@ -8,20 +8,20 @@ 'checkout' => '', 'empty_cart' => '', 'info' => '', - 'more_goodies' => '', - 'shipping_fees' => '', - 'title' => '', - 'total' => '', + 'more_goodies' => 'Ман мехоҳам пеш аз ба итмом расонидани фармоиш чизҳои бештарро тафтиш кунам', + 'shipping_fees' => 'ҳаққи интиқол', + 'title' => 'Сабади харид', + 'total' => 'умумии', 'errors_no_checkout' => [ 'line_1' => '', - 'line_2' => '', + 'line_2' => 'Барои идома додани ҷузъҳои боло хориҷ ё навсозӣ кунед.', ], 'empty' => [ - 'text' => '', + 'text' => 'Аробаи шумо холист.', 'return_link' => [ - '_' => '', + '_' => 'Ба :link баргардед, то чизҳои хубро пайдо кунед!', 'link_text' => '', ], ], diff --git a/resources/lang/tg-TJ/supporter_tag.php b/resources/lang/tg-TJ/supporter_tag.php index e0ea36b594e..632913da8d7 100644 --- a/resources/lang/tg-TJ/supporter_tag.php +++ b/resources/lang/tg-TJ/supporter_tag.php @@ -4,10 +4,10 @@ // See the LICENCE file in the repository root for full licence text. return [ - 'months' => '', + 'months' => 'моҳҳо', 'user_search' => [ - 'searching' => '', - 'not_found' => "", + 'searching' => 'чустучуй...', + 'not_found' => "Ин корбар вуҷуд надорад", ], ]; diff --git a/resources/lang/th/password_reset.php b/resources/lang/th/password_reset.php index c54ecbe0f3f..3ec39d0816b 100644 --- a/resources/lang/th/password_reset.php +++ b/resources/lang/th/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'กรอกอีเมล หรือชื่อผู้ใช้', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => ' ต้องการความช่วยเหลือเพิ่มเติมหรือไม่? ติดต่อเราผ่านทาง :button', diff --git a/resources/lang/tr/accounts.php b/resources/lang/tr/accounts.php index e0e2b59d090..2ef9f55ba94 100644 --- a/resources/lang/tr/accounts.php +++ b/resources/lang/tr/accounts.php @@ -63,15 +63,15 @@ ], 'github_user' => [ - 'info' => "", + 'info' => "Eğer osu!'nun açık kaynaklı repository'lerinde katkılıysanız, GitHub hesabınızı bağlamanız sizin değişim günlüğü girişleriniz, osu! profilinizle ilişkilendirilecektir. osu! repository'lerinde katkı geçmişi olmayan GitHub hesapları bağlanamaz.", 'link' => 'GitHub Hesabını Bağla', 'title' => 'GitHub', 'unlink' => 'GitHub Hesabının bağlantısını Kaldır', 'error' => [ - 'already_linked' => '', - 'no_contribution' => '', - 'unverified_email' => '', + 'already_linked' => 'Bu GitHub hesabı zaten başka bir kullanıcıya bağlı.', + 'no_contribution' => 'osu! repository\'lerinde katkı geçmişi olmayan GitHub hesabı bağlanamaz.', + 'unverified_email' => 'Lütfen GitHub\'daki ana e-postanızı doğrulayın, sonra hesabınızı tekrar bağlamayı deneyin.', ], ], diff --git a/resources/lang/tr/notifications.php b/resources/lang/tr/notifications.php index b2caf09edc4..fdf894192b9 100644 --- a/resources/lang/tr/notifications.php +++ b/resources/lang/tr/notifications.php @@ -57,9 +57,9 @@ 'beatmapset_discussion_unlock_compact' => 'Tartışmanın kilidi kaldırılmış', 'review_count' => [ - 'praises' => '', - 'problems' => '', - 'suggestions' => '', + 'praises' => ':count_delimited övgü|:count_delimited övgü', + 'problems' => ':count_delimited sorun|:count_delimited sorun', + 'suggestions' => ':count_delimited öneri|:count_delimited öneri', ], ], diff --git a/resources/lang/tr/password_reset.php b/resources/lang/tr/password_reset.php index b5c76eaccf7..3a5f712a0bc 100644 --- a/resources/lang/tr/password_reset.php +++ b/resources/lang/tr/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'E-posta adresi veya kullanıcı adı girin', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Yardıma mı ihtiyacınız var? :button üzerinden bizimle iletişime geçin.', 'button' => 'Destek sistemimiz', diff --git a/resources/lang/tr/store.php b/resources/lang/tr/store.php index ea644a74d86..5a5efabe4da 100644 --- a/resources/lang/tr/store.php +++ b/resources/lang/tr/store.php @@ -65,7 +65,7 @@ 'cancelled' => [ 'title' => 'Siparişiniz iptal edildi', 'line_1' => [ - '_' => "", + '_' => "Eğer iptal edilmesini talep etmediyseniz lütfen :link yoluyla sipariş numaranızı bahsederek ulaşınız. (#:order_number).", 'link_text' => 'osu!store destek', ], ], @@ -78,8 +78,8 @@ ], 'prepared' => [ 'title' => 'Siparişiniz hazılrlanıyor!', - 'line_1' => '', - 'line_2' => '', + 'line_1' => 'Lütfen kargolanması için az daha bekleyiniz. Takip bilgisi, siparişiniz işlenip gönderildiğinde burada belirecektir. Meşgullük durumumuza göre 5 güne kadar sürebilir (ama genellikle daha az!).', + 'line_2' => 'Siparişleri, ağırlığı ve değerine bağlı olarak çeşitli kargo şirketleri kullanarak gönderiyoruz. Bu alan, siparişi gönderdiğimizde detaylarla güncellenecektir.', ], 'processing' => [ 'title' => 'Ödemeniz henüz onaylanmadı!', @@ -93,7 +93,7 @@ 'title' => 'Siparişiniz kargoya verildi!', 'tracking_details' => 'Kargo takip detayları aşağıdadır:', 'no_tracking_details' => [ - '_' => "", + '_' => "Paketinizi uçak kargosu yoluyla gönderdiğimiz için takip ayrıntılarına sahip değiliz, ancak paketinizi 1-3 hafta içinde almayı bekleyebilirsiniz. Avrupa'da bazen gümrükler bizim kontrolümüz dışında siparişi geciktirebilir. Herhangi bir endişeniz varsa lütfen sana gelen sipariş onay e-postasını yanıtlayınız (ya da :link).", 'link_text' => 'bize bir e-mail yollayın', ], ], @@ -157,7 +157,7 @@ 'thanks' => [ 'title' => 'Siparişiniz için teşekkür ederiz!', 'line_1' => [ - '_' => '', + '_' => 'Yakında bir onay e-postası alacaksınız. Sorunuz varsa, lütfen :link!', 'link_text' => 'bizimle iletişime geçin', ], ], diff --git a/resources/lang/uk/password_reset.php b/resources/lang/uk/password_reset.php index 39d71896dea..25c09f64796 100644 --- a/resources/lang/uk/password_reset.php +++ b/resources/lang/uk/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Введіть пошту або нікнейм', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Потрібна додаткова допомога? Зв\'яжіться з нами через :button.', 'button' => 'система підтримки', diff --git a/resources/lang/vi/password_reset.php b/resources/lang/vi/password_reset.php index ba3fdd91254..7173c59e470 100644 --- a/resources/lang/vi/password_reset.php +++ b/resources/lang/vi/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => 'Nhập địa chỉ email hoặc tên tài khoản', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => 'Cần nhiều sự giúp đỡ hơn? Liên hệ với chúng tôi bằng :button.', 'button' => 'hệ thống hỗ trợ', diff --git a/resources/lang/zh-tw/password_reset.php b/resources/lang/zh-tw/password_reset.php index 1dc941288ac..be415f6fd13 100644 --- a/resources/lang/zh-tw/password_reset.php +++ b/resources/lang/zh-tw/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => '輸入郵件地址或使用者名稱', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '需要進一步的幫助?通過我們的 :button 聯繫我們。', 'button' => '支持系統', diff --git a/resources/lang/zh/password_reset.php b/resources/lang/zh/password_reset.php index 53df1500968..b3f202696c7 100644 --- a/resources/lang/zh/password_reset.php +++ b/resources/lang/zh/password_reset.php @@ -36,6 +36,9 @@ 'starting' => [ 'username' => '输入邮箱或用户名', + 'reason' => [ + 'inactive_different_country' => "", + ], 'support' => [ '_' => '需要进一步的帮助?通过我们的 :button 联系我们。', 'button' => '支持系统', From af12c518d05d73d93ce06ed5b5e6b5a3511446af Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 29 Jan 2024 18:19:13 +0900 Subject: [PATCH 183/203] Add ranked field in score params --- app/Models/Solo/Score.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/Models/Solo/Score.php b/app/Models/Solo/Score.php index c8754e0f478..e2ed726cccb 100644 --- a/app/Models/Solo/Score.php +++ b/app/Models/Solo/Score.php @@ -12,6 +12,7 @@ use App\Libraries\Score\UserRank; use App\Libraries\Search\ScoreSearchParams; use App\Models\Beatmap; +use App\Models\Beatmapset; use App\Models\Model; use App\Models\Multiplayer\ScoreLink as MultiplayerScoreLink; use App\Models\Score as LegacyScore; @@ -117,6 +118,12 @@ public static function extractParams(array $rawParams, ScoreToken|MultiplayerSco $params['started_at'] = $scoreToken->created_at; $params['user_id'] = $scoreToken->user_id; + $beatmap = $scoreToken->beatmap; + $params['ranked'] = $beatmap !== null && in_array($beatmap->approved, [ + Beatmapset::STATES['approved'], + Beatmapset::STATES['ranked'], + ], true); + return $params; } From 501498de351b1225845e24b4f6a94195f72cb4ab Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 29 Jan 2024 22:06:11 +0900 Subject: [PATCH 184/203] Remove client check for registration It will be on a separate PR. --- app/Http/Controllers/UsersController.php | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index aa7aa81da25..42501f459e4 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -9,7 +9,6 @@ use App\Exceptions\UserProfilePageLookupException; use App\Exceptions\ValidationException; use App\Http\Middleware\RequestCost; -use App\Libraries\ClientCheck; use App\Libraries\RateLimiter; use App\Libraries\Search\ForumSearch; use App\Libraries\Search\ForumSearchRequestParams; @@ -218,15 +217,11 @@ public function store() ], 403); } - $request = \Request::instance(); - - if (!starts_with($request->header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) { + if (!starts_with(Request::header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) { return error_popup(osu_trans('users.store.from_client'), 403); } - ClientCheck::parseToken($request); - - return $this->storeUser($request->all()); + return $this->storeUser(request()->all()); } public function storeWeb() From 604dd53c174f551595357fc14eb4c333721dc6bd Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 29 Jan 2024 21:19:24 +0900 Subject: [PATCH 185/203] Add client check on registration and allow enabling both registration modes --- .env.example | 2 ++ app/Http/Controllers/UsersController.php | 39 ++++++++++++++++------- config/osu.php | 6 +++- tests/Controllers/UsersControllerTest.php | 16 ++++++---- 4 files changed, 44 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 388820fa363..c5d784ed0c5 100644 --- a/.env.example +++ b/.env.example @@ -225,6 +225,8 @@ CLIENT_CHECK_VERSION=false # CHAT_PUBLIC_BACKLOG_LIMIT_HOURS=24 # ALLOW_REGISTRATION=true +# REGISTRATION_MODE_CLIENT=true +# REGISTRATION_MODE_WEB=false # USER_ALLOW_EMAIL_LOGIN=true # USER_BYPASS_VERIFICATION=false diff --git a/app/Http/Controllers/UsersController.php b/app/Http/Controllers/UsersController.php index 42501f459e4..70ccde3504c 100644 --- a/app/Http/Controllers/UsersController.php +++ b/app/Http/Controllers/UsersController.php @@ -9,6 +9,7 @@ use App\Exceptions\UserProfilePageLookupException; use App\Exceptions\ValidationException; use App\Http\Middleware\RequestCost; +use App\Libraries\ClientCheck; use App\Libraries\RateLimiter; use App\Libraries\Search\ForumSearch; use App\Libraries\Search\ForumSearchRequestParams; @@ -33,6 +34,7 @@ use NoCaptcha; use Request; use Sentry\State\Scope; +use Symfony\Component\HttpKernel\Exception\HttpException; /** * @group Users @@ -103,6 +105,14 @@ public function __construct() parent::__construct(); } + private static function storeClientDisabledError() + { + return response([ + 'error' => osu_trans('users.store.from_web'), + 'url' => route('users.create'), + ], 403); + } + public function card($id) { try { @@ -116,7 +126,7 @@ public function card($id) public function create() { - if ($GLOBALS['cfg']['osu']['user']['registration_mode'] !== 'web') { + if (!$GLOBALS['cfg']['osu']['user']['registration_mode']['web']) { return abort(403, osu_trans('users.store.from_client')); } @@ -210,23 +220,28 @@ public function extraPages($_id, $page) public function store() { - if ($GLOBALS['cfg']['osu']['user']['registration_mode'] !== 'client') { - return response([ - 'error' => osu_trans('users.store.from_web'), - 'url' => route('users.create'), - ], 403); + if (!$GLOBALS['cfg']['osu']['user']['registration_mode']['client']) { + return static::storeClientDisabledError(); } - if (!starts_with(Request::header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) { + $request = \Request::instance(); + + if (!starts_with($request->header('User-Agent'), $GLOBALS['cfg']['osu']['client']['user_agent'])) { return error_popup(osu_trans('users.store.from_client'), 403); } - return $this->storeUser(request()->all()); + try { + ClientCheck::parseToken($request); + } catch (HttpException $e) { + return static::storeClientDisabledError(); + } + + return $this->storeUser($request->all()); } public function storeWeb() { - if ($GLOBALS['cfg']['osu']['user']['registration_mode'] !== 'web') { + if (!$GLOBALS['cfg']['osu']['user']['registration_mode']['web']) { return error_popup(osu_trans('users.store.from_client'), 403); } @@ -984,13 +999,13 @@ private function storeUser(array $rawParams) ); } - if ($GLOBALS['cfg']['osu']['user']['registration_mode'] === 'web') { + if (is_json_request()) { + return json_item($user->fresh(), new CurrentUserTransformer()); + } else { $this->login($user); session()->flash('popup', osu_trans('users.store.saved')); return ujs_redirect(route('home')); - } else { - return json_item($user->fresh(), new CurrentUserTransformer()); } } catch (ValidationException $e) { return ModelNotSavedException::makeResponse($e, [ diff --git a/config/osu.php b/config/osu.php index 1658a724e3a..243fcae63a7 100644 --- a/config/osu.php +++ b/config/osu.php @@ -268,7 +268,6 @@ 'key_length' => 8, 'tries' => 8, ], - 'registration_mode' => presence(env('REGISTRATION_MODE')) ?? 'client', 'super_friendly' => array_map('intval', explode(' ', env('SUPER_FRIENDLY', '3'))), 'ban_persist_days' => get_int(env('BAN_PERSIST_DAYS')) ?? 28, @@ -276,6 +275,11 @@ 'max_mixed_months' => get_int(env('USER_COUNTRY_CHANGE_MAX_MIXED_MONTHS')) ?? 2, 'min_months' => get_int(env('USER_COUNTRY_CHANGE_MIN_MONTHS')) ?? 6, ], + + 'registration_mode' => [ + 'client' => get_bool(env('REGISTRATION_MODE_CLIENT')) ?? true, + 'web' => get_bool(env('REGISTRATION_MODE_WEB')) ?? false, + ], ], 'user_report_notification' => [ 'endpoint_cheating' => presence(env('USER_REPORT_NOTIFICATION_ENDPOINT_CHEATING')), diff --git a/tests/Controllers/UsersControllerTest.php b/tests/Controllers/UsersControllerTest.php index 43670ca2d38..5c0a4df2783 100644 --- a/tests/Controllers/UsersControllerTest.php +++ b/tests/Controllers/UsersControllerTest.php @@ -50,9 +50,10 @@ public function testStore() $this->assertSame($previousCount + 1, User::count()); } - public function testStoreRegModeWeb() + public function testStoreRegModeWebOnly() { - config_set('osu.user.registration_mode', 'web'); + config_set('osu.user.registration_mode.client', false); + config_set('osu.user.registration_mode.web', true); $this->expectCountChange(fn () => User::count(), 0); $this @@ -131,8 +132,11 @@ public function testStoreInvalid() $this->assertSame($previousCount, User::count()); } - public function testStoreWebRegModeClient() + public function testStoreWebRegModeClientOnly() { + config_set('osu.user.registration_mode.client', true); + config_set('osu.user.registration_mode.web', false); + $this->expectCountChange(fn () => User::count(), 0); $this->post(route('users.store'), [ @@ -149,7 +153,7 @@ public function testStoreWebRegModeClient() public function testStoreWeb(): void { - config_set('osu.user.registration_mode', 'web'); + config_set('osu.user.registration_mode.web', true); $this->expectCountChange(fn () => User::count(), 1); $this->post(route('users.store-web'), [ @@ -168,7 +172,7 @@ public function testStoreWeb(): void */ public function testStoreWebInvalidParams($username, $email, $emailConfirmation, $password, $passwordConfirmation): void { - config_set('osu.user.registration_mode', 'web'); + config_set('osu.user.registration_mode.web', true); $this->expectCountChange(fn () => User::count(), 0); $this->post(route('users.store-web'), [ @@ -184,7 +188,7 @@ public function testStoreWebInvalidParams($username, $email, $emailConfirmation, public function testStoreWebLoggedIn(): void { - config_set('osu.user.registration_mode', 'web'); + config_set('osu.user.registration_mode.web', true); $user = User::factory()->create(); $this->expectCountChange(fn () => User::count(), 0); From fc6cec66f90aa524b87370d38d3238231e365c19 Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 29 Jan 2024 22:43:02 +0900 Subject: [PATCH 186/203] Update icon for lazer mode --- .../layout/osu-lazer-logo-triangles.svg | 257 ++++++++++++++++++ public/images/layout/osu-lazer-logo-white.svg | 46 ++++ resources/css/bem/nav2.less | 4 +- resources/css/bem/navbar-mobile.less | 2 +- resources/css/bem/osu-layout.less | 8 + .../views/layout/_score_mode_toggle.blade.php | 6 +- resources/views/master.blade.php | 5 +- 7 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 public/images/layout/osu-lazer-logo-triangles.svg create mode 100644 public/images/layout/osu-lazer-logo-white.svg diff --git a/public/images/layout/osu-lazer-logo-triangles.svg b/public/images/layout/osu-lazer-logo-triangles.svg new file mode 100644 index 00000000000..2321f2c04c2 --- /dev/null +++ b/public/images/layout/osu-lazer-logo-triangles.svg @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/layout/osu-lazer-logo-white.svg b/public/images/layout/osu-lazer-logo-white.svg new file mode 100644 index 00000000000..8209a0c0ae4 --- /dev/null +++ b/public/images/layout/osu-lazer-logo-white.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/css/bem/nav2.less b/resources/css/bem/nav2.less index eade7ae8eff..2617b0c61f7 100644 --- a/resources/css/bem/nav2.less +++ b/resources/css/bem/nav2.less @@ -60,7 +60,7 @@ transition: all 100ms ease-in-out; will-change: opacity, transform; - background-image: url('~@images/layout/osu-logo-white.svg'); + background-image: var(--nav-logo); .@{_top}__logo-link:hover & { // be careful of weird snapping at the end of animation on Firefox (with 1.1, ~60px). @@ -68,7 +68,7 @@ } &--bg { - background-image: url('~@images/layout/osu-logo-triangles.svg'); + background-image: var(--nav-logo-bg); opacity: 0; .@{_top}__logo-link:hover & { diff --git a/resources/css/bem/navbar-mobile.less b/resources/css/bem/navbar-mobile.less index def8b0270fa..9ec8321e802 100644 --- a/resources/css/bem/navbar-mobile.less +++ b/resources/css/bem/navbar-mobile.less @@ -33,7 +33,7 @@ &__logo { flex: none; display: block; - background-image: url('~@images/layout/osu-logo-white.svg'); + background-image: var(--nav-logo); background-size: contain; background-repeat: no-repeat; background-position: center; diff --git a/resources/css/bem/osu-layout.less b/resources/css/bem/osu-layout.less index 226b8c79bb7..09915043850 100644 --- a/resources/css/bem/osu-layout.less +++ b/resources/css/bem/osu-layout.less @@ -13,6 +13,9 @@ transition: filter 200ms ease-in-out, opacity 200ms ease-in-out; // for fading in after &--masked is removed &--body { + --nav-logo: url('~@images/layout/osu-logo-white.svg'); + --nav-logo-bg: url('~@images/layout/osu-logo-triangles.svg'); + background-color: @osu-colour-b6; } @@ -35,6 +38,11 @@ } } + &--body-lazer { + --nav-logo: url('~@images/layout/osu-lazer-logo-white.svg'); + --nav-logo-bg: url('~@images/layout/osu-lazer-logo-triangles.svg'); + } + &--full { flex: 1 0 auto; width: 100%; diff --git a/resources/views/layout/_score_mode_toggle.blade.php b/resources/views/layout/_score_mode_toggle.blade.php index bf35d058dfb..423749dd25b 100644 --- a/resources/views/layout/_score_mode_toggle.blade.php +++ b/resources/views/layout/_score_mode_toggle.blade.php @@ -3,15 +3,15 @@ See the LICENCE file in the repository root for full licence text. --}} @php - $legacyScoreOnlyValue = App\Libraries\Search\ScoreSearchParams::showLegacyForUser(Auth::user()); - $icon = $legacyScoreOnlyValue + $legacyScoreMode ??= App\Libraries\Search\ScoreSearchParams::showLegacyForUser($currentUser) === true; + $icon = $legacyScoreMode ? 'far fa-square' : 'fas fa-check-square'; @endphp