diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 007edaac2..4497a2d22 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -763,7 +763,8 @@ class ApiV1Controller extends Controller 'reblog_of_id', 'type', 'id', - 'scope' + 'scope', + 'pinned_order' ) ->whereProfileId($profile['id']) ->whereNull('in_reply_to_id') @@ -4439,49 +4440,56 @@ class ApiV1Controller extends Controller /** * GET /api/v2/statuses/{id}/pin */ - public function statusPin(Request $request, $id) { + public function statusPin(Request $request, $id) + { abort_if(! $request->user(), 403); - $status = Status::findOrFail($id); $user = $request->user(); + $status = Status::whereScope('public')->find($id); - $res = [ - 'status' => false, - 'message' => '' - ]; + if (! $status) { + return $this->json(['error' => 'Record not found'], 404); + } - if($status->profile_id == $user->profile_id){ - if(StatusService::markPin($status->id)){ - $res['status'] = true; - } else { - $res['message'] = 'Limit pin reached'; - } - return $this->json($res)->setStatusCode(200); + if ($status->profile_id != $user->profile_id) { + return $this->json(['error' => "Validation failed: Someone else's post cannot be pinned"], 422); } + $res = StatusService::markPin($status->id); - return $this->json("")->setStatusCode(400); - } + if (! $res['success']) { + return $this->json([ + 'error' => $res['error'], + ], 422); + } + + $statusRes = StatusService::get($status->id, true, true); + $status['pinned'] = true; + return $this->json($statusRes); + } /** * GET /api/v2/statuses/{id}/unpin */ - public function statusUnpin(Request $request, $id) { + public function statusUnpin(Request $request, $id) + { abort_if(! $request->user(), 403); - $status = Status::findOrFail($id); + $status = Status::whereScope('public')->findOrFail($id); $user = $request->user(); - if($status->profile_id == $user->profile_id){ - StatusService::unmarkPin($status->id); - $res = [ - 'status' => true, - 'message' => '' - ]; - return $this->json($res)->setStatusCode(200); + if ($status->profile_id != $user->profile_id) { + return $this->json(['error' => 'Record not found'], 404); } - return $this->json("")->setStatusCode(200); - } + $res = StatusService::unmarkPin($status->id); + if (! $res) { + return $this->json($res, 422); + } + + $status = StatusService::get($status->id, true, true); + $status['pinned'] = false; + return $this->json($status); + } } diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index c7e681deb..dbdf4d1f7 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -667,10 +667,8 @@ class PublicApiController extends Controller 'only_media' => 'nullable', 'pinned' => 'nullable', 'exclude_replies' => 'nullable', - 'max_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX, - 'since_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX, - 'min_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX, 'limit' => 'nullable|integer|min:1|max:24', + 'cursor' => 'nullable', ]); $user = $request->user(); @@ -683,84 +681,137 @@ class PublicApiController extends Controller } $limit = $request->limit ?? 9; - $max_id = $request->max_id; - $min_id = $request->min_id; $scope = ['photo', 'photo:album', 'video', 'video:album']; $onlyMedia = $request->input('only_media', true); + $pinned = $request->filled('pinned') && $request->boolean('pinned') == true; + $hasCursor = $request->filled('cursor'); + + $visibility = $this->determineVisibility($profile, $user); + + if (empty($visibility)) { + return response()->json([]); + } + + $result = collect(); + $remainingLimit = $limit; - if (! $min_id && ! $max_id) { - $min_id = 1; + if ($pinned && ! $hasCursor) { + $pinnedStatuses = Status::whereProfileId($profile['id']) + ->whereNotNull('pinned_order') + ->orderBy('pinned_order') + ->get(); + + $pinnedResult = $this->processStatuses($pinnedStatuses, $user, $onlyMedia); + $result = $pinnedResult; + + $remainingLimit = max(1, $limit - $pinnedResult->count()); + } + + $paginator = Status::whereProfileId($profile['id']) + ->whereNull('in_reply_to_id') + ->whereNull('reblog_of_id') + ->when($pinned, function ($query) { + return $query->whereNull('pinned_order'); + }) + ->whereIn('type', $scope) + ->whereIn('scope', $visibility) + ->orderByDesc('id') + ->cursorPaginate($remainingLimit) + ->withQueryString(); + + $headers = $this->generatePaginationHeaders($paginator); + $regularStatuses = $this->processStatuses($paginator->items(), $user, $onlyMedia); + $result = $result->concat($regularStatuses); + + return response()->json($result, 200, $headers); + } + + private function determineVisibility($profile, $user) + { + if ($profile['id'] == $user->profile_id) { + return ['public', 'unlisted', 'private']; } if ($profile['locked']) { if (! $user) { - return response()->json([]); + return []; } + $pid = $user->profile_id; - $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) { - $following = Follower::whereProfileId($pid)->pluck('following_id'); + $isFollowing = Follower::whereProfileId($pid) + ->whereFollowingId($profile['id']) + ->exists(); - return $following->push($pid)->toArray(); - }); - $visibility = in_array($profile['id'], $following) == true ? ['public', 'unlisted', 'private'] : []; + return $isFollowing ? ['public', 'unlisted', 'private'] : ['public']; } else { if ($user) { $pid = $user->profile_id; - $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) { - $following = Follower::whereProfileId($pid)->pluck('following_id'); + $isFollowing = Follower::whereProfileId($pid) + ->whereFollowingId($profile['id']) + ->exists(); - return $following->push($pid)->toArray(); - }); - $visibility = in_array($profile['id'], $following) == true ? ['public', 'unlisted', 'private'] : ['public', 'unlisted']; + return $isFollowing ? ['public', 'unlisted', 'private'] : ['public', 'unlisted']; } else { - $visibility = ['public', 'unlisted']; + return ['public', 'unlisted']; } } - $dir = $min_id ? '>' : '<'; - $id = $min_id ?? $max_id; - $res = Status::whereProfileId($profile['id']) - ->whereNull('in_reply_to_id') - ->whereNull('reblog_of_id') - ->whereIn('type', $scope) - ->where('id', $dir, $id) - ->whereIn('scope', $visibility) - ->limit($limit) - ->orderBy('pinned_order') - ->orderByDesc('id') - ->get() - ->map(function ($s) use ($user) { - try { - $status = StatusService::get($s->id, false); - if (! $status) { - return false; - } - } catch (\Exception $e) { - $status = false; + } + + private function processStatuses($statuses, $user, $onlyMedia) + { + return collect($statuses)->map(function ($status) use ($user) { + try { + $mastodonStatus = StatusService::getMastodon($status->id, false); + if (! $mastodonStatus) { + return null; } - if ($user && $status) { - $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id); + + if ($user) { + $mastodonStatus['favourited'] = (bool) LikeService::liked($user->profile_id, $status->id); } - return $status; - }) - ->filter(function ($s) use ($onlyMedia) { - if (! $s) { + return $mastodonStatus; + } catch (\Exception $e) { + return null; + } + }) + ->filter(function ($status) use ($onlyMedia) { + if (! $status) { return false; } + if ($onlyMedia) { - if ( - ! isset($s['media_attachments']) || - ! is_array($s['media_attachments']) || - empty($s['media_attachments']) - ) { - return false; - } + return isset($status['media_attachments']) && + is_array($status['media_attachments']) && + ! empty($status['media_attachments']); } - return $s; + return true; }) ->values(); + } - return response()->json($res); + /** + * Generate pagination link headers from paginator + */ + private function generatePaginationHeaders($paginator) + { + $link = null; + + if ($paginator->onFirstPage()) { + if ($paginator->hasMorePages()) { + $link = '<'.$paginator->nextPageUrl().'>; rel="prev"'; + } + } else { + if ($paginator->previousPageUrl()) { + $link = '<'.$paginator->previousPageUrl().'>; rel="next"'; + } + + if ($paginator->hasMorePages()) { + $link .= ($link ? ', ' : '').'<'.$paginator->nextPageUrl().'>; rel="prev"'; + } + } + + return isset($link) ? ['Link' => $link] : []; } } diff --git a/app/Services/StatusService.php b/app/Services/StatusService.php index e93e6ebf5..014c76aba 100644 --- a/app/Services/StatusService.php +++ b/app/Services/StatusService.php @@ -11,8 +11,8 @@ use League\Fractal\Serializer\ArraySerializer; class StatusService { const CACHE_KEY = 'pf:services:status:v1.1:'; - const MAX_PINNED = 3; + const MAX_PINNED = 3; public static function key($id, $publicOnly = true) { @@ -84,7 +84,6 @@ class StatusService $status['shortcode'], $status['taggedPeople'], $status['thread'], - $status['pinned'], $status['account']['header_bg'], $status['account']['is_admin'], $status['account']['last_fetched_at'], @@ -203,43 +202,86 @@ class StatusService public static function isPinned($id) { - $status = Status::find($id); - return $status && $status->whereNotNull("pinned_order")->count() > 0; + return Status::whereId($id)->whereNotNull('pinned_order')->exists(); } - public static function totalPins($pid) + public static function totalPins($pid) { - return Status::whereProfileId($pid)->whereNotNull("pinned_order")->count(); + return Status::whereProfileId($pid)->whereNotNull('pinned_order')->count(); } public static function markPin($id) { $status = Status::find($id); + if (! $status) { + return [ + 'success' => false, + 'error' => 'Record not found', + ]; + } + + if ($status->scope != 'public') { + return [ + 'success' => false, + 'error' => 'Validation failed: you can only pin public posts', + ]; + } + if (self::isPinned($id)) { - return true; + return [ + 'success' => false, + 'error' => 'This post is already pinned', + ]; } + $totalPins = self::totalPins($status->profile_id); if ($totalPins >= self::MAX_PINNED) { - return false; + return [ + 'success' => false, + 'error' => 'Validation failed: You have already pinned the max number of posts', + ]; } $status->pinned_order = $totalPins + 1; $status->save(); self::refresh($id); - return true; + + return [ + 'success' => true, + 'error' => null, + ]; } public static function unmarkPin($id) { $status = Status::find($id); + if (! $status || is_null($status->pinned_order)) { + return false; + } + + $removedOrder = $status->pinned_order; + $profileId = $status->profile_id; + $status->pinned_order = null; $status->save(); + Status::where('profile_id', $profileId) + ->whereNotNull('pinned_order') + ->where('pinned_order', '>', $removedOrder) + ->orderBy('pinned_order', 'asc') + ->chunk(10, function ($statuses) { + foreach ($statuses as $s) { + $s->pinned_order = $s->pinned_order - 1; + $s->save(); + } + }); + self::refresh($id); + return true; } } diff --git a/resources/assets/components/Post.vue b/resources/assets/components/Post.vue index 07698fe21..823653c3c 100644 --- a/resources/assets/components/Post.vue +++ b/resources/assets/components/Post.vue @@ -85,6 +85,8 @@ :profile="user" @report-modal="handleReport()" @delete="deletePost()" + @pinned="handlePinned()" + @unpinned="handleUnpinned()" v-on:edit="handleEdit" /> @@ -441,7 +443,15 @@ this.$nextTick(() => { this.forceUpdateIdx++; }); - } + }, + + handlePinned() { + this.post.pinned = true; + }, + + handleUnpinned() { + this.post.pinned = false; + }, } } diff --git a/resources/assets/components/partials/post/ContextMenu.vue b/resources/assets/components/partials/post/ContextMenu.vue index eb2f6aa27..7260f9578 100644 --- a/resources/assets/components/partials/post/ContextMenu.vue +++ b/resources/assets/components/partials/post/ContextMenu.vue @@ -997,15 +997,23 @@ return; } - axios.post('/api/v2/statuses/' + status.id + '/pin') + this.closeModals(); + + axios.post('/api/v2/statuses/' + status.id.toString() + '/pin') .then(res => { const data = res.data; - if(data.status){ - swal('Success', "Post was pinned successfully!" , 'success'); - }else { - swal('Error', data.message, 'error'); + if(data.id && data.pinned) { + this.$emit('pinned'); + swal('Pinned', 'Successfully pinned post to your profile', 'success'); + } else { + swal('Error', 'An error occured when attempting to pin', 'error'); } + }) + .catch(err => { this.closeModals(); + if(err.response?.data?.error) { + swal('Error', err.response?.data?.error, 'error'); + } }); }, @@ -1013,16 +1021,25 @@ if(window.confirm(this.$t('menu.unpinPostConfirm')) == false) { return; } + this.closeModals(); - axios.post('/api/v2/statuses/' + status.id + '/unpin') + axios.post('/api/v2/statuses/' + status.id.toString() + '/unpin') .then(res => { const data = res.data; - if(data.status){ - swal('Success', "Post was unpinned successfully!" , 'success'); - }else { - swal('Error', data.message, 'error'); + if(data.id) { + this.$emit('unpinned'); + swal('Unpinned', 'Successfully unpinned post from your profile', 'success'); + } else { + swal('Error', data.error, 'error'); } + }) + .catch(err => { this.closeModals(); + if(err.response?.data?.error) { + swal('Error', err.response?.data?.error, 'error'); + } else { + window.location.reload() + } }); }, } diff --git a/resources/assets/components/partials/profile/ProfileFeed.vue b/resources/assets/components/partials/profile/ProfileFeed.vue index 860f65e92..2b0ee168e 100644 --- a/resources/assets/components/partials/profile/ProfileFeed.vue +++ b/resources/assets/components/partials/profile/ProfileFeed.vue @@ -1,1185 +1,1201 @@