diff --git a/app/Livewire/Concerns/Followable.php b/app/Livewire/Concerns/Followable.php new file mode 100644 index 000000000..66611cf14 --- /dev/null +++ b/app/Livewire/Concerns/Followable.php @@ -0,0 +1,63 @@ +check()) { + $this->redirectRoute('login', navigate: true); + + return; + } + + $user = type(auth()->user())->as(User::class); + $user->following()->attach($id); + + if ($this->shouldHandleFollowingCount()) { + $this->dispatch('following.updated'); + } + + $this->dispatch('user.followed', id: $id); + } + + /** + * Unfollows the given user. + */ + #[Renderless] + public function unfollow(int $id): void + { + if (! auth()->check()) { + $this->redirectRoute('login', navigate: true); + + return; + } + + $user = type(auth()->user())->as(User::class); + $user->following()->detach($id); + + if ($this->shouldHandleFollowingCount()) { + $this->dispatch('following.updated'); + } + + $this->dispatch('user.unfollowed', id: $id); + } + + /** + * Indicates if the following count should be handled. + */ + protected function shouldHandleFollowingCount(): bool + { + return false; + } +} diff --git a/app/Livewire/Followers/Index.php b/app/Livewire/Followers/Index.php index 6156758ef..78c69311f 100644 --- a/app/Livewire/Followers/Index.php +++ b/app/Livewire/Followers/Index.php @@ -4,6 +4,7 @@ namespace App\Livewire\Followers; +use App\Livewire\Concerns\Followable; use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -15,7 +16,7 @@ final class Index extends Component { - use WithoutUrlPagination, WithPagination; + use Followable, WithoutUrlPagination, WithPagination; /** * The component's user ID. @@ -38,13 +39,27 @@ public function render(): View return view('livewire.followers.index', [ 'user' => $user, 'followers' => $this->isOpened ? $user->followers() - ->when(auth()->user()?->isNot($user), function (Builder|BelongsToMany $query): void { + ->unless(auth()->user()?->is($user), function (Builder|BelongsToMany $query): void { $query->withExists([ 'following as is_follower' => function (Builder $query): void { $query->where('user_id', auth()->id()); }, ]); - })->latest('followers.id')->simplePaginate(10) : collect(), + }) + ->withExists([ + 'followers as is_following' => function (Builder $query): void { + $query->where('follower_id', auth()->id()); + }, + ]) + ->latest('followers.id')->simplePaginate(10) : collect(), ]); } + + /** + * Indicates if the following count should be handled. + */ + protected function shouldHandleFollowingCount(): bool + { + return $this->userId === auth()->id(); + } } diff --git a/app/Livewire/Following/Index.php b/app/Livewire/Following/Index.php index 5f383e801..2c1994d7d 100644 --- a/app/Livewire/Following/Index.php +++ b/app/Livewire/Following/Index.php @@ -4,8 +4,10 @@ namespace App\Livewire\Following; +use App\Livewire\Concerns\Followable; use App\Models\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\View\View; use Livewire\Attributes\Locked; use Livewire\Component; @@ -14,7 +16,7 @@ final class Index extends Component { - use WithoutUrlPagination, WithPagination; + use Followable, WithoutUrlPagination, WithPagination; /** * The component's user ID. @@ -36,11 +38,28 @@ public function render(): View return view('livewire.following.index', [ 'user' => $user, - 'following' => $this->isOpened ? $user->following()->withExists([ - 'following as is_follower' => function (Builder $query): void { - $query->where('user_id', auth()->id()); - }, - ])->latest('followers.id')->simplePaginate(10) : collect(), + 'following' => $this->isOpened ? $user->following() + ->withExists([ + 'following as is_follower' => function (Builder $query): void { + $query->where('user_id', auth()->id()); + }, + ]) + ->unless(auth()->user()?->is($user), function (Builder|BelongsToMany $query): void { + $query->withExists([ + 'followers as is_following' => function (Builder $query): void { + $query->where('follower_id', auth()->id()); + }, + ]); + }) + ->latest('followers.id')->simplePaginate(10) : collect(), ]); } + + /** + * Indicates if the following count should be handled. + */ + protected function shouldHandleFollowingCount(): bool + { + return $this->userId === auth()->id(); + } } diff --git a/app/Livewire/Home/Users.php b/app/Livewire/Home/Users.php index 456d2dee1..8d585a24f 100644 --- a/app/Livewire/Home/Users.php +++ b/app/Livewire/Home/Users.php @@ -4,6 +4,7 @@ namespace App\Livewire\Home; +use App\Livewire\Concerns\Followable; use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -14,6 +15,8 @@ final class Users extends Component { + use Followable; + /** * The component's search query. */ @@ -51,6 +54,16 @@ private function usersByQuery(): Collection $query->whereNotNull('answer'); }]) ->orderBy('answered_questions_count', 'desc') + ->when(auth()->check(), function (Builder $query): void { + $query->withExists([ + 'following as is_follower' => function (Builder $query): void { + $query->where('user_id', auth()->id()); + }, + 'followers as is_following' => function (Builder $query): void { + $query->where('follower_id', auth()->id()); + }, + ]); + }) ->limit(10) ->get(); } @@ -67,7 +80,17 @@ private function defaultUsers(): Collection return $this->famousUsers($verifiedUsers) ->merge($verifiedUsers) ->shuffle() - ->load('links'); + ->load('links') + ->when(auth()->check(), function (Collection $users): void { + $users->loadExists([ // @phpstan-ignore-line + 'following as is_follower' => function (Builder $query): void { + $query->where('user_id', auth()->id()); + }, + 'followers as is_following' => function (Builder $query): void { + $query->where('follower_id', auth()->id()); + }, + ]); + }); } /** diff --git a/app/Livewire/Links/Index.php b/app/Livewire/Links/Index.php index 1f3fe74a8..ca35ba253 100644 --- a/app/Livewire/Links/Index.php +++ b/app/Livewire/Links/Index.php @@ -160,6 +160,7 @@ public function unfollow(int $targetId): void #[On('link.created')] #[On('link.updated')] #[On('link-settings.updated')] + #[On('following.updated')] public function refresh(): void { // diff --git a/resources/js/app.js b/resources/js/app.js index c2b0adb7f..061058f95 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -60,6 +60,8 @@ Alpine.data('likeButton', likeButton); import { bookmarkButton } from './bookmark-button.js'; Alpine.data('bookmarkButton', bookmarkButton); +import { followButton } from './follow-button.js' +Alpine.data('followButton', followButton) import { viewCreate } from './view-cerate.js'; Alpine.data('viewCreate', viewCreate); diff --git a/resources/js/follow-button.js b/resources/js/follow-button.js new file mode 100644 index 000000000..e4e1ed716 --- /dev/null +++ b/resources/js/follow-button.js @@ -0,0 +1,50 @@ +const followButton = (id, isFollowing, isFollower, isAuthenticated) => ({ + id, + isFollowing, + isFollower, + isAuthenticated, + buttonText: '', + + init() { + this.setButtonText(); + this.initEventListeners(); + }, + + toggleFollow() { + if (!this.isAuthenticated) { + window.Livewire.navigate('/login'); + return; + } + + if (this.isFollowing) { + this.$wire.unfollow(id); + this.$dispatch('user.unfollowed', { id: id }); + } else { + this.$wire.follow(id); + this.$dispatch('user.followed', { id: id }); + } + }, + + setButtonText() { + this.buttonText = this.isFollowing ? 'Unfollow' : (this.isFollower ? 'Follow Back' : 'Follow'); + }, + + initEventListeners() { + window.addEventListener('user.followed', (event) => { + if (event.detail.id == this.id) { + this.isFollowing = true; + this.setButtonText(); + } + }); + window.addEventListener('user.unfollowed', (event) => { + if (event.detail.id == id) { + this.isFollowing = false; + this.setButtonText(); + } + }); + } +}); + +export { followButton } + + diff --git a/resources/views/components/follow-button.blade.php b/resources/views/components/follow-button.blade.php new file mode 100644 index 000000000..79a695148 --- /dev/null +++ b/resources/views/components/follow-button.blade.php @@ -0,0 +1,21 @@ +@props([ + 'id', + 'isFollower' => false, + 'isFollowing' => false, +]) + +@if(auth()->id() !== $id) +
+ + + +
+@endif diff --git a/resources/views/livewire/followers/index.blade.php b/resources/views/livewire/followers/index.blade.php index a792ff254..1ca07d91b 100644 --- a/resources/views/livewire/followers/index.blade.php +++ b/resources/views/livewire/followers/index.blade.php @@ -2,7 +2,7 @@ name="followers" maxWidth="2xl" > -
+
@if ($followers->isEmpty()) @{{ $user->username }} does not have any followers @@ -15,12 +15,12 @@
diff --git a/resources/views/livewire/following/index.blade.php b/resources/views/livewire/following/index.blade.php index dea467826..98ca0a2f7 100644 --- a/resources/views/livewire/following/index.blade.php +++ b/resources/views/livewire/following/index.blade.php @@ -2,7 +2,7 @@ name="following" maxWidth="2xl" > -
+
@if ($following->isEmpty()) @{{ $user->username }} does not have any following @@ -15,22 +15,27 @@
diff --git a/resources/views/livewire/home/users.blade.php b/resources/views/livewire/home/users.blade.php index 45132feeb..0d56310b6 100644 --- a/resources/views/livewire/home/users.blade.php +++ b/resources/views/livewire/home/users.blade.php @@ -23,12 +23,12 @@ class="w-full mx-1 !rounded-2xl dark:!bg-slate-950 !bg-slate-50 !bg-opacity-80 p
diff --git a/tests/Unit/Livewire/Concerns/FollowableTest.php b/tests/Unit/Livewire/Concerns/FollowableTest.php new file mode 100644 index 000000000..90dc5e28d --- /dev/null +++ b/tests/Unit/Livewire/Concerns/FollowableTest.php @@ -0,0 +1,113 @@ +create(); + $anotherUser = User::factory()->create(); + + /** @var Testable $component */ + $component = Livewire::actingAs($user)->test(supportsFollow()::class); + + $component->call('follow', $anotherUser->id); + + expect($user->following->contains($anotherUser))->toBeTrue(); + + $component->assertDispatched('following.updated'); + + $component->assertDispatched('user.followed', + id: $anotherUser->id, + ); +}); + +it('unfollows the given user', function () { + $user = User::factory()->create(); + $anotherUser = User::factory()->create(); + + $user->following()->attach($anotherUser); + + /** @var Testable $component */ + $component = Livewire::actingAs($user)->test(supportsFollow()::class); + + $component->call('unfollow', $anotherUser->id); + + expect($user->following->contains($anotherUser))->toBeFalse(); + + $component->assertDispatched('following.updated'); + + $component->assertDispatched('user.unfollowed', + id: $anotherUser->id, + ); +}); + +it('redirects to the login page when the user is not authenticated', function () { + $component = Livewire::test(supportsFollow()::class); + + $component->call('follow', 1); + + $component->assertRedirect('login'); + + $component->call('unfollow', 1); + + $component->assertRedirect('login'); +}); + +it('does not handle following count when the method is not implemented', function () { + $user = User::factory()->create(); + $anotherUser = User::factory()->create(); + + /** @var Testable $component */ + $component = Livewire::actingAs($user)->test(withoutFollowingHandle()::class); + + $component->call('follow', $anotherUser->id); + + expect($user->following->contains($anotherUser))->toBeTrue(); + + $component->assertNotDispatched('following.updated'); +}); + +function supportsFollow(): Component +{ + return new class() extends Component + { + use Followable; + + public function render() + { + return <<<'HTML' +
+ + +
+ HTML; + } + + protected function shouldHandleFollowingCount(): bool + { + return true; + } + }; +} +function withoutFollowingHandle(): Component +{ + return new class() extends Component + { + use Followable; + + public function render() + { + return <<<'HTML' +
+ + +
+ HTML; + } + }; +} diff --git a/tests/Unit/Livewire/Followers/IndexTest.php b/tests/Unit/Livewire/Followers/IndexTest.php index aabe1d141..53574ad1d 100644 --- a/tests/Unit/Livewire/Followers/IndexTest.php +++ b/tests/Unit/Livewire/Followers/IndexTest.php @@ -83,3 +83,56 @@ $component->assertDontSee('Follows you'); }); + +test('users data has is_following and is_follower keys as expected', function () { + $user = User::factory()->create(); + $following = User::factory(10)->create(); + + $user->followers()->sync($following->pluck('id')); + + $component = Livewire::actingAs($user)->test(Index::class, [ + 'userId' => $user->id, + ]); + + $component->set('isOpened', true); + + $component->refresh(); + + $component->viewData('followers')->each(function (User $user): void { + expect($user)->toHaveKey('is_following'); + expect($user)->not->toHaveKey('is_follower'); + }); + + $anotherUser = User::factory()->create(); + + $component = Livewire::actingAs($anotherUser)->test(Index::class, [ + 'userId' => $user->id, + ]); + + $component->set('isOpened', true); + + $component->refresh(); + + $component->viewData('followers')->each(function (User $user): void { + expect($user)->toHaveKey('is_following'); + expect($user)->toHaveKey('is_follower'); + }); +}); + +test('shouldHandleFollowingCount returns true when the user is viewing his profile', function () { + $user = User::factory()->create(); + + $component = Livewire::actingAs($user)->test(Index::class, [ + 'userId' => $user->id, + ]); + + expect(invade($component->instance())->shouldHandleFollowingCount())->toBeTrue(); + + $anotherUser = User::factory()->create(); + + $component = Livewire::actingAs($anotherUser)->test(Index::class, [ + 'userId' => $user->id, + ]); + + expect($component->invade()->shouldHandleFollowingCount())->toBeFalse(); +}); diff --git a/tests/Unit/Livewire/Following/IndexTest.php b/tests/Unit/Livewire/Following/IndexTest.php index 27b75c9b9..853b98b00 100644 --- a/tests/Unit/Livewire/Following/IndexTest.php +++ b/tests/Unit/Livewire/Following/IndexTest.php @@ -65,3 +65,56 @@ $component->assertSeeInOrder($orderedText); }); + +test('users data has is_following and is_follower keys as expected', function () { + $user = User::factory()->create(); + $following = User::factory(10)->create(); + + $user->following()->sync($following->pluck('id')); + + $component = Livewire::actingAs($user)->test(Index::class, [ + 'userId' => $user->id, + ]); + + $component->set('isOpened', true); + + $component->refresh(); + + $component->viewData('following')->each(function (User $user): void { + expect($user)->not->toHaveKey('is_following'); + expect($user)->toHaveKey('is_follower'); + }); + + $anotherUser = User::factory()->create(); + + $component = Livewire::actingAs($anotherUser)->test(Index::class, [ + 'userId' => $user->id, + ]); + + $component->set('isOpened', true); + + $component->refresh(); + + $component->viewData('following')->each(function (User $user): void { + expect($user)->toHaveKey('is_following'); + expect($user)->toHaveKey('is_follower'); + }); +}); + +test('shouldHandleFollowingCount returns true when the user is viewing his profile', function () { + $user = User::factory()->create(); + + $component = Livewire::actingAs($user)->test(Index::class, [ + 'userId' => $user->id, + ]); + + expect(invade($component->instance())->shouldHandleFollowingCount())->toBeTrue(); + + $anotherUser = User::factory()->create(); + + $component = Livewire::actingAs($anotherUser)->test(Index::class, [ + 'userId' => $user->id, + ]); + + expect($component->invade()->shouldHandleFollowingCount())->toBeFalse(); +}); diff --git a/tests/Unit/Livewire/Home/UsersTest.php b/tests/Unit/Livewire/Home/UsersTest.php index 71353db42..f1a533a6e 100644 --- a/tests/Unit/Livewire/Home/UsersTest.php +++ b/tests/Unit/Livewire/Home/UsersTest.php @@ -210,3 +210,54 @@ $this->assertNotEquals($famousUsers->pluck('id')->toArray(), $CachedFamousUsers); $this->assertEquals($newFamousUsers->pluck('id')->toArray(), $CachedFamousUsers); }); + +test('users has is_follower and is_following attributes only when authenticated', function () { + + User::factory(10) + ->hasLinks(1, function (array $attributes, User $user) { + return ['url' => "https://twitter.com/{$user->username}"]; + }) + ->hasQuestionsReceived(2, ['answer' => 'this is an answer']) + ->create(); + + User::factory() + ->hasLinks(1, function (array $attributes, User $user) { + return ['url' => "https://twitter.com/{$user->username}"]; + }) + ->sequence([ + 'name' => 'Nuno Maduro', + ], [ + 'name' => 'Punyapal Shah', + ]) + ->create(); + + $component = Livewire::test(Users::class); + + $component->viewData('users')->each(function (User $user): void { + expect($user)->not->toHaveKey('is_follower'); + expect($user)->not->toHaveKey('is_following'); + }); + + $component->set('query', 'un'); + + $component->viewData('users')->each(function (User $user): void { + expect($user)->not->toHaveKey('is_follower'); + expect($user)->not->toHaveKey('is_following'); + }); + + $component->actingAs(User::factory()->create()); + + $component->set('query', ''); + + $component->viewData('users')->each(function (User $user): void { + expect($user->is_follower)->toBeBool(); + expect($user->is_following)->toBeBool(); + }); + + $component->set('query', 'un'); + + $component->viewData('users')->each(function (User $user): void { + expect($user->is_follower)->toBeBool(); + expect($user->is_following)->toBeBool(); + }); +});