update
This commit is contained in:
300
app/Services/SocialService.php
Normal file
300
app/Services/SocialService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user