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(); } }