Fix pinned posts implementation

pull/5914/head
Daniel Supernault 5 months ago
parent f70e0b4ae0
commit 2f655d0008
No known key found for this signature in database
GPG Key ID: 23740873EE6F76A1

@ -763,7 +763,8 @@ class ApiV1Controller extends Controller
'reblog_of_id', 'reblog_of_id',
'type', 'type',
'id', 'id',
'scope' 'scope',
'pinned_order'
) )
->whereProfileId($profile['id']) ->whereProfileId($profile['id'])
->whereNull('in_reply_to_id') ->whereNull('in_reply_to_id')
@ -4439,49 +4440,56 @@ class ApiV1Controller extends Controller
/** /**
* GET /api/v2/statuses/{id}/pin * GET /api/v2/statuses/{id}/pin
*/ */
public function statusPin(Request $request, $id) { public function statusPin(Request $request, $id)
{
abort_if(! $request->user(), 403); abort_if(! $request->user(), 403);
$status = Status::findOrFail($id);
$user = $request->user(); $user = $request->user();
$status = Status::whereScope('public')->find($id);
$res = [ if (! $status) {
'status' => false, return $this->json(['error' => 'Record not found'], 404);
'message' => '' }
];
if($status->profile_id == $user->profile_id){ if ($status->profile_id != $user->profile_id) {
if(StatusService::markPin($status->id)){ return $this->json(['error' => "Validation failed: Someone else's post cannot be pinned"], 422);
$res['status'] = true;
} else {
$res['message'] = 'Limit pin reached';
}
return $this->json($res)->setStatusCode(200);
} }
$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 * GET /api/v2/statuses/{id}/unpin
*/ */
public function statusUnpin(Request $request, $id) { public function statusUnpin(Request $request, $id)
{
abort_if(! $request->user(), 403); abort_if(! $request->user(), 403);
$status = Status::findOrFail($id); $status = Status::whereScope('public')->findOrFail($id);
$user = $request->user(); $user = $request->user();
if($status->profile_id == $user->profile_id){ if ($status->profile_id != $user->profile_id) {
StatusService::unmarkPin($status->id); return $this->json(['error' => 'Record not found'], 404);
$res = [
'status' => true,
'message' => ''
];
return $this->json($res)->setStatusCode(200);
} }
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);
}
} }

@ -667,10 +667,8 @@ class PublicApiController extends Controller
'only_media' => 'nullable', 'only_media' => 'nullable',
'pinned' => 'nullable', 'pinned' => 'nullable',
'exclude_replies' => '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', 'limit' => 'nullable|integer|min:1|max:24',
'cursor' => 'nullable',
]); ]);
$user = $request->user(); $user = $request->user();
@ -683,84 +681,137 @@ class PublicApiController extends Controller
} }
$limit = $request->limit ?? 9; $limit = $request->limit ?? 9;
$max_id = $request->max_id;
$min_id = $request->min_id;
$scope = ['photo', 'photo:album', 'video', 'video:album']; $scope = ['photo', 'photo:album', 'video', 'video:album'];
$onlyMedia = $request->input('only_media', true); $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) { if ($pinned && ! $hasCursor) {
$min_id = 1; $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 ($profile['locked']) {
if (! $user) { if (! $user) {
return response()->json([]); return [];
} }
$pid = $user->profile_id; $pid = $user->profile_id;
$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) { $isFollowing = Follower::whereProfileId($pid)
$following = Follower::whereProfileId($pid)->pluck('following_id'); ->whereFollowingId($profile['id'])
->exists();
return $following->push($pid)->toArray(); return $isFollowing ? ['public', 'unlisted', 'private'] : ['public'];
});
$visibility = in_array($profile['id'], $following) == true ? ['public', 'unlisted', 'private'] : [];
} else { } else {
if ($user) { if ($user) {
$pid = $user->profile_id; $pid = $user->profile_id;
$following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) { $isFollowing = Follower::whereProfileId($pid)
$following = Follower::whereProfileId($pid)->pluck('following_id'); ->whereFollowingId($profile['id'])
->exists();
return $following->push($pid)->toArray(); return $isFollowing ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
});
$visibility = in_array($profile['id'], $following) == true ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
} else { } else {
$visibility = ['public', 'unlisted']; return ['public', 'unlisted'];
} }
} }
$dir = $min_id ? '>' : '<'; }
$id = $min_id ?? $max_id;
$res = Status::whereProfileId($profile['id']) private function processStatuses($statuses, $user, $onlyMedia)
->whereNull('in_reply_to_id') {
->whereNull('reblog_of_id') return collect($statuses)->map(function ($status) use ($user) {
->whereIn('type', $scope) try {
->where('id', $dir, $id) $mastodonStatus = StatusService::getMastodon($status->id, false);
->whereIn('scope', $visibility) if (! $mastodonStatus) {
->limit($limit) return null;
->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;
} }
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; return $mastodonStatus;
}) } catch (\Exception $e) {
->filter(function ($s) use ($onlyMedia) { return null;
if (! $s) { }
})
->filter(function ($status) use ($onlyMedia) {
if (! $status) {
return false; return false;
} }
if ($onlyMedia) { if ($onlyMedia) {
if ( return isset($status['media_attachments']) &&
! isset($s['media_attachments']) || is_array($status['media_attachments']) &&
! is_array($s['media_attachments']) || ! empty($status['media_attachments']);
empty($s['media_attachments'])
) {
return false;
}
} }
return $s; return true;
}) })
->values(); ->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] : [];
} }
} }

