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
6namespace App\Http\Controllers;
7
8use App\Exceptions\ModelNotSavedException;
9use App\Jobs\UpdateUserMappingFollowerCountCache;
10use App\Models\BeatmapDiscussion;
11use App\Models\Beatmapset;
12use App\Models\Comment;
13use App\Models\Follow;
14use App\Models\Forum\Topic;
15use App\Models\Forum\TopicTrack;
16use App\Models\Forum\TopicWatch;
17use App\Models\User;
18use App\Transformers\FollowCommentTransformer;
19use App\Transformers\FollowModdingTransformer;
20use DB;
21use Exception;
22
23class FollowsController extends Controller
24{
25 private User $user;
26
27 public function __construct()
28 {
29 parent::__construct();
30
31 $this->middleware('auth');
32 }
33
34 public function destroy()
35 {
36 $params = $this->getParams();
37 $follow = Follow::where($params)->first();
38
39 if ($follow !== null) {
40 $follow->delete();
41
42 if ($follow->subtype === 'mapping') {
43 dispatch(new UpdateUserMappingFollowerCountCache($follow->notifiable_id));
44 }
45 }
46
47 return response([], 204);
48 }
49
50 public function index($subtype = null)
51 {
52 $this->user = auth()->user();
53
54 $viewArgs = match ($subtype) {
55 'comment' => $this->indexComment(),
56 'forum_topic' => $this->indexForumTopic(),
57 'mapping' => $this->indexMapping(),
58 'modding' => $this->indexModding(),
59 default => null,
60 };
61
62 if ($viewArgs === null) {
63 return ujs_redirect(route('follows.index', ['subtype' => Follow::DEFAULT_SUBTYPE]));
64 }
65
66 $viewArgs[1]['subtype'] = $subtype;
67
68 return ext_view(...$viewArgs);
69 }
70
71 public function store()
72 {
73 if (\Auth::user()->follows()->count() >= $GLOBALS['cfg']['osu']['user']['max_follows']) {
74 return error_popup(osu_trans('follows.store.too_many'));
75 }
76 $params = $this->getParams();
77 $follow = new Follow($params);
78
79 try {
80 $follow->saveOrExplode();
81 } catch (Exception $e) {
82 if ($e instanceof ModelNotSavedException) {
83 return error_popup($e->getMessage());
84 }
85
86 if (!is_sql_unique_exception($e)) {
87 throw $e;
88 }
89 }
90
91 if ($params['subtype'] === 'mapping') {
92 dispatch(new UpdateUserMappingFollowerCountCache($params['notifiable_id']));
93 }
94
95 return response(null, 204);
96 }
97
98 private function getParams()
99 {
100 $params = get_params(request()->all(), 'follow', ['notifiable_type:string', 'notifiable_id:int', 'subtype:string']);
101 $params['user_id'] = auth()->user()->getKey();
102
103 return $params;
104 }
105
106 private function indexComment()
107 {
108 $followsQuery = Follow::where(['user_id' => $this->user->getKey(), 'subtype' => 'comment']);
109
110 $recentCommentIds = Comment
111 ::selectRaw('MAX(id) latest_comment_id, commentable_type, commentable_id')
112 ->whereIn(
113 DB::raw('(commentable_type, commentable_id)'),
114 (clone $followsQuery)->selectRaw('notifiable_type, notifiable_id')
115 )->groupBy('commentable_type', 'commentable_id')
116 ->pluck('latest_comment_id');
117
118 $comments = Comment
119 ::whereIn('id', $recentCommentIds)
120 ->with('user')
121 ->get()
122 ->keyBy(function ($comment) {
123 return "{$comment->commentable_type}:{$comment->commentable_id}";
124 });
125
126 $follows = (clone $followsQuery)
127 ->with('notifiable')
128 ->get()
129 ->sortByDesc(function ($follow) use ($comments) {
130 $comment = $comments["{$follow->notifiable_type}:{$follow->notifiable_id}"] ?? null;
131
132 return $comment === null ? null : $comment->getKey();
133 });
134
135 $followsTransformer = new FollowCommentTransformer($comments);
136 $followsJson = json_collection($follows, $followsTransformer, ['commentable_meta', 'latest_comment.user']);
137
138 return ['follows.comment', compact('followsJson')];
139 }
140
141 private function indexForumTopic()
142 {
143 $topics = Topic::watchedByUser($this->user)->paginate();
144 $topicReadStatus = TopicTrack::readStatus($this->user, $topics);
145 $topicWatchStatus = TopicWatch::watchStatus($this->user, $topics);
146
147 $counts = [
148 'total' => $topics->total(),
149 'unread' => TopicWatch::unreadCount($this->user),
150 ];
151
152 return [
153 'follows.forum_topic',
154 compact('topics', 'topicReadStatus', 'topicWatchStatus', 'counts'),
155 ];
156 }
157
158 private function indexMapping()
159 {
160 $followsQuery = Follow::where(['user_id' => $this->user->getKey(), 'subtype' => 'mapping']);
161
162 $recentBeatmapsetIds = Beatmapset
163 ::selectRaw('MAX(beatmapset_id) latest_beatmapset_id, user_id')
164 ->whereIn(
165 'user_id',
166 (clone $followsQuery)->select('notifiable_id')
167 )->groupBy('user_id')
168 ->where('approved', '<>', Beatmapset::STATES['wip'])
169 ->pluck('latest_beatmapset_id');
170
171 $beatmapsets = Beatmapset
172 ::whereIn('beatmapset_id', $recentBeatmapsetIds)
173 ->with('beatmaps')
174 ->get()
175 ->keyBy('user_id');
176
177 $follows = (clone $followsQuery)
178 ->with('notifiable')
179 ->get()
180 ->sortByDesc(function ($follow) use ($beatmapsets) {
181 $beatmapset = $beatmapsets[$follow->notifiable_id] ?? null;
182
183 return $beatmapset === null ? null : $beatmapset->getKey();
184 });
185
186 $followsTransformer = new FollowModdingTransformer($beatmapsets);
187 $followsJson = json_collection($follows, $followsTransformer, ['latest_beatmapset.beatmaps', 'user']);
188
189 return ['follows.mapping', compact('followsJson')];
190 }
191
192 private function indexModding()
193 {
194 $watches = $this->user
195 ->beatmapsetWatches()
196 ->visible()
197 ->orderBy('last_notified', 'DESC')
198 ->with('beatmapset')
199 ->paginate();
200 $totalCount = $watches->total();
201 $unreadCount = $this->user->beatmapsetWatches()->visible()->unread()->count();
202 $openIssues = BeatmapDiscussion
203 ::whereIn('beatmapset_id', $watches->pluck('beatmapset_id'))
204 ->openIssues()
205 ->groupBy('beatmapset_id')
206 ->selectRaw('beatmapset_id, COUNT(*) AS open_count')
207 ->get()
208 ->keyBy('beatmapset_id');
209
210 return ['follows.modding', compact('openIssues', 'watches', 'totalCount', 'unreadCount')];
211 }
212}