From cce4c41d97fe01c400d242b41b77ea83e6dda21b Mon Sep 17 00:00:00 2001 From: Felipe Mateus Date: Thu, 20 Mar 2025 12:47:48 -0300 Subject: [PATCH] pinned posts --- app/Http/Controllers/Api/ApiV1Controller.php | 49 +++++++++++++++++++ app/Http/Controllers/PublicApiController.php | 1 + app/Services/StatusService.php | 44 +++++++++++++++++ .../Api/StatusStatelessTransformer.php | 1 + app/Transformer/Api/StatusTransformer.php | 1 + ...2553_add_pinned_columns_statuses_table.php | 30 ++++++++++++ .../components/partials/post/ContextMenu.vue | 49 +++++++++++++++++++ .../partials/profile/ProfileFeed.vue | 19 +++++++ resources/lang/en/web.php | 12 +++-- resources/lang/pt/web.php | 4 ++ routes/web-api.php | 2 + 11 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php index 0f0254946..f82b2ad23 100644 --- a/app/Http/Controllers/Api/ApiV1Controller.php +++ b/app/Http/Controllers/Api/ApiV1Controller.php @@ -4426,4 +4426,53 @@ class ApiV1Controller extends Controller }) ); } + + /** + * GET /api/v2/statuses/{id}/pin + */ + public function statusPin(Request $request, $id) { + abort_if(! $request->user(), 403); + $status = Status::findOrFail($id); + $user = $request->user(); + + $res = [ + 'status' => false, + 'message' => '' + ]; + + 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); + } + + + return $this->json("")->setStatusCode(400); + } + + + /** + * GET /api/v2/statuses/{id}/unpin + */ + public function statusUnpin(Request $request, $id) { + + abort_if(! $request->user(), 403); + $status = Status::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); + } + + return $this->json("")->setStatusCode(200); + } + } diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index c83fb2a53..c7e681deb 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -725,6 +725,7 @@ class PublicApiController extends Controller ->where('id', $dir, $id) ->whereIn('scope', $visibility) ->limit($limit) + ->orderBy('pinned_order') ->orderByDesc('id') ->get() ->map(function ($s) use ($user) { diff --git a/app/Services/StatusService.php b/app/Services/StatusService.php index de2f4d112..e93e6ebf5 100644 --- a/app/Services/StatusService.php +++ b/app/Services/StatusService.php @@ -11,6 +11,8 @@ use League\Fractal\Serializer\ArraySerializer; class StatusService { const CACHE_KEY = 'pf:services:status:v1.1:'; + const MAX_PINNED = 3; + public static function key($id, $publicOnly = true) { @@ -198,4 +200,46 @@ class StatusService { return InstanceService::totalLocalStatuses(); } + + public static function isPinned($id) + { + $status = Status::find($id); + return $status && $status->whereNotNull("pinned_order")->count() > 0; + } + + public static function totalPins($pid) + { + return Status::whereProfileId($pid)->whereNotNull("pinned_order")->count(); + } + + public static function markPin($id) + { + $status = Status::find($id); + + if (self::isPinned($id)) { + return true; + } + $totalPins = self::totalPins($status->profile_id); + + if ($totalPins >= self::MAX_PINNED) { + return false; + } + + $status->pinned_order = $totalPins + 1; + $status->save(); + + self::refresh($id); + return true; + } + + public static function unmarkPin($id) + { + $status = Status::find($id); + + $status->pinned_order = null; + $status->save(); + + self::refresh($id); + return true; + } } diff --git a/app/Transformer/Api/StatusStatelessTransformer.php b/app/Transformer/Api/StatusStatelessTransformer.php index 9f52ab50a..9de16465b 100644 --- a/app/Transformer/Api/StatusStatelessTransformer.php +++ b/app/Transformer/Api/StatusStatelessTransformer.php @@ -69,6 +69,7 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract 'tags' => StatusHashtagService::statusTags($status->id), 'poll' => $poll, 'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null, + 'pinned' => (bool) $status->pinned_order, ]; } } diff --git a/app/Transformer/Api/StatusTransformer.php b/app/Transformer/Api/StatusTransformer.php index 4a6dc5d7d..4c8520628 100644 --- a/app/Transformer/Api/StatusTransformer.php +++ b/app/Transformer/Api/StatusTransformer.php @@ -71,6 +71,7 @@ class StatusTransformer extends Fractal\TransformerAbstract 'poll' => $poll, 'bookmarked' => BookmarkService::get($pid, $status->id), 'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null, + 'pinned' => (bool) $status->pinned_order, ]; } } diff --git a/database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php b/database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php new file mode 100644 index 000000000..232b7b1a7 --- /dev/null +++ b/database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php @@ -0,0 +1,30 @@ +integer('pinned_order')->nullable()->default(null); + + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + Schema::table('statuses', function (Blueprint $table) { + $table->dropColumn('pinned_order'); + }); + } +}; diff --git a/resources/assets/components/partials/post/ContextMenu.vue b/resources/assets/components/partials/post/ContextMenu.vue index ad94da335..eb2f6aa27 100644 --- a/resources/assets/components/partials/post/ContextMenu.vue +++ b/resources/assets/components/partials/post/ContextMenu.vue @@ -112,6 +112,21 @@ @click.prevent="unarchivePost(status)"> {{ $t('menu.unarchive') }} + + {{ $t('menu.pin') }} + + + + {{ $t('menu.unpin') }} + { + const data = res.data; + if(data.status){ + swal('Success', "Post was pinned successfully!" , 'success'); + }else { + swal('Error', data.message, 'error'); + } + this.closeModals(); + }); + }, + + unpinPost(status) { + if(window.confirm(this.$t('menu.unpinPostConfirm')) == false) { + return; + } + + axios.post('/api/v2/statuses/' + status.id + '/unpin') + .then(res => { + const data = res.data; + if(data.status){ + swal('Success', "Post was unpinned successfully!" , 'success'); + }else { + swal('Error', data.message, 'error'); + } + this.closeModals(); + }); + }, } } diff --git a/resources/assets/components/partials/profile/ProfileFeed.vue b/resources/assets/components/partials/profile/ProfileFeed.vue index c6c69efb0..c0734c3d4 100644 --- a/resources/assets/components/partials/profile/ProfileFeed.vue +++ b/resources/assets/components/partials/profile/ProfileFeed.vue @@ -181,6 +181,9 @@ {{ timeago(s.created_at) }} + + + @@ -219,6 +222,10 @@ {{ timeago(s.created_at) }} + + + + @@ -246,6 +253,9 @@ {{ timeago(s.created_at) }} + + + @@ -1071,6 +1081,7 @@ }); }); }, + } } @@ -1126,6 +1137,14 @@ opacity: 0.6; } + .pinned-overlay-badge { + position: absolute; + top: 10px; + left: 10px; + color: var(--dark); + font-size: 120%; + } + .profile-nav-btns { margin-right: 1rem; diff --git a/resources/lang/en/web.php b/resources/lang/en/web.php index 3acde415b..705f2eaa2 100644 --- a/resources/lang/en/web.php +++ b/resources/lang/en/web.php @@ -129,10 +129,10 @@ return [ 'emptyPosts' => 'We can\'t seem to find any posts', ], - 'menu' => [ - 'viewPost' => 'View Post', - 'viewProfile' => 'View Profile', - 'moderationTools' => 'Moderation Tools', + 'menu' => [ + 'viewPost' => 'View Post', + 'viewProfile' => 'View Profile', + 'moderationTools' => 'Moderation Tools', 'report' => 'Report', 'archive' => 'Archive', 'unarchive' => 'Unarchive', @@ -176,6 +176,10 @@ return [ 'deletePostConfirm' => 'Are you sure you want to delete this post?', 'archivePostConfirm' => 'Are you sure you want to archive this post?', 'unarchivePostConfirm' => 'Are you sure you want to unarchive this post?', + 'pin' => "Pin", + 'unpin' => "Unpin", + 'pinPostConfirm' => 'Are you sure you want to pin this post?', + 'unpinPostConfirm' => 'Are you sure you want to unpin this post?' ], 'story' => [ diff --git a/resources/lang/pt/web.php b/resources/lang/pt/web.php index 2305aac12..05af6c8bf 100644 --- a/resources/lang/pt/web.php +++ b/resources/lang/pt/web.php @@ -176,6 +176,10 @@ return [ 'deletePostConfirm' => 'Tem a certeza que pretende apagar esta publicação?', 'archivePostConfirm' => 'Tem a certeza que pretende arquivar esta publicação?', 'unarchivePostConfirm' => 'Tem a certeza que pretende desarquivar este post?', + 'pin' => "Fixar", + 'unpin' => "Desfixar", + "pinPostConfirm" => "Tem certeza de que deseja fixar esta publicação?", + "unpinPostConfirm" => "Tem certeza de que deseja desafixar esta publicação?" ], 'story' => [ diff --git a/routes/web-api.php b/routes/web-api.php index 4b0595c66..20d12148b 100644 --- a/routes/web-api.php +++ b/routes/web-api.php @@ -58,6 +58,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('discover/tag', 'DiscoverController@getHashtags'); Route::get('statuses/{id}/replies', 'Api\ApiV1Controller@statusReplies'); Route::get('statuses/{id}/state', 'Api\ApiV1Controller@statusState'); + Route::post('statuses/{id}/pin', 'Api\ApiV1Controller@statusPin'); + Route::post('statuses/{id}/unpin', 'Api\ApiV1Controller@statusUnpin'); }); Route::group(['prefix' => 'pixelfed'], function() {