diff --git a/app/Http/Controllers/Api/ApiV1Controller.php b/app/Http/Controllers/Api/ApiV1Controller.php
index a6fdaaea5..007edaac2 100644
--- a/app/Http/Controllers/Api/ApiV1Controller.php
+++ b/app/Http/Controllers/Api/ApiV1Controller.php
@@ -4435,4 +4435,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') }}
+
+
+
{
+ 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 748ec5a90..860f65e92 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 eb1073947..69d318db4 100644
--- a/resources/lang/en/web.php
+++ b/resources/lang/en/web.php
@@ -165,10 +165,10 @@ return [
'noDescription' => 'No description available'
],
- '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',
@@ -212,6 +212,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 8e5ec8994..d8e74d5ce 100644
--- a/resources/lang/pt/web.php
+++ b/resources/lang/pt/web.php
@@ -208,6 +208,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() {