the browser-facing portion of osu!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add support to follow user modding activities

nanaya 4d85d405 02dc793d

+361 -40
+22
app/Http/Controllers/InterOp/BeatmapsetsController.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + namespace App\Http\Controllers\InterOp; 7 + 8 + use App\Http\Controllers\Controller; 9 + use App\Jobs\Notifications\UserBeatmapsetNew; 10 + use App\Models\Beatmapset; 11 + 12 + class BeatmapsetsController extends Controller 13 + { 14 + public function broadcastNew($id) 15 + { 16 + $beatmapset = Beatmapset::findOrFail($id); 17 + 18 + (new UserBeatmapsetNew($beatmapset))->dispatch(); 19 + 20 + return response(null, 204); 21 + } 22 + }
+53
app/Jobs/Notifications/UserBeatmapsetNew.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + namespace App\Jobs\Notifications; 7 + 8 + use App\Models\Beatmapset; 9 + use App\Models\Follow; 10 + use App\Models\Notification; 11 + use App\Models\UserNotificationOption; 12 + 13 + class UserBeatmapsetNew extends BroadcastNotificationBase 14 + { 15 + const NOTIFICATION_OPTION_NAME = UserNotificationOption::BEATMAPSET_MODDING; 16 + 17 + protected $beatmapset; 18 + 19 + public static function getMailLink(Notification $notification): string 20 + { 21 + return route('beatmapsets.show', $notification->details['beatmapset_id']); 22 + } 23 + 24 + public function __construct(Beatmapset $beatmapset) 25 + { 26 + parent::__construct($beatmapset->user); 27 + 28 + $this->beatmapset = $beatmapset; 29 + } 30 + 31 + public function getDetails(): array 32 + { 33 + return [ 34 + 'beatmapset_id' => $this->beatmapset->getKey(), 35 + 'title' => $this->beatmapset->title, 36 + 'title_unicode' => $this->beatmapset->title_unicode, 37 + 'cover_url' => $this->beatmapset->coverURL('card'), 38 + ]; 39 + } 40 + 41 + public function getListeningUserIds(): array 42 + { 43 + return Follow::whereNotifiable($this->beatmapset->user) 44 + ->where(['subtype' => 'mapping']) 45 + ->pluck('user_id') 46 + ->all(); 47 + } 48 + 49 + public function getNotifiable() 50 + { 51 + return $this->beatmapset->user; 52 + } 53 + }
+9 -6
app/Models/Follow.php
··· 19 19 { 20 20 use Validatable; 21 21 22 + const SUBTYPES = [ 23 + 'comment' => Comment::COMMENTABLES, 24 + 25 + 'modding' => [ 26 + MorphMap::MAP[User::class], 27 + ], 28 + ]; 29 + 22 30 public function scopeWhereNotifiable($query, $notifiable) 23 31 { 24 32 $query->where([ ··· 69 77 $this->validationErrors()->add('notifiable', 'required'); 70 78 } 71 79 72 - if ($this->subtype === 'comment' && !in_array($this->notifiable_type, Comment::COMMENTABLES, true)) { 73 - $this->validationErrors()->add('notifiable_type', '.invalid'); 74 - } 75 - 76 - // FIXME: this should accept other types later. 77 - if ($this->subtype !== 'comment') { 80 + if (!in_array($this->notifiable_type, static::SUBTYPES[$this->subtype] ?? [], true)) { 78 81 $this->validationErrors()->add('subtype', '.invalid'); 79 82 } 80 83
+2
app/Models/Notification.php
··· 42 42 const COMMENT_NEW = 'comment_new'; 43 43 const FORUM_TOPIC_REPLY = 'forum_topic_reply'; 44 44 const USER_ACHIEVEMENT_UNLOCK = 'user_achievement_unlock'; 45 + const USER_BEATMAPSET_NEW = 'user_beatmapset_new'; 45 46 46 47 const NAME_TO_CATEGORY = [ 47 48 self::BEATMAPSET_DISCUSSION_LOCK => 'beatmapset_discussion', ··· 60 61 self::COMMENT_NEW => 'comment', 61 62 self::FORUM_TOPIC_REPLY => 'forum_topic_reply', 62 63 self::USER_ACHIEVEMENT_UNLOCK => 'user_achievement_unlock', 64 + self::USER_BEATMAPSET_NEW => 'user_modding', 63 65 ]; 64 66 65 67 const NOTIFIABLE_CLASSES = [
+9 -1
app/Models/User.php
··· 1551 1551 // TODO: we should rename this to currentUserJson or something. 1552 1552 public function defaultJson() 1553 1553 { 1554 - return json_item($this, 'User', ['blocks', 'friends', 'groups', 'is_admin', 'unread_pm_count', 'user_preferences']); 1554 + return json_item($this, 'User', [ 1555 + 'blocks', 1556 + 'follow_user_modding', 1557 + 'friends', 1558 + 'groups', 1559 + 'is_admin', 1560 + 'unread_pm_count', 1561 + 'user_preferences', 1562 + ]); 1555 1563 } 1556 1564 1557 1565 public function supportLength()
+17 -5
app/Transformers/UserCompactTransformer.php
··· 5 5 6 6 namespace App\Transformers; 7 7 8 + use App\Libraries\MorphMap; 8 9 use App\Models\Beatmap; 9 10 use App\Models\User; 10 11 use App\Models\UserProfileCustomization; ··· 35 36 'cover', 36 37 'current_mode_rank', 37 38 'favourite_beatmapset_count', 39 + 'follow_user_modding', 38 40 'follower_count', 39 41 'friends', 40 42 'graveyard_beatmapset_count', ··· 174 176 return $this->primitive($user->profileBeatmapsetsFavourite()->count()); 175 177 } 176 178 177 - public function includeFollowerCount(User $user) 178 - { 179 - return $this->primitive($user->followerCount()); 180 - } 181 - 182 179 public function includeFriends(User $user) 183 180 { 184 181 return $this->collection( 185 182 $user->relations()->friends()->withMutual()->get(), 186 183 new UserRelationTransformer() 187 184 ); 185 + } 186 + 187 + public function includeFollowUserModding(User $user) 188 + { 189 + return $this->primitive( 190 + $user->follows()->where([ 191 + 'notifiable_type' => MorphMap::getType($user), 192 + 'subtype' => 'modding', 193 + ])->pluck('notifiable_id') 194 + ); 195 + } 196 + 197 + public function includeFollowerCount(User $user) 198 + { 199 + return $this->primitive($user->followerCount()); 188 200 } 189 201 190 202 public function includeGraveyardBeatmapsetCount(User $user)
+39
database/migrations/2020_11_26_055928_add_modding_and_user_to_follows.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + use Illuminate\Database\Migrations\Migration; 7 + 8 + class AddModdingAndUserToFollows extends Migration 9 + { 10 + /** 11 + * Run the migrations. 12 + * 13 + * @return void 14 + */ 15 + public function up() 16 + { 17 + DB::statement("ALTER TABLE follows 18 + MODIFY COLUMN notifiable_type 19 + enum('beatmapset', 'build', 'news_post', 'user')"); 20 + DB::statement("ALTER TABLE follows 21 + MODIFY COLUMN subtype 22 + enum('comment', 'modding')"); 23 + } 24 + 25 + /** 26 + * Reverse the migrations. 27 + * 28 + * @return void 29 + */ 30 + public function down() 31 + { 32 + DB::statement("ALTER TABLE follows 33 + MODIFY COLUMN notifiable_type 34 + enum('beatmapset', 'build', 'news_post')"); 35 + DB::statement("ALTER TABLE follows 36 + MODIFY COLUMN subtype 37 + enum('comment')"); 38 + } 39 + }
+6
resources/assets/coffee/react/profile-page/detail-bar.coffee
··· 3 3 4 4 import { Rank } from './rank' 5 5 import { BlockButton } from 'block-button' 6 + import FollowUserModdingButton from 'follow-user-modding-button' 6 7 import { FriendButton } from 'friend-button' 7 8 import * as React from 'react' 8 9 import { a, button, div, dd, dl, dt, h1, i, img, li, span, ul } from 'react-dom-factories' ··· 51 52 followers: @props.user.follower_count 52 53 modifiers: ['profile-page'] 53 54 alwaysVisible: true 55 + 56 + if @state.currentUser.id != @props.user.id 57 + div className: "#{bn}__entry", 58 + el FollowUserModdingButton, userId: @props.user.id, modifiers: ['profile-page'] 59 + 54 60 if @state.currentUser.id != @props.user.id && !isBlocked 55 61 div className: "#{bn}__entry", 56 62 a
+1 -1
resources/assets/less/bem/notification-popup-item.less
··· 77 77 border-radius: @border-radius-large 0 0 @border-radius-large; 78 78 background-color: fade(#000, 60%); 79 79 80 - .@{_top}--compact & { 80 + .@{_top}--compact.@{_top}--user_achievement_unlock & { 81 81 background-color: transparent; 82 82 } 83 83 }
+118
resources/assets/lib/follow-user-modding-button.tsx
··· 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 { route } from 'laroute'; 5 + import { without } from 'lodash'; 6 + import * as React from 'react'; 7 + import { Spinner } from 'spinner'; 8 + import { classWithModifiers, Modifiers } from 'utils/css'; 9 + 10 + interface Props { 11 + modifiers?: Modifiers; 12 + userId: number; 13 + } 14 + 15 + interface State { 16 + follow: boolean; 17 + loading: boolean; 18 + } 19 + 20 + const bn = 'user-action-button'; 21 + 22 + export default class FollowUserModdingButton extends React.Component<Props, State> { 23 + private buttonRef = React.createRef<HTMLButtonElement>(); 24 + private eventId = `follow-user-modding-button-${osu.uuid()}`; 25 + private xhr: JQueryXHR | null = null; 26 + 27 + constructor(props: Props) { 28 + super(props); 29 + 30 + this.state = { 31 + follow: currentUser.follow_user_modding.includes(this.props.userId), 32 + loading: false, 33 + }; 34 + } 35 + 36 + componentDidMount() { 37 + $.subscribe(`followUserModding:refresh.${this.eventId}`, this.refresh); 38 + } 39 + 40 + componentWillUnmount() { 41 + $.unsubscribe(`.${this.eventId}`); 42 + this.xhr?.abort(); 43 + } 44 + 45 + render() { 46 + if (currentUser.id === this.props.userId) { 47 + return null; 48 + } 49 + 50 + const title = osu.trans(`follows.user.modding.${this.state.follow ? 'to_0' : 'to_1'}`); 51 + 52 + let blockClass = classWithModifiers(bn, this.props.modifiers); 53 + blockClass += classWithModifiers(bn, { friend: this.state.follow }, true); 54 + 55 + return ( 56 + <div title={title}> 57 + <button 58 + className={blockClass} 59 + disabled={this.state.loading} 60 + onClick={this.onClick} 61 + ref={this.buttonRef} 62 + > 63 + {this.renderIcon()} 64 + </button> 65 + </div> 66 + ); 67 + } 68 + 69 + private onClick = () => { 70 + this.setState({ loading: true }, () => { 71 + const params: JQuery.AjaxSettings = { 72 + data: { 73 + follow: { 74 + notifiable_id: this.props.userId, 75 + notifiable_type: 'user', 76 + subtype: 'modding', 77 + }, 78 + }, 79 + }; 80 + 81 + if (this.state.follow) { 82 + params.type = 'DELETE'; 83 + params.url = route('follows.destroy'); 84 + } else { 85 + params.type = 'POST'; 86 + params.url = route('follows.store'); 87 + } 88 + 89 + this.xhr = $.ajax(params) 90 + .done(this.updateData) 91 + .fail(osu.emitAjaxError(this.buttonRef.current)) 92 + .always(() => this.setState({ loading: false })); 93 + }); 94 + } 95 + 96 + private refresh = () => { 97 + this.setState({ 98 + follow: currentUser.follow_user_modding.includes(this.props.userId), 99 + }); 100 + } 101 + 102 + private renderIcon() { 103 + return (this.state.loading 104 + ? <Spinner /> 105 + : <i className='fas fa-bell' /> 106 + ); 107 + } 108 + 109 + private updateData = () => { 110 + if (this.state.follow) { 111 + currentUser.follow_user_modding = without(currentUser.follow_user_modding, this.props.userId); 112 + } else { 113 + currentUser.follow_user_modding = currentUser.follow_user_modding.concat(this.props.userId); 114 + } 115 + 116 + $.publish('followUserModding:refresh'); 117 + } 118 + }
+4 -1
resources/assets/lib/globals.d.ts
··· 59 59 unsubscribe: (eventName: string) => void; 60 60 } 61 61 62 + type AjaxError = (xhr: JQueryXHR) => void; 63 + 62 64 interface OsuCommon { 63 - ajaxError: (xhr: JQueryXHR) => void; 65 + ajaxError: AjaxError; 64 66 classWithModifiers: (baseName: string, modifiers?: string[]) => string; 65 67 diffColour: (difficultyRating?: string | null) => React.CSSProperties; 68 + emitAjaxError: (el?: HTMLElement | null) => AjaxError; 66 69 groupColour: (group?: GroupJson) => React.CSSProperties; 67 70 isClickable: (el: HTMLElement) => boolean; 68 71 jsonClone: (obj: any) => any;
+1
resources/assets/lib/interfaces/current-user.ts
··· 8 8 9 9 export default interface CurrentUser extends UserJsonExtended { 10 10 blocks: UserRelationJson[]; 11 + follow_user_modding: number[]; 11 12 follower_count?: number; 12 13 friends: UserRelationJson[]; 13 14 groups: GroupJson[];
+1
resources/assets/lib/notification-maps/category.ts
··· 42 42 forum_topic_reply: 'forum_topic_reply', 43 43 legacy_pm: 'legacy_pm', 44 44 user_achievement_unlock: 'user_achievement_unlock', 45 + user_beatmapset_new: 'user_beatmapset_new', 45 46 };
+3
resources/assets/lib/notification-maps/icons.ts
··· 14 14 forum_topic_reply: ['fas fa-comment-medical'], 15 15 legacy_pm: ['fas fa-envelope'], 16 16 user_achievement_unlock: ['fas fa-medal'], 17 + user_beatmapset_new: ['fas fa-music'], 17 18 }; 18 19 19 20 export const nameToIcons: IconsMap = { ··· 34 35 forum_topic_reply: ['fas fa-comment-medical'], 35 36 legacy_pm: ['fas fa-envelope'], 36 37 user_achievement_unlock: ['fas fa-trophy'], 38 + user_beatmapset_new: ['fas fa-music'], 37 39 }; 38 40 39 41 export const nameToIconsCompact: IconsMap = { ··· 53 55 comment_reply: ['fas fa-reply'], 54 56 forum_topic_reply: ['fas fa-comment-medical'], 55 57 legacy_pm: ['fas fa-envelope'], 58 + user_beatmapset_new: ['fas fa-music'], 56 59 };
+6 -2
resources/assets/lib/notification-maps/message.ts
··· 50 50 return osu.trans(key, replacements); 51 51 } 52 52 53 - if (item.name === 'user_achievement_unlock') { 54 - return osu.trans(`notifications.item.${item.displayType}.${item.category}.${item.name}_group`); 53 + if (item.name === 'user_achievement_unlock' || item.name === 'user_beatmapset_new') { 54 + const replacements = { 55 + username: item.details.username, 56 + }; 57 + 58 + return osu.trans(`notifications.item.${item.displayType}.${item.category}.${item.name}_group`, replacements); 55 59 } 56 60 57 61 return item.title;
+4
resources/assets/lib/notification-maps/type.ts
··· 16 16 return 'user_achievement'; 17 17 } 18 18 19 + if (item.name === 'user_beatmapset_new') { 20 + return 'user_modding'; 21 + } 22 + 19 23 return item.objectType; 20 24 }
+4
resources/assets/lib/notification-maps/url.ts
··· 16 16 } 17 17 } else if (item.name === 'user_achievement_unlock') { 18 18 return userAchievementUrl(item); 19 + } else if (item.name === 'user_beatmapset_new') { 20 + return `${route('users.show', { user: item.objectId })}#beatmaps`; 19 21 } 20 22 21 23 switch (item.objectType) { ··· 58 60 return route('forum.posts.show', { post: item.details.postId }); 59 61 case 'user_achievement_unlock': 60 62 return userAchievementUrl(item); 63 + case 'user_beatmapset_new': 64 + return route('beatmapsets.show', { beatmapset: item.details.beatmapsetId }); 61 65 } 62 66 } 63 67
+1 -1
resources/assets/lib/notification-widget/item-compact.tsx
··· 28 28 modifiers={['compact']} 29 29 url={urlSingular(this.props.item)} 30 30 withCategory={false} 31 - withCoverImage={this.props.item.name === 'user_achievement_unlock'} 31 + withCoverImage={this.props.item.name === 'user_achievement_unlock' || this.props.item.name === 'user_beatmapset_new'} 32 32 /> 33 33 ); 34 34 }
+20 -13
resources/assets/lib/user-card.tsx
··· 3 3 4 4 import { BlockButton } from 'block-button'; 5 5 import FlagCountry from 'flag-country'; 6 + import FollowUserModdingButton from 'follow-user-modding-button'; 6 7 import { FriendButton } from 'friend-button'; 7 8 import UserJson from 'interfaces/user-json'; 8 9 import { route } from 'laroute'; ··· 203 204 <FlagCountry country={this.user.country} /> 204 205 </a> 205 206 206 - { 207 - this.props.mode === 'card' && this.user.is_supporter ? 208 - <a className='user-card__icon' href={route('support-the-game')}> 209 - <SupporterIcon modifiers={['user-card']}/> 210 - </a> : null 211 - } 212 - 213 - { 214 - this.props.mode === 'card' ? 215 - <div className='user-card__icon'> 216 - <FriendButton userId={this.user.id} modifiers={['user-card']} /> 217 - </div> : null 218 - } 207 + {this.props.mode === 'card' && ( 208 + <> 209 + {this.user.is_supporter && ( 210 + <a className='user-card__icon' href={route('support-the-game')}> 211 + <SupporterIcon modifiers={['user-card']}/> 212 + </a> 213 + )} 214 + <div className='user-card__icon'> 215 + <FriendButton userId={this.user.id} modifiers={['user-card']} /> 216 + </div> 217 + <div className='user-card__icon'> 218 + <FollowUserModdingButton userId={this.user.id} modifiers={['user-card']} /> 219 + </div> 220 + </> 221 + )} 219 222 </div> 220 223 ); 221 224 } ··· 233 236 234 237 <div className='user-card__icon'> 235 238 <FriendButton userId={this.user.id} modifiers={['user-list']} /> 239 + </div> 240 + 241 + <div className='user-card__icon'> 242 + <FollowUserModdingButton userId={this.user.id} modifiers={['user-list']} /> 236 243 </div> 237 244 </div> 238 245 );
+3 -3
resources/assets/lib/utils/css.ts
··· 3 3 4 4 import { forEach } from 'lodash'; 5 5 6 - type Modifiers = (string | null | undefined)[] | Record<string, boolean | null | undefined>; 6 + export type Modifiers = (string | null | undefined)[] | Record<string, boolean | null | undefined>; 7 7 8 - export function classWithModifiers(className: string, modifiers?: Modifiers) { 9 - let ret = className; 8 + export function classWithModifiers(className: string, modifiers?: Modifiers, modifiersOnly = false) { 9 + let ret = modifiersOnly ? '' : className; 10 10 11 11 if (modifiers != null) { 12 12 if (Array.isArray(modifiers)) {
+13
resources/lang/en/follows.php
··· 1 + <?php 2 + 3 + // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4 + // See the LICENCE file in the repository root for full licence text. 5 + 6 + return [ 7 + 'user' => [ 8 + 'modding' => [ 9 + 'to_0' => 'stop notifying me when this user uploads new beatmap', 10 + 'to_1' => 'notify me when this user uploads new beatmap', 11 + ], 12 + ], 13 + ];
+14
resources/lang/en/notifications.php
··· 143 143 'user_achievement_unlock_group' => 'Medals unlocked!', 144 144 ], 145 145 ], 146 + 147 + 'user_modding' => [ 148 + 'user_beatmapset_new' => [ 149 + '_' => 'New beatmap', 150 + 151 + 'user_beatmapset_new' => 'New beatmap ":title" by :username', 152 + 'user_beatmapset_new_compact' => 'New beatmap ":title"', 153 + 'user_beatmapset_new_group' => 'New beatmaps by :username', 154 + ], 155 + ], 146 156 ], 147 157 148 158 'mail' => [ ··· 200 210 'user_achievement_unlock' => [ 201 211 'user_achievement_unlock' => ':username has unlocked a new medal, ":title"!', 202 212 'user_achievement_unlock_self' => 'You\'ve unlocked a new medal, ":title"!', 213 + ], 214 + 215 + 'user_modding' => [ 216 + 'user_beatmapset_new' => ':username has created new beatmaps', 203 217 ], 204 218 ], 205 219 ],
+11 -7
routes/web.php
··· 461 461 Route::get('/news', 'LegacyInterOpController@news'); 462 462 Route::apiResource('users', 'InterOp\UsersController', ['only' => ['store']]); 463 463 464 - Route::group(['as' => 'indexing.', 'prefix' => 'indexing'], function () { 465 - Route::apiResource('bulk', 'InterOp\Indexing\BulkController', ['only' => ['store']]); 466 - }); 464 + Route::group(['namespace' => 'InterOp'], function () { 465 + Route::post('beatmapsets/{beatmapset}/broadcast-new', 'BeatmapsetsController@broadcastNew'); 467 466 468 - Route::group(['as' => 'user-groups.'], function () { 469 - Route::post('user-group', 'InterOp\UserGroupsController@store')->name('store'); 470 - Route::delete('user-group', 'InterOp\UserGroupsController@destroy')->name('destroy'); 471 - Route::post('user-default-group', 'InterOp\UserGroupsController@setDefault')->name('store-default'); 467 + Route::group(['as' => 'indexing.', 'prefix' => 'indexing'], function () { 468 + Route::apiResource('bulk', 'Indexing\BulkController', ['only' => ['store']]); 469 + }); 470 + 471 + Route::group(['as' => 'user-groups.'], function () { 472 + Route::post('user-group', 'UserGroupsController@store')->name('store'); 473 + Route::delete('user-group', 'UserGroupsController@destroy')->name('destroy'); 474 + Route::post('user-default-group', 'UserGroupsController@setDefault')->name('store-default'); 475 + }); 472 476 }); 473 477 }); 474 478