find($id); } else { $status = Status::whereIn('scope', [ 'public', 'private', 'unlisted', 'group', ])->find($id); } if (! $status) { return null; } $fractal = new Fractal\Manager; $fractal->setSerializer(new ArraySerializer); $resource = new Fractal\Resource\Item( $status, new StatusStatelessTransformer ); $res = $fractal->createData($resource)->toArray(); $res['_pid'] = isset($res['account'], $res['account']['id']) ? $res['account']['id'] : null; if (isset($res['_pid'])) { unset($res['account']); } return $res; }); if (! $res) { return null; } if ($viewerProfileId !== null) { $ownerProfileId = $res['_pid'] ?? null; $visibility = $res['visibility'] ?? null; if (! self::isVisibleTo( $ownerProfileId, $visibility, $viewerProfileId )) { return null; } } if (isset($res['_pid'])) { $res['account'] = $mastodonMode === true ? AccountService::getMastodon($res['_pid'], true) : AccountService::get($res['_pid'], true); unset($res['_pid']); } return $res; } public static function getMastodon( $id, $publicOnly = true, $viewerProfileId = null ) { $status = self::get( $id, $publicOnly, true, $viewerProfileId ); if (! $status || ! isset($status['account'])) { return null; } $status['replies_count'] = $status['reply_count']; if (config('exp.emc') == false) { return $status; } // The shared status nests under `reblog` and needs Mastodon field names too if (! empty($status['reblog'])) { $status['reblog']['replies_count'] = $status['reblog']['reply_count'] ?? 0; $status['reblog']['favourited'] = false; $status['reblog']['muted'] = false; $status['reblog']['reblogged'] = false; } unset( $status['_v'], $status['comments_disabled'], $status['content_text'], $status['gid'], $status['liked_by'], $status['local'], $status['parent'], $status['pf_type'], $status['place'], $status['replies'], $status['reply_count'], $status['shortcode'], $status['taggedPeople'], $status['thread'], $status['account']['header_bg'], $status['account']['is_admin'], $status['account']['last_fetched_at'], $status['account']['local'], $status['account']['location'], $status['account']['note_text'], $status['account']['pronouns'], $status['account']['website'], $status['media_attachments'], ); $status['account']['avatar_static'] = $status['account']['avatar']; $status['account']['bot'] = false; $status['account']['emojis'] = []; $status['account']['fields'] = []; $status['account']['header'] = url('/storage/headers/missing.png'); $status['account']['header_static'] = url('/storage/headers/missing.png'); $status['account']['last_status_at'] = null; $status['media_attachments'] = array_values( MediaService::getMastodon($status['id']) ); $status['muted'] = false; $status['reblogged'] = false; return $status; } /** * Determine whether a status with the given visibility can be * returned to a viewer. */ public static function isVisibleTo( $ownerProfileId, $visibility, $viewerProfileId ) { if (! $ownerProfileId || ! $visibility || ! $viewerProfileId) { return false; } $ownerProfileId = (int) $ownerProfileId; $viewerProfileId = (int) $viewerProfileId; if ($ownerProfileId === $viewerProfileId) { return true; } switch ($visibility) { case 'public': case 'unlisted': return true; case 'private': return FollowerService::follows( $viewerProfileId, $ownerProfileId ); case 'group': default: return false; } } public static function clampReplyVisibility( $replyVisibility, $parentVisibility ): string { if ($parentVisibility === 'group') { return 'group'; } $levels = [ 'private' => 0, 'unlisted' => 1, 'public' => 2, ]; if ( ! isset($levels[$replyVisibility]) || ! isset($levels[$parentVisibility]) ) { return 'private'; } if ($levels[$replyVisibility] > $levels[$parentVisibility]) { return $parentVisibility; } return $replyVisibility; } public static function getState($id, $pid): array { $status = self::get($id, false); if (! $status || ! $pid) { return [ 'liked' => false, 'shared' => false, 'bookmarked' => false, ]; } return [ 'liked' => LikeService::liked($pid, $id), 'shared' => self::isShared($id, $pid), 'bookmarked' => self::isBookmarked($id, $pid), ]; } public static function getFull($id, $pid, $publicOnly = true) { $res = self::get($id, $publicOnly); if (! $res || ! isset($res['account']) || ! isset($res['account']['id'])) { return $res; } $res['relationship'] = RelationshipService::get($pid, $res['account']['id']); return $res; } public static function getDirectMessage($id) { $status = Status::whereScope('direct')->find($id); if (! $status) { return null; } return FractalService::item($status, new StatusStatelessTransformer); } public static function del($id, $purge = false) { if ($purge) { $status = self::get($id); if ($status && isset($status['account']) && isset($status['account']['id'])) { Cache::forget('profile:embed:'.$status['account']['id']); } Cache::forget('status:transformer:media:attachments:'.$id); MediaService::del($id); Cache::forget('pf:services:sh:id:'.$id); PublicTimelineService::rem($id); NetworkTimelineService::rem($id); } Cache::forget(self::key($id, false)); return Cache::forget(self::key($id)); } public static function refresh($id) { Cache::forget(self::key($id, false)); Cache::forget(self::key($id, true)); self::get($id, false); self::get($id, true); } public static function isShared($id, $pid = null) { return $pid ? ReblogService::get($pid, $id) : false; } public static function isBookmarked($id, $pid = null) { return $pid ? BookmarkService::get($pid, $id) : false; } public static function totalLocalStatuses() { return InstanceService::totalLocalStatuses(); } public static function isPinned($id) { return Status::whereId($id)->whereNotNull('pinned_order')->exists(); } public static function totalPins($pid) { return Status::whereProfileId($pid)->whereNotNull('pinned_order')->count(); } public static function markPin($id): array { $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 [ 'success' => false, 'error' => 'This post is already pinned', ]; } $totalPins = self::totalPins($status->profile_id); if ($totalPins >= self::MAX_PINNED) { 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 [ '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; } /** * Canonical source-of-truth like count for a status. */ public static function recalculateLikeCount($id): int { return (int) Like::whereStatusId($id)->count(); } /** * Canonical source-of-truth boost/reblog count for a status. */ public static function recalculateReblogCount($id): int { return (int) Status::whereReblogOfId($id)->count(); } /** * Canonical source-of-truth reply/comment count for a status. */ public static function recalculateReplyCount($id): int { return (int) Status::whereInReplyToId($id)->count(); } /** * Reconcile a status's cached count columns (likes_count, reblogs_count, * reply_count) against source-of-truth tables. Only writes and busts the * cache when a column actually drifted. * * @param array $only Restrict to a subset of * ['likes','boosts','comments']. * @return array * Per-metric before/after summary. */ public static function reconcileStatusCounts($status, array $only = ['likes', 'boosts', 'comments']): array { if (! $status instanceof Status) { $status = Status::find($status); } if (! $status) { return []; } $summary = []; $changed = false; if (in_array('likes', $only, true)) { $cached = (int) $status->likes_count; $live = self::recalculateLikeCount($status->id); $drift = $cached !== $live; if ($drift) { $status->likes_count = $live; $changed = true; } $summary['likes'] = ['cached' => $cached, 'live' => $live, 'drifted' => $drift]; } if (in_array('boosts', $only, true)) { $cached = (int) $status->reblogs_count; $live = self::recalculateReblogCount($status->id); $drift = $cached !== $live; if ($drift) { $status->reblogs_count = $live; $changed = true; } $summary['boosts'] = ['cached' => $cached, 'live' => $live, 'drifted' => $drift]; } if (in_array('comments', $only, true)) { $cached = (int) $status->reply_count; $live = self::recalculateReplyCount($status->id); $drift = $cached !== $live; if ($drift) { $status->reply_count = $live; $changed = true; } $summary['comments'] = ['cached' => $cached, 'live' => $live, 'drifted' => $drift]; } if ($changed) { $status->save(); self::del($status->id, true); } return $summary; } }