From ffee8c717dff737d6a4ba7073dcc860addb73ca4 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 1 Jun 2023 17:46:27 +0900 Subject: [PATCH 001/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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/130] 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 711b991f35a802f16455de358e5c9c8c1aeefaf6 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Thu, 11 Jan 2024 20:17:02 +0900 Subject: [PATCH 115/130] 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 116/130] 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 117/130] 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 118/130] 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 119/130] 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 120/130] 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 121/130] 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 122/130] 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 123/130] 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 124/130] 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 125/130] 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 126/130] 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 127/130] 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 128/130] 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 129/130] 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 006687971e2ea3377d6cbb6de346c1e725cae317 Mon Sep 17 00:00:00 2001 From: bakaneko Date: Fri, 26 Jan 2024 18:40:00 +0900 Subject: [PATCH 130/130] 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; } } }