···238$ php artisan migrate:fresh --seed
239```
240241-Run the above command to rebuild the database and seed with sample data. In order for the seeder to seed beatmaps, you must enter a valid osu! API key as the value of the `OSU_API_KEY` property in the `.env` configuration file, as the seeder obtains beatmap data from the osu! API. The key can be obtained at [the "osu! API Access" page](https://old.ppy.sh/p/api), which is currently only available on the old site.
242243## Continuous asset generation while developing
244
···238$ php artisan migrate:fresh --seed
239```
240241+Run the above command to rebuild the database and seed with sample data. In order for the seeder to seed beatmaps, you must enter a valid osu! API key as the value of the `OSU_API_KEY` property in the `.env` configuration file, as the seeder obtains beatmap data from the osu! API. The key can be obtained from [the "Legacy API" section of your account settings page](https://osu.ppy.sh/home/account/edit#legacy-api).
242243## Continuous asset generation while developing
244
···17{
18 abstract protected function newReportableExtraParams(): array;
190000020 public function reportedIn()
21 {
22 return $this->morphMany(UserReport::class, 'reportable');
···36 $attributes['comments'] = $params['comments'] ?? '';
37 $attributes['reporter_id'] = $reporter->getKey();
3839- if (array_key_exists('reason', $params)) {
40 $attributes['reason'] = $params['reason'];
41 }
42
···17{
18 abstract protected function newReportableExtraParams(): array;
1920+ public function reportableAdditionalInfo(): ?string
21+ {
22+ return null;
23+ }
24+25 public function reportedIn()
26 {
27 return $this->morphMany(UserReport::class, 'reportable');
···41 $attributes['comments'] = $params['comments'] ?? '';
42 $attributes['reporter_id'] = $reporter->getKey();
4344+ if (present($params['reason'] ?? null)) {
45 $attributes['reason'] = $params['reason'];
46 }
47
+1
app/Models/Traits/ReportableInterface.php
···1011interface ReportableInterface
12{
013 public function reportBy(User $reporter, array $params): ?UserReport;
14 public function trashed();
15}
···1011interface ReportableInterface
12{
13+ public function reportableAdditionalInfo(): ?string;
14 public function reportBy(User $reporter, array $params): ?UserReport;
15 public function trashed();
16}
+8-4
app/Models/User.php
···25use Carbon\Carbon;
26use DB;
27use Ds\Set;
28-use Egulias\EmailValidator\EmailValidator;
29-use Egulias\EmailValidator\Validation\NoRFCWarningsValidation;
30use Exception;
31use Hash;
32use Illuminate\Auth\Authenticatable;
···2000 $this->currentPassword = $value;
2001 }
20020002003 public function validateEmailConfirmation()
2004 {
2005 $this->validateEmailConfirmation = true;
···2246 }
22472248 if ($this->validateEmailConfirmation) {
00002249 if ($this->user_email !== $this->emailConfirmation) {
2250 $this->validationErrors()->add('user_email_confirmation', '.wrong_email_confirmation');
2251 }
···22962297 public function isValidEmail()
2298 {
2299- $emailValidator = new EmailValidator();
2300- if (!$emailValidator->isValid($this->user_email, new NoRFCWarningsValidation())) {
2301 $this->validationErrors()->add('user_email', '.invalid_email');
23022303 // no point validating further if address isn't valid.
···25use Carbon\Carbon;
26use DB;
27use Ds\Set;
0028use Exception;
29use Hash;
30use Illuminate\Auth\Authenticatable;
···1998 $this->currentPassword = $value;
1999 }
20002001+ /**
2002+ * Enables email presence and confirmation field equality check.
2003+ */
2004 public function validateEmailConfirmation()
2005 {
2006 $this->validateEmailConfirmation = true;
···2247 }
22482249 if ($this->validateEmailConfirmation) {
2250+ if ($this->user_email === null) {
2251+ $this->validationErrors()->add('user_email', '.required');
2252+ }
2253+2254 if ($this->user_email !== $this->emailConfirmation) {
2255 $this->validationErrors()->add('user_email_confirmation', '.wrong_email_confirmation');
2256 }
···23012302 public function isValidEmail()
2303 {
2304+ if (!is_valid_email_format($this->user_email)) {
02305 $this->validationErrors()->add('user_email', '.invalid_email');
23062307 // no point validating further if address isn't valid.
···15import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json';
16import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json';
17import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json';
18-import { BeatmapsetWithDiscussionsJson } from 'interfaces/beatmapset-json';
19import UserJson from 'interfaces/user-json';
20import { route } from 'laroute';
21import { isEqual } from 'lodash';
···15import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json';
16import { BeatmapsetDiscussionMessagePostJson } from 'interfaces/beatmapset-discussion-post-json';
17import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json';
18+import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json';
19import UserJson from 'interfaces/user-json';
20import { route } from 'laroute';
21import { isEqual } from 'lodash';
···1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2// See the LICENCE file in the repository root for full licence text.
34-import { Comments } from 'components/comments';
5import { CommentsManager } from 'components/comments-manager';
6import HeaderV4 from 'components/header-v4';
7import NotificationBanner from 'components/notification-banner';
···1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2// See the LICENCE file in the repository root for full licence text.
34+import Comments from 'components/comments';
5import { CommentsManager } from 'components/comments-manager';
6import HeaderV4 from 'components/header-v4';
7import NotificationBanner from 'components/notification-banner';
···1# Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2# See the LICENCE file in the repository root for full licence text.
34-import { Main } from 'comments-index/main'
5import { CommentsManager } from 'components/comments-manager'
6import core from 'osu-core-singleton'
7import { createElement } from 'react'
···13 core.dataStore.uiState.initializeWithCommentBundleJson(commentBundle)
1415 createElement CommentsManager,
16- component: Main
17 user: commentBundle.user
···1# Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2# See the LICENCE file in the repository root for full licence text.
34+import CommentsIndex from 'comments-index'
5import { CommentsManager } from 'components/comments-manager'
6import core from 'osu-core-singleton'
7import { createElement } from 'react'
···13 core.dataStore.uiState.initializeWithCommentBundleJson(commentBundle)
1415 createElement CommentsManager,
16+ component: CommentsIndex
17 user: commentBundle.user
+2-2
resources/js/entrypoints/comments-show.coffee
···1# Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2# See the LICENCE file in the repository root for full licence text.
34-import { Main } from 'comments-show/main'
5import { CommentsManager } from 'components/comments-manager'
6import core from 'osu-core-singleton'
7import { createElement } from 'react'
···13 core.dataStore.uiState.initializeWithCommentBundleJson(commentBundle)
1415 createElement CommentsManager,
16- component: Main
···1# Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2# See the LICENCE file in the repository root for full licence text.
34+import CommentsShow from 'comments-show'
5import { CommentsManager } from 'components/comments-manager'
6import core from 'osu-core-singleton'
7import { createElement } from 'react'
···13 core.dataStore.uiState.initializeWithCommentBundleJson(commentBundle)
1415 createElement CommentsManager,
16+ component: CommentsShow
···1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2// See the LICENCE file in the repository root for full licence text.
34-import { BeatmapsetWithDiscussionsJson } from './beatmapset-json';
56export interface BeatmapsetDiscussionPostStoreResponseJson {
7 beatmap_discussion_id: number;
···1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2// See the LICENCE file in the repository root for full licence text.
34+import BeatmapsetWithDiscussionsJson from 'interfaces/beatmapset-with-discussions-json';
56export interface BeatmapsetDiscussionPostStoreResponseJson {
7 beatmap_discussion_id: number;
···1+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2+// See the LICENCE file in the repository root for full licence text.
3+4+import BeatmapsetExtendedJson from './beatmapset-extended-json';
5+6+type DiscussionsRequiredAttributes = 'beatmaps' | 'current_user_attributes' | 'discussions' | 'events' | 'nominations' | 'related_users';
7+type BeatmapsetWithDiscussionsJson = BeatmapsetExtendedJson & Required<Pick<BeatmapsetExtendedJson, DiscussionsRequiredAttributes>>;
8+9+export default BeatmapsetWithDiscussionsJson;
+8
resources/js/interfaces/legacy-api-key-json.ts
···00000000
···1+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2+// See the LICENCE file in the repository root for full licence text.
3+4+export default interface LegacyApiKeyJson {
5+ api_key: string;
6+ app_name: string;
7+ app_url: string;
8+}
+6
resources/js/interfaces/legacy-irc-key-json.ts
···000000
···1+// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2+// See the LICENCE file in the repository root for full licence text.
3+4+export default interface LegacyIrcKeyJson {
5+ token: string;
6+}
···2// See the LICENCE file in the repository root for full licence text.
34import AdminMenu from 'components/admin-menu';
5-import { Comments } from 'components/comments';
6import { CommentsManager } from 'components/comments-manager';
7import NewsHeader from 'components/news-header';
8import StringWithComponent from 'components/string-with-component';
···2// See the LICENCE file in the repository root for full licence text.
34import AdminMenu from 'components/admin-menu';
5+import Comments from 'components/comments';
6import { CommentsManager } from 'components/comments-manager';
7import NewsHeader from 'components/news-header';
8import StringWithComponent from 'components/string-with-component';
···31import NotificationsWorker from 'notifications/worker';
32import SocketWorker from 'socket-worker';
33import RootDataStore from 'stores/root-data-store';
03435// will this replace main.coffee eventually?
36export default class OsuCore {
37- beatmapsetSearchController: BeatmapsetSearchController;
38- readonly captcha = new Captcha();
39- readonly chatWorker = new ChatWorker();
40- readonly clickMenu = new ClickMenu();
41 @observable currentUser?: CurrentUserJson;
42- readonly currentUserModel = new UserModel(this);
43- dataStore: RootDataStore;
44- readonly enchant: Enchant;
45- readonly forumPoll = new ForumPoll();
46- readonly forumPostEdit = new ForumPostEdit();
47- readonly forumPostInput = new ForumPostInput();
48- readonly forumPostReport = new ForumPostReport();
49- readonly localtime = new Localtime();
50- readonly mobileToggle = new MobileToggle();
51- notificationsWorker: NotificationsWorker;
52- readonly osuAudio: OsuAudio;
53- readonly reactTurbolinks: ReactTurbolinks;
54- readonly referenceLinkTooltip = new ReferenceLinkTooltip();
55- readonly scorePins = new ScorePins();
56- @observable scrolling = false;
57- socketWorker: SocketWorker;
58- readonly stickyHeader = new StickyHeader();
59- readonly timeago = new Timeago();
60- readonly turbolinksReload = new TurbolinksReload();
61- readonly userLogin: UserLogin;
62- userLoginObserver: UserLoginObserver;
63- readonly userPreferences = new UserPreferences();
64- readonly userVerification = new UserVerification();
65- windowFocusObserver: WindowFocusObserver;
66- readonly windowSize = new WindowSize();
6768 @computed
69 get currentUserOrFail() {
···75 }
7677 constructor() {
78- // refresh current user on page reload (and initial page load)
79- $(document).on('turbolinks:load.osu-core', this.onPageLoad);
0000080 $.subscribe('user:update', this.onCurrentUserUpdate);
81000000000000000000082 this.enchant = new Enchant(this.turbolinksReload);
83 this.osuAudio = new OsuAudio(this.userPreferences);
84- this.reactTurbolinks = new ReactTurbolinks(this.turbolinksReload);
85 this.userLogin = new UserLogin(this.captcha);
86 // should probably figure how to conditionally or lazy initialize these so they don't all init when not needed.
87 // TODO: requires dynamic imports to lazy load modules.
···95 this.notificationsWorker = new NotificationsWorker(this.socketWorker);
9697 makeObservable(this);
000098 }
99100- private onCurrentUserUpdate = (event: unknown, user: CurrentUserJson) => {
101- this.setCurrentUser(user);
000000102 };
103104- private onPageLoad = () => {
105- this.setCurrentUser(window.currentUser);
106 };
107108 @action
109- private setCurrentUser = (userOrEmpty: typeof window.currentUser) => {
110 const user = userOrEmpty.id == null ? undefined : userOrEmpty;
111112 if (user != null) {
···31import NotificationsWorker from 'notifications/worker';
32import SocketWorker from 'socket-worker';
33import RootDataStore from 'stores/root-data-store';
34+import { parseJsonNullable } from 'utils/json';
3536// will this replace main.coffee eventually?
37export default class OsuCore {
38+ readonly beatmapsetSearchController;
39+ readonly captcha;
40+ readonly chatWorker;
41+ readonly clickMenu;
42 @observable currentUser?: CurrentUserJson;
43+ readonly currentUserModel;
44+ readonly dataStore;
45+ readonly enchant;
46+ readonly forumPoll;
47+ readonly forumPostEdit;
48+ readonly forumPostInput;
49+ readonly forumPostReport;
50+ readonly localtime;
51+ readonly mobileToggle;
52+ readonly notificationsWorker;
53+ readonly osuAudio;
54+ readonly reactTurbolinks;
55+ readonly referenceLinkTooltip;
56+ readonly scorePins;
57+ readonly socketWorker;
58+ readonly stickyHeader;
59+ readonly timeago;
60+ readonly turbolinksReload;
61+ readonly userLogin;
62+ readonly userLoginObserver;
63+ readonly userPreferences;
64+ readonly userVerification;
65+ readonly windowFocusObserver;
66+ readonly windowSize;
06768 @computed
69 get currentUserOrFail() {
···75 }
7677 constructor() {
78+ // Set current user on first page load. Further updates are done in
79+ // reactTurbolinks before the new page is rendered.
80+ // This needs to be fired before everything else (turbolinks:load etc).
81+ const isLoading = document.readyState === 'loading';
82+ if (isLoading) {
83+ document.addEventListener('DOMContentLoaded', this.updateCurrentUser);
84+ }
85 $.subscribe('user:update', this.onCurrentUserUpdate);
8687+ this.captcha = new Captcha();
88+ this.chatWorker = new ChatWorker();
89+ this.clickMenu = new ClickMenu();
90+ this.currentUserModel = new UserModel(this);
91+ this.forumPoll = new ForumPoll();
92+ this.forumPostEdit = new ForumPostEdit();
93+ this.forumPostInput = new ForumPostInput();
94+ this.forumPostReport = new ForumPostReport();
95+ this.localtime = new Localtime();
96+ this.mobileToggle = new MobileToggle();
97+ this.referenceLinkTooltip = new ReferenceLinkTooltip();
98+ this.scorePins = new ScorePins();
99+ this.stickyHeader = new StickyHeader();
100+ this.timeago = new Timeago();
101+ this.turbolinksReload = new TurbolinksReload();
102+ this.userPreferences = new UserPreferences();
103+ this.userVerification = new UserVerification();
104+ this.windowSize = new WindowSize();
105+106 this.enchant = new Enchant(this.turbolinksReload);
107 this.osuAudio = new OsuAudio(this.userPreferences);
108+ this.reactTurbolinks = new ReactTurbolinks(this, this.turbolinksReload);
109 this.userLogin = new UserLogin(this.captcha);
110 // should probably figure how to conditionally or lazy initialize these so they don't all init when not needed.
111 // TODO: requires dynamic imports to lazy load modules.
···119 this.notificationsWorker = new NotificationsWorker(this.socketWorker);
120121 makeObservable(this);
122+123+ if (!isLoading) {
124+ this.updateCurrentUser();
125+ }
126 }
127128+ readonly updateCurrentUser = () => {
129+ // Remove from DOM so only new data is parsed on navigation.
130+ const currentUser = parseJsonNullable<typeof window.currentUser>('json-current-user', true);
131+132+ if (currentUser != null) {
133+ window.currentUser = currentUser;
134+ this.setCurrentUser(window.currentUser);
135+ }
136 };
137138+ private readonly onCurrentUserUpdate = (event: unknown, user: CurrentUserJson) => {
139+ this.setCurrentUser(user);
140 };
141142 @action
143+ private readonly setCurrentUser = (userOrEmpty: typeof window.currentUser) => {
144 const user = userOrEmpty.id == null ? undefined : userOrEmpty;
145146 if (user != null) {
+1-1
resources/js/register-components.tsx
···5import BeatmapsetEvents, { Props as BeatmapsetEventsProps } from 'components/beatmapset-events';
6import BlockButton from 'components/block-button';
7import ChatIcon from 'components/chat-icon';
8-import { Comments } from 'components/comments';
9import { CommentsManager, Props as CommentsManagerProps } from 'components/comments-manager';
10import CountdownTimer from 'components/countdown-timer';
11import { LandingNews } from 'components/landing-news';
···5import BeatmapsetEvents, { Props as BeatmapsetEventsProps } from 'components/beatmapset-events';
6import BlockButton from 'components/block-button';
7import ChatIcon from 'components/chat-icon';
8+import Comments from 'components/comments';
9import { CommentsManager, Props as CommentsManagerProps } from 'components/comments-manager';
10import CountdownTimer from 'components/countdown-timer';
11import { LandingNews } from 'components/landing-news';
···175 ],
176177 'nominations' => [
00178 'delete' => 'Delete',
179 'delete_own_confirm' => 'Are you sure? The beatmap will be deleted and you will be redirected back to your profile.',
180 'delete_other_confirm' => 'Are you sure? The beatmap will be deleted and you will be redirected back to the user\'s profile.',
···175 ],
176177 'nominations' => [
178+ 'already_nominated' => 'You\'ve already nominated this beatmap.',
179+ 'cannot_nominate' => 'You cannot nominate this beatmap game mode.',
180 'delete' => 'Delete',
181 'delete_own_confirm' => 'Are you sure? The beatmap will be deleted and you will be redirected back to your profile.',
182 'delete_other_confirm' => 'Are you sure? The beatmap will be deleted and you will be redirected back to the user\'s profile.',
···2// See the LICENCE file in the repository root for full licence text.
34import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json';
0005import User from 'models/user';
6import * as moment from 'moment';
7-import { discussionMode, maxLengthTimeline, nearbyDiscussions, validMessageLength } from 'utils/beatmapset-discussion-helper';
0000089const template: BeatmapsetDiscussionJson = Object.freeze({
10 beatmap_id: 1,
···76 cases.forEach((test) => {
77 it(test.description, () => {
78 expect(discussionMode(test.json)).toBe(test.expected);
0000000000000000000000000000000000000000000000000000000000000000000000000000000079 });
80 });
81 });
···2// See the LICENCE file in the repository root for full licence text.
34import BeatmapsetDiscussionJson from 'interfaces/beatmapset-discussion-json';
5+import GameMode from 'interfaces/game-mode';
6+import UserGroupJson from 'interfaces/user-group-json';
7+import UserJson from 'interfaces/user-json';
8import User from 'models/user';
9import * as moment from 'moment';
10+import { discussionMode, isUserFullNominator, maxLengthTimeline, nearbyDiscussions, validMessageLength } from 'utils/beatmapset-discussion-helper';
11+12+interface TestCase<T> {
13+ description: string;
14+ expected: T;
15+}
1617const template: BeatmapsetDiscussionJson = Object.freeze({
18 beatmap_id: 1,
···84 cases.forEach((test) => {
85 it(test.description, () => {
86 expect(discussionMode(test.json)).toBe(test.expected);
87+ });
88+ });
89+ });
90+91+ describe('.isUserFullNominator', () => {
92+ const userTemplate = currentUser.toJson();
93+ const groupsTemplate: UserGroupJson = {
94+ colour: null,
95+ has_listing: true,
96+ has_playmodes: true,
97+ id: 1,
98+ identifier: 'placeholder',
99+ is_probationary: false,
100+ name: 'test',
101+ short_name: 'test',
102+ };
103+104+ const allowedGroups = ['bng', 'nat'];
105+ const unallowedGroups = ['admin', 'bng_limited', 'bot', 'dev', 'loved'];
106+107+ describe('with no gameMode', () => {
108+ const cases = [];
109+ for (const identifier of allowedGroups) {
110+ cases.push({
111+ description: `${identifier} is full nominator`,
112+ expected: true,
113+ user: { ...userTemplate, groups: [{ ...groupsTemplate, identifier }] },
114+ });
115+ }
116+117+ for (const identifier of unallowedGroups) {
118+ cases.push({
119+ description: `${identifier} is not full nominator`,
120+ expected: false,
121+ user: { ...userTemplate, groups: [{ ...groupsTemplate, identifier }] },
122+ });
123+ }
124+125+ cases.push({
126+ description: 'groupless is not full nominator',
127+ expected: false,
128+ user: { ...userTemplate },
129+ });
130+131+ cases.forEach((test) => {
132+ it(test.description, () => {
133+ expect(isUserFullNominator(test.user)).toBe(test.expected);
134+ });
135+ });
136+ });
137+138+ describe('with gameMode', () => {
139+ const cases: (TestCase<boolean> & { gameMode: GameMode; user: UserJson })[] = [];
140+ for (const identifier of allowedGroups) {
141+ cases.push({
142+ description: `${identifier} with matching playmode is full nominator`,
143+ expected: true,
144+ gameMode: 'osu',
145+ user: { ...userTemplate, groups: [{ ...groupsTemplate, identifier, playmodes: ['osu'] }] },
146+ });
147+148+ cases.push({
149+ description: `${identifier} without matching playmode is not full nominator`,
150+ expected: false,
151+ gameMode: 'osu',
152+ user: { ...userTemplate, groups: [{ ...groupsTemplate, identifier, playmodes: ['taiko'] }] },
153+ });
154+155+ cases.push({
156+ description: `${identifier} without playmodes is not full nominator`,
157+ expected: false,
158+ gameMode: 'osu',
159+ user: { ...userTemplate, groups: [{ ...groupsTemplate, identifier }] },
160+ });
161+ }
162+163+ cases.forEach((test) => {
164+ it(test.description, () => {
165+ expect(isUserFullNominator(test.user, test.gameMode)).toBe(test.expected);
166+ });
167 });
168 });
169 });