This commit is contained in:
2026-03-20 21:17:26 +01:00
parent 1a62fcb81d
commit 29c3ff8572
229 changed files with 13147 additions and 2577 deletions

View File

@@ -0,0 +1,300 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Events\Achievements\AchievementCheckRequested;
use App\Models\Story;
use App\Models\StoryBookmark;
use App\Models\StoryComment;
use App\Models\StoryLike;
use App\Models\User;
use App\Notifications\StoryCommentedNotification;
use App\Notifications\StoryLikedNotification;
use App\Notifications\StoryMentionedNotification;
use App\Services\ContentSanitizer;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
final class SocialService
{
private const COMMENT_MAX_LENGTH = 10000;
public function __construct(
private readonly \App\Services\ActivityService $activity,
private readonly FollowService $followService,
private readonly XPService $xp,
) {}
public function toggleFollow(int $actorId, int $targetId, bool $state): array
{
if ($state) {
$this->followService->follow($actorId, $targetId);
} else {
$this->followService->unfollow($actorId, $targetId);
}
return [
'following' => $state,
'followers_count' => $this->followService->followersCount($targetId),
];
}
public function toggleStoryLike(User $actor, Story $story, bool $state): array
{
$changed = false;
if ($state) {
$like = StoryLike::query()->firstOrCreate([
'story_id' => (int) $story->id,
'user_id' => (int) $actor->id,
]);
$changed = $like->wasRecentlyCreated;
} else {
$changed = StoryLike::query()
->where('story_id', $story->id)
->where('user_id', $actor->id)
->delete() > 0;
}
$likesCount = StoryLike::query()->where('story_id', $story->id)->count();
$story->forceFill(['likes_count' => $likesCount])->save();
if ($state && $changed) {
$this->activity->record((int) $actor->id, 'story_like', 'story', (int) $story->id);
if ((int) $story->creator_id > 0 && (int) $story->creator_id !== (int) $actor->id) {
$creator = User::query()->find($story->creator_id);
if ($creator) {
$creator->notify(new StoryLikedNotification($story, $actor));
event(new AchievementCheckRequested((int) $creator->id));
}
}
}
return [
'ok' => true,
'liked' => StoryLike::query()->where('story_id', $story->id)->where('user_id', $actor->id)->exists(),
'likes_count' => $likesCount,
];
}
public function toggleStoryBookmark(User $actor, Story $story, bool $state): array
{
if ($state) {
StoryBookmark::query()->firstOrCreate([
'story_id' => (int) $story->id,
'user_id' => (int) $actor->id,
]);
} else {
StoryBookmark::query()
->where('story_id', $story->id)
->where('user_id', $actor->id)
->delete();
}
return [
'ok' => true,
'bookmarked' => StoryBookmark::query()->where('story_id', $story->id)->where('user_id', $actor->id)->exists(),
'bookmarks_count' => StoryBookmark::query()->where('story_id', $story->id)->count(),
];
}
public function listStoryComments(Story $story, ?int $viewerId, int $page = 1, int $perPage = 20): array
{
$comments = StoryComment::query()
->with(['user.profile', 'approvedReplies'])
->where('story_id', $story->id)
->where('is_approved', true)
->whereNull('parent_id')
->whereNull('deleted_at')
->latest('created_at')
->paginate($perPage, ['*'], 'page', max(1, $page));
return [
'data' => $comments->getCollection()->map(fn (StoryComment $comment) => $this->formatComment($comment, $viewerId, true))->values()->all(),
'meta' => [
'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(),
'total' => $comments->total(),
'per_page' => $comments->perPage(),
],
];
}
public function addStoryComment(User $actor, Story $story, string $raw, ?int $parentId = null): StoryComment
{
$trimmed = trim($raw);
if ($trimmed === '' || mb_strlen($trimmed) > self::COMMENT_MAX_LENGTH) {
abort(422, 'Invalid comment content.');
}
$errors = ContentSanitizer::validate($trimmed);
if ($errors) {
abort(422, implode(' ', $errors));
}
$parent = null;
if ($parentId !== null) {
$parent = StoryComment::query()
->where('story_id', $story->id)
->where('id', $parentId)
->where('is_approved', true)
->whereNull('deleted_at')
->first();
if (! $parent) {
abort(422, 'The comment you are replying to is no longer available.');
}
}
$comment = DB::transaction(function () use ($actor, $story, $trimmed, $parent): StoryComment {
$comment = StoryComment::query()->create([
'story_id' => (int) $story->id,
'user_id' => (int) $actor->id,
'parent_id' => $parent?->id,
'content' => $trimmed,
'raw_content' => $trimmed,
'rendered_content' => ContentSanitizer::render($trimmed),
'is_approved' => true,
]);
$commentsCount = StoryComment::query()
->where('story_id', $story->id)
->whereNull('deleted_at')
->count();
$story->forceFill(['comments_count' => $commentsCount])->save();
return $comment;
});
$comment->load(['user.profile', 'approvedReplies']);
$this->activity->record((int) $actor->id, 'story_comment', 'story', (int) $story->id, ['comment_id' => (int) $comment->id]);
$this->xp->awardCommentCreated((int) $actor->id, (int) $comment->id, 'story');
$this->notifyStoryCommentRecipients($story, $comment, $actor, $parent);
return $comment;
}
public function deleteStoryComment(User $actor, StoryComment $comment): void
{
$story = $comment->story;
$canDelete = (int) $comment->user_id === (int) $actor->id
|| (int) ($story?->creator_id ?? 0) === (int) $actor->id
|| $actor->hasRole('admin')
|| $actor->hasRole('moderator');
abort_unless($canDelete, 403);
$comment->delete();
if ($story) {
$commentsCount = StoryComment::query()
->where('story_id', $story->id)
->whereNull('deleted_at')
->count();
$story->forceFill(['comments_count' => $commentsCount])->save();
}
}
public function storyStateFor(?User $viewer, Story $story): array
{
if (! $viewer) {
return [
'liked' => false,
'bookmarked' => false,
'is_following_creator' => false,
'likes_count' => (int) $story->likes_count,
'comments_count' => (int) $story->comments_count,
'bookmarks_count' => StoryBookmark::query()->where('story_id', $story->id)->count(),
];
}
return [
'liked' => StoryLike::query()->where('story_id', $story->id)->where('user_id', $viewer->id)->exists(),
'bookmarked' => StoryBookmark::query()->where('story_id', $story->id)->where('user_id', $viewer->id)->exists(),
'is_following_creator' => $story->creator_id ? $this->followService->isFollowing((int) $viewer->id, (int) $story->creator_id) : false,
'likes_count' => StoryLike::query()->where('story_id', $story->id)->count(),
'comments_count' => StoryComment::query()->where('story_id', $story->id)->whereNull('deleted_at')->count(),
'bookmarks_count' => StoryBookmark::query()->where('story_id', $story->id)->count(),
];
}
public function formatComment(StoryComment $comment, ?int $viewerId, bool $includeReplies = false): array
{
$user = $comment->user;
$avatarHash = $user?->profile?->avatar_hash;
return [
'id' => (int) $comment->id,
'parent_id' => $comment->parent_id,
'raw_content' => $comment->raw_content ?? $comment->content,
'rendered_content' => $comment->rendered_content,
'created_at' => $comment->created_at?->toIso8601String(),
'time_ago' => $comment->created_at ? Carbon::parse($comment->created_at)->diffForHumans() : null,
'can_delete' => $viewerId !== null && ((int) $comment->user_id === $viewerId || (int) ($comment->story?->creator_id ?? 0) === $viewerId),
'user' => [
'id' => (int) ($user?->id ?? 0),
'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : null,
'avatar_url' => AvatarUrl::forUser((int) ($user?->id ?? 0), $avatarHash, 64),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
'replies' => $includeReplies && $comment->relationLoaded('approvedReplies')
? $comment->approvedReplies->map(fn (StoryComment $reply) => $this->formatComment($reply, $viewerId, true))->values()->all()
: [],
];
}
private function notifyStoryCommentRecipients(Story $story, StoryComment $comment, User $actor, ?StoryComment $parent): void
{
$notifiedUserIds = [];
if ((int) ($story->creator_id ?? 0) > 0 && (int) $story->creator_id !== (int) $actor->id) {
$creator = User::query()->find($story->creator_id);
if ($creator) {
$creator->notify(new StoryCommentedNotification($story, $comment, $actor));
$notifiedUserIds[] = (int) $creator->id;
}
}
if ($parent && (int) $parent->user_id !== (int) $actor->id && ! in_array((int) $parent->user_id, $notifiedUserIds, true)) {
$parentUser = User::query()->find($parent->user_id);
if ($parentUser) {
$parentUser->notify(new StoryCommentedNotification($story, $comment, $actor));
$notifiedUserIds[] = (int) $parentUser->id;
}
}
$mentionedUsers = User::query()
->whereIn(DB::raw('LOWER(username)'), $this->extractMentions((string) ($comment->raw_content ?? '')))
->get();
foreach ($mentionedUsers as $mentionedUser) {
if ((int) $mentionedUser->id === (int) $actor->id || in_array((int) $mentionedUser->id, $notifiedUserIds, true)) {
continue;
}
$mentionedUser->notify(new StoryMentionedNotification($story, $comment, $actor));
}
}
private function extractMentions(string $content): array
{
preg_match_all('/(^|[^A-Za-z0-9_])@([A-Za-z0-9_-]{3,20})/', $content, $matches);
return collect($matches[2] ?? [])
->map(fn ($username) => strtolower((string) $username))
->unique()
->values()
->all();
}
}