@ -11,8 +11,8 @@ use League\Fractal\Serializer\ArraySerializer;
class StatusService class StatusService
{ {
const CACHE_KEY = 'pf:services:status:v1.1:'; const CACHE_KEY = 'pf:services:status:v1.1:';
const MAX_PINNED = 3;
const MAX_PINNED = 3;
public static function key($id, $publicOnly = true) public static function key($id, $publicOnly = true)
{ {
@ -84,7 +84,6 @@ class StatusService
$status['shortcode'], $status['shortcode'],
$status['taggedPeople'], $status['taggedPeople'],
$status['thread'], $status['thread'],
$status['pinned'],
$status['account']['header_bg'], $status['account']['header_bg'],
$status['account']['is_admin'], $status['account']['is_admin'],
$status['account']['last_fetched_at'], $status['account']['last_fetched_at'],
@ -203,43 +202,86 @@ class StatusService
public static function isPinned($id) public static function isPinned($id)
{ {
$status = Status::find($id); return Status::whereId($id)->whereNotNull('pinned_order')->exists();
return $status && $status->whereNotNull("pinned_order")->count() > 0;
} }
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) public static function markPin($id)
{ {
$status = Status::find($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)) { if (self::isPinned($id)) {
return true; return [
'success' => false,
'error' => 'This post is already pinned',
];
} }
$totalPins = self::totalPins($status->profile_id); $totalPins = self::totalPins($status->profile_id);
if ($totalPins >= self::MAX_PINNED) { 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->pinned_order = $totalPins + 1;
$status->save(); $status->save();
self::refresh($id); self::refresh($id);
return true;
return [
'success' => true,
'error' => null,
];
} }
public static function unmarkPin($id) public static function unmarkPin($id)
{ {
$status = Status::find($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->pinned_order = null;
$status->save(); $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); self::refresh($id);
return true; return true;
} }
} }

@ -85,6 +85,8 @@
:profile="user" :profile="user"
@report-modal="handleReport()" @report-modal="handleReport()"
@delete="deletePost()" @delete="deletePost()"
@pinned="handlePinned()"
@unpinned="handleUnpinned()"
v-on:edit="handleEdit" v-on:edit="handleEdit"
/> />
@ -441,7 +443,15 @@
this.$nextTick(() => { this.$nextTick(() => {
this.forceUpdateIdx++; this.forceUpdateIdx++;
}); });
} },
handlePinned() {
this.post.pinned = true;
},
handleUnpinned() {
this.post.pinned = false;
},
} }
} }
</script> </script>

@ -997,15 +997,23 @@
return; return;
} }
axios.post('/api/v2/statuses/' + status.id + '/pin') this.closeModals();
axios.post('/api/v2/statuses/' + status.id.toString() + '/pin')
.then(res => { .then(res => {
const data = res.data; const data = res.data;
if(data.status){ if(data.id && data.pinned) {
swal('Success', "Post was pinned successfully!" , 'success'); this.$emit('pinned');
}else { swal('Pinned', 'Successfully pinned post to your profile', 'success');
swal('Error', data.message, 'error'); } else {
swal('Error', 'An error occured when attempting to pin', 'error');
} }
})
.catch(err => {
this.closeModals(); 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) { if(window.confirm(this.$t('menu.unpinPostConfirm')) == false) {
return; return;
} }
this.closeModals();
axios.post('/api/v2/statuses/' + status.id + '/unpin') axios.post('/api/v2/statuses/' + status.id.toString() + '/unpin')
.then(res => { .then(res => {
const data = res.data; const data = res.data;
if(data.status){ if(data.id) {
swal('Success', "Post was unpinned successfully!" , 'success'); this.$emit('unpinned');
}else { swal('Unpinned', 'Successfully unpinned post from your profile', 'success');
swal('Error', data.message, 'error'); } else {
swal('Error', data.error, 'error');
} }
})
.catch(err => {
this.closeModals(); this.closeModals();
if(err.response?.data?.error) {
swal('Error', err.response?.data?.error, 'error');
} else {
window.location.reload()
}
}); });
}, },
} }

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save