diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 7602a9a..e4b5cc5 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -4,6 +4,7 @@ use App\Devnull\Data\Commit; use App\Devnull\Data\CommitThread; +use App\Devnull\Data\Connection; use App\Devnull\Data\CurrentUser; use App\Devnull\Data\Notification; use App\Devnull\Data\Page; @@ -220,6 +221,25 @@ public function userCommits(string $username, string $tab = 'commits', ?string $ return Page::fromArray($this->get('users/'.rawurlencode($username).'/commits', $query), Commit::fromArray(...)); } + /** + * The people following someone, or with 'following' the people they + * follow, newest follow first as the web lists them, 30 a page. + * + * @return Page + */ + public function connections(string $username, string $type, ?string $cursor = null): Page + { + return Page::fromArray($this->get(self::connectionsPath($username, $type), ['cursor' => $cursor]), Connection::fromArray(...)); + } + + /** + * Where someone's followers are, or with 'following' the people they follow. + */ + public static function connectionsPath(string $username, string $type): string + { + return 'users/'.rawurlencode($username).'/'.($type === 'following' ? 'following' : 'followers'); + } + public function follow(string $username): void { $this->send('POST', 'users/'.rawurlencode($username).'/follow'); diff --git a/app/Devnull/Data/Connection.php b/app/Devnull/Data/Connection.php new file mode 100644 index 0000000..97d3813 --- /dev/null +++ b/app/Devnull/Data/Connection.php @@ -0,0 +1,43 @@ +id = $user->id; + } + + /** + * Reads one of `GET /users/{username}/followers` or `/following`: the user, + * with `is_followed` and `follows_you` beside their profile. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + user: User::fromArray($data), + isFollowed: ($data['is_followed'] ?? false) === true, + followsYou: ($data['follows_you'] ?? false) === true, + ); + } + + public function withFollowed(bool $isFollowed): self + { + return new self($this->user, $isFollowed, $this->followsYou); + } +} diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index 15f1ffb..fa3d59f 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -43,6 +43,16 @@ final class ResponseCache '#^users/[^/]+$#', '#^users/[^/]+/commits$#', '#^saved$#', + '#^users/[^/]+/(followers|following)$#', + ]; + + /** + * The kept answers that list people with whether you follow each one. + */ + private const array FollowedPeople = [ + '#^users/[^/]+/(followers|following)$#', + '#^notifications$#', + '#^notifications/[^/]+/group$#', ]; /** @@ -344,21 +354,40 @@ public function commitDeleted(string $id): void } /** - * You followed or unfollowed someone: their profile and your following count change. + * You followed or unfollowed someone: their profile and your following + * count change, and every list of people kept with whether you follow them. + * Without their profile kept, the screen's word is taken that it changed. */ public function followChanged(string $username, bool $following): void { - $changed = false; + $kept = $this->get("users/{$username}"); + $changed = $kept === null || ($kept['is_following'] ?? false) !== $following; - $this->patch("users/{$username}", [], function (array $json) use ($following, &$changed): array { - $changed = ($json['is_following'] ?? false) !== $following; - - return $changed ? [...self::count($json, 'followers_count', $following ? 1 : -1), 'is_following' => $following] : $json; - }); + if ($changed && $kept !== null) { + $this->patch("users/{$username}", [], fn (array $json): array => [...self::count($json, 'followers_count', $following ? 1 : -1), 'is_following' => $following]); + } if ($changed && ($me = $this->username()) !== null) { $this->patch("users/{$me}", [], fn (array $json): array => self::count($json, 'following_count', $following ? 1 : -1)); } + + $this->patchFollowedPeople(rawurldecode($username), $following); + } + + /** + * Marks someone followed, or not, wherever a kept list of people says whether you follow them. + */ + private function patchFollowedPeople(string $username, bool $following): void + { + $userId = $this->userId(); + + foreach ($userId === null ? [] : $this->index($userId) as $meta) { + if (! self::listsFollowedPeople($meta['path'])) { + continue; + } + + $this->patch($meta['path'], $meta['query'], fn (array $json): array => self::changeFollowed($json, $username, $following)); + } } /** @@ -662,6 +691,39 @@ private static function changeUser(mixed $node, string $id, array $profile): mix return $node; } + private static function listsFollowedPeople(string $path): bool + { + foreach (self::FollowedPeople as $pattern) { + if (preg_match($pattern, $path) === 1) { + return true; + } + } + + return false; + } + + /** + * Gives every copy of the user with this username, anywhere in $node, that says whether you follow them, the new answer. + */ + private static function changeFollowed(mixed $node, string $username, bool $following): mixed + { + if (! is_array($node)) { + return $node; + } + + if (($node['username'] ?? null) === $username && array_key_exists('is_followed', $node)) { + $node['is_followed'] = $following; + } + + foreach ($node as $key => $child) { + if (is_array($child)) { + $node[$key] = self::changeFollowed($child, $username, $following); + } + } + + return $node; + } + /** * devnull marks notifications read as it sends them, and you've seen them * by the time they're shown again, so they're kept as read, and without diff --git a/app/NativeComponents/Connections.php b/app/NativeComponents/Connections.php new file mode 100644 index 0000000..fb00ac6 --- /dev/null +++ b/app/NativeComponents/Connections.php @@ -0,0 +1,237 @@ + */ + public array $people = []; + + public ?string $nextCursor = null; + + /** + * Whether the list has had people to show, kept or fresh. Until then it shows skeleton rows. + */ + public bool $loaded = false; + + /** + * Why devnull won't list them, when it won't: user_blocked or user_unavailable. + */ + public ?string $unavailable = null; + + public function mount(): void + { + $this->showKept(); + $this->revalidate(); + } + + /** + * Back from someone's profile: take a follow made there from the cache at once, then ask devnull. + */ + public function onResume(): void + { + $this->forgetRefreshes(); + $this->showKept(); + $this->revalidate(); + } + + public function navTitle(): string + { + return '@'.$this->username."'s ".$this->type; + } + + /** + * Pull to refresh. + */ + public function load(): void + { + $this->refresh($this->listPath(), [], $this->takeFresh(...), again: true); + } + + public function loadMore(): void + { + if ($this->nextCursor === null) { + return; + } + + $page = $this->attempt(fn (Client $devnull): Page => $devnull->connections($this->username, $this->type, $this->nextCursor)); + + if ($page !== null) { + $this->people = [...$this->people, ...$page->items]; + $this->nextCursor = $page->nextCursor; + } + } + + public function open(string $username): void + { + $person = $this->find($username); + + if ($person !== null) { + $this->openInTab('/users/'.rawurlencode($person->user->username), ['user' => $person->user]); + } + } + + /** + * Follows or unfollows straight away, and puts it back if the server says no. + */ + public function toggleFollow(string $username): void + { + $person = $this->find($username); + + if ($person === null || $this->isMe($username)) { + return; + } + + $following = ! $person->isFollowed; + $this->setFollowed($username, $following); + + $done = $this->attempt(function (Client $devnull) use ($username, $following): bool { + $following ? $devnull->follow($username) : $devnull->unfollow($username); + + return true; + }); + + if ($done !== true) { + $this->setFollowed($username, ! $following); + } + } + + /** + * The web's muted and blocked people, where a block is undone, since the app can't unblock yet. + */ + public function openSafety(): void + { + Browser::inApp(rtrim((string) config('devnull.url'), '/').'/settings/safety'); + } + + public function isMe(string $username): bool + { + return $this->me()?->username === $username; + } + + protected function revalidate(): void + { + $this->refresh($this->listPath(), [], $this->takeFresh(...)); + } + + protected function showsContent(): bool + { + return $this->loaded && $this->unavailable === null; + } + + /** + * A list across a block says so as the profile does, whatever devnull's message says. + */ + protected function refused(ApiException $exception): bool + { + if (! $exception->is(ErrorCode::UserBlocked) && ! $exception->is(ErrorCode::UserUnavailable)) { + return false; + } + + $this->unavailable = $exception->errorCode; + $this->people = []; + $this->nextCursor = null; + $this->error = null; + + return true; + } + + private function showKept(): void + { + if (($json = $this->cached($this->listPath())) !== null) { + $this->takeFresh($json); + } + } + + /** + * The fresh first page over what's showing: new people go on top, people + * no longer in it go, and pages loaded further down stay. A first page + * with no more after it is the whole list, so it replaces what's showing. + * + * @param array $json + */ + private function takeFresh(array $json): void + { + $page = Page::fromArray($json, Connection::fromArray(...)); + + $fresh = $page->hasMore() + ? FreshPage::over($this->people, $this->nextCursor, $page)->withNewer() + : new FreshPage($page->items, null); + + $this->people = $fresh->items; + $this->nextCursor = $fresh->nextCursor; + $this->loaded = true; + $this->unavailable = null; + } + + private function setFollowed(string $username, bool $following): void + { + $this->people = array_map( + fn (Connection $person): Connection => $person->user->username === $username ? $person->withFollowed($following) : $person, + $this->people, + ); + } + + private function find(string $username): ?Connection + { + foreach ($this->people as $person) { + if ($person->user->username === $username) { + return $person; + } + } + + return null; + } + + private function listPath(): string + { + return Client::connectionsPath($this->username, $this->type); + } + + public function render(): View + { + return view('native.connections', [ + 'isBlocked' => $this->unavailable === ErrorCode::UserBlocked->value, + 'isOwn' => $this->isMe($this->username), + 'viewer' => $this->me(), + 'myColour' => $this->myColour(), + ]); + } +} diff --git a/app/NativeComponents/Profile.php b/app/NativeComponents/Profile.php index 4bc68ff..d34aeb0 100644 --- a/app/NativeComponents/Profile.php +++ b/app/NativeComponents/Profile.php @@ -192,6 +192,16 @@ public function toggleFollow(): void } } + /** + * Their followers, or the people they follow, from the counts under the bio. + */ + public function openConnections(string $type): void + { + if (in_array($type, Connections::Types, true) && $this->showsContent() && ! $this->sketched) { + $this->openInTab('/users/'.rawurlencode($this->username).'/'.$type); + } + } + public function isMe(): bool { return $this->username !== '' && $this->me()?->username === $this->username; diff --git a/resources/views/native/connections.blade.php b/resources/views/native/connections.blade.php new file mode 100644 index 0000000..35ad283 --- /dev/null +++ b/resources/views/native/connections.blade.php @@ -0,0 +1,29 @@ + + @include('native.partials.error') + @include('native.partials.refresh-failed') + + + @if ($unavailable) + @include('native.partials.profile-unavailable', ['profile' => null]) + @elseif (! $loaded) + @if ($error === null) + @foreach (range(1, 6) as $skeleton) + @include('native.partials.connection-loading', ['ref' => 'loading-'.$skeleton, 'label' => 'Loading '.$type]) + @endforeach + @endif + @elseif ($people === []) + @include('native.partials.empty', $type === 'following' + ? ['command' => 'fatal: no following yet', 'message' => "@{$username} isn't following anyone yet."] + : ['command' => 'fatal: no followers yet', 'message' => "Nobody is following @{$username} yet."]) + @else + @foreach ($people as $person) + {{-- Everyone on your own Followers follows you, so it goes without saying there. --}} + @include('native.partials.connection', ['person' => $person, 'isMe' => $person->user->username === $viewer?->username, 'showsFollowsYou' => ! ($isOwn && $type === 'followers'), 'myColour' => $myColour]) + @endforeach + + @if ($nextCursor !== null) + @include('native.partials.connection-loading', ['ref' => 'loading-more', 'label' => 'Loading more people']) + @endif + @endif + + diff --git a/resources/views/native/partials/connection-loading.blade.php b/resources/views/native/partials/connection-loading.blade.php new file mode 100644 index 0000000..2d96c6e --- /dev/null +++ b/resources/views/native/partials/connection-loading.blade.php @@ -0,0 +1,15 @@ +{{-- + While people load, a skeleton row stands in for one of them: the avatar's circle and the name, handle and bio as + bars, the same height as a row with a line of bio. A list with nothing kept to show yet shows a few of them. +--}} + + + + + + + + + + + diff --git a/resources/views/native/partials/connection.blade.php b/resources/views/native/partials/connection.blade.php new file mode 100644 index 0000000..c2611b3 --- /dev/null +++ b/resources/views/native/partials/connection.blade.php @@ -0,0 +1,42 @@ +{{-- + Someone in a list of followers or following, as the web's connections page draws them: their avatar ringed in their + lane and offset from the surface, then their name, their handle with Follows you beside it where that applies, and + up to two lines of bio. Follow sits on the right, a fixed width so Following takes the same room, except on your + own row. A tap anywhere else opens them. + + No press-* feedback: on iOS it holds any drag that starts on the row, and these rows cover the whole list. +--}} +@php($user = $person->user) + + + + + @include('native.partials.avatar', ['user' => $user, 'size' => 40]) + + + + + {{ $user->name }} + {{-- EDGE's rows don't wrap on iOS, so the handle gives way to the badge. --}} + + {{ $user->handle() }} + @if ($person->followsYou && $showsFollowsYou) + + Follows you + + @endif + + @if ($user->bio) + {{ $user->bio }} + @endif + + + @if (! $isMe) + {{-- A button sizes to its label, so it fills a slot of its own. --}} + + {{ $person->isFollowed ? 'Following' : 'Follow' }} + + @endif + + + diff --git a/resources/views/native/profile.blade.php b/resources/views/native/profile.blade.php index 8b291dc..57184ab 100644 --- a/resources/views/native/profile.blade.php +++ b/resources/views/native/profile.blade.php @@ -91,12 +91,22 @@ @if (! $sketched) + {{-- Following and followers open their lists, as the web links them. --}} - @foreach ([[$profile->commitsCount, 'commit', 'commits'], [$profile->followingCount, 'following', 'following'], [$profile->followersCount, 'follower', 'followers']] as [$count, $singular, $plural]) - - {{ $count }} - {{ $count === 1 ? $singular : $plural }} - + @foreach ([[$profile->commitsCount, 'commit', 'commits', null], [$profile->followingCount, 'following', 'following', 'following'], [$profile->followersCount, 'follower', 'followers', 'followers']] as [$count, $singular, $plural, $list]) + @if ($list === null) + + {{ $count }} + {{ $count === 1 ? $singular : $plural }} + + @else + + + {{ $count }} + {{ $count === 1 ? $singular : $plural }} + + + @endif @endforeach @endif diff --git a/routes/mobile.php b/routes/mobile.php index c0ebf04..b2c7e74 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -2,6 +2,7 @@ use App\NativeComponents\AuthCallback; use App\NativeComponents\CommitDetail; +use App\NativeComponents\Connections; use App\NativeComponents\Launch; use App\NativeComponents\Layouts\TabsLayout; use App\NativeComponents\Log; @@ -23,10 +24,11 @@ Route::native('/profile', Profile::class); Route::native('/profile/device', ThisDevice::class); - // Commits and profiles open beneath the tab they were tapped in, so that tab's - // own navigation stack pushes them (see Concerns\OpensInTab). + // Commits, profiles and their followers open beneath the tab they were tapped + // in, so that tab's own navigation stack pushes them (see Concerns\OpensInTab). foreach (TabsLayout::Tabs as $tab) { Route::native("{$tab}/commits/{id}", CommitDetail::class); Route::native("{$tab}/users/{username}", Profile::class); + Route::native("{$tab}/users/{username}/{type}", Connections::class)->whereIn('type', Connections::Types); } }); diff --git a/tests/Feature/Devnull/ClientCacheTest.php b/tests/Feature/Devnull/ClientCacheTest.php index b5e607d..faf34e5 100644 --- a/tests/Feature/Devnull/ClientCacheTest.php +++ b/tests/Feature/Devnull/ClientCacheTest.php @@ -30,6 +30,25 @@ ->and(kept('users/ada/commits'))->toBeNull(); }); +it("keeps the first page of someone's followers and following", function () { + serveApi([ + 'GET /api/v1/users/ada/followers' => [pageJson([userJson(['id' => 'g1', 'username' => 'grace', 'is_followed' => true, 'follows_you' => false])], 'page2')], + 'GET /api/v1/users/ada/following' => [pageJson([])], + ]); + + $followers = $this->devnull->connections('ada', 'followers'); + $this->devnull->connections('ada', 'followers', 'page2'); + $this->devnull->connections('ada', 'following'); + + expect($followers->items[0]->user->username)->toBe('grace') + ->and($followers->items[0]->isFollowed)->toBeTrue() + ->and($followers->items[0]->followsYou)->toBeFalse() + ->and($followers->nextCursor)->toBe('page2') + ->and(kept('users/ada/followers')['data'][0]['username'])->toBe('grace') + ->and(kept('users/ada/followers', ['cursor' => 'page2']))->toBeNull() + ->and(kept('users/ada/following'))->toBe(pageJson([])); +}); + it('signs a GET for the background to send, and the background sends it as signed', function () { $this->travelTo(now()->setTimestamp(1760000000)); serveApi(['GET /api/v1/log/latest' => [pageJson([commitJson()])]]); diff --git a/tests/Feature/Devnull/ResponseCacheTest.php b/tests/Feature/Devnull/ResponseCacheTest.php index 936cb2e..369ccba 100644 --- a/tests/Feature/Devnull/ResponseCacheTest.php +++ b/tests/Feature/Devnull/ResponseCacheTest.php @@ -29,6 +29,9 @@ ->and(ResponseCache::caches('users/ada/commits', ['replies' => '1']))->toBeTrue() ->and(ResponseCache::caches('saved'))->toBeTrue() ->and(ResponseCache::caches('saved', ['cursor' => 'page2']))->toBeFalse() + ->and(ResponseCache::caches('users/ada/followers'))->toBeTrue() + ->and(ResponseCache::caches('users/ada/following'))->toBeTrue() + ->and(ResponseCache::caches('users/ada/followers', ['cursor' => 'page2']))->toBeFalse() ->and(ResponseCache::caches('log/following', ['cursor' => 'page2']))->toBeFalse() ->and(ResponseCache::caches('devices'))->toBeFalse() ->and(ResponseCache::caches('key-syncs'))->toBeFalse() @@ -304,6 +307,41 @@ ->and($this->cache->get('users/local_dev')['data']['following_count'])->toBe(2); }); +it('counts a follow on your profile when theirs was never kept', function () { + $this->cache->put('users/local_dev', [], ['data' => userWithCountsJson(following: 2), 'is_following' => false]); + + $this->cache->followChanged('grace', true); + + expect($this->cache->get('users/local_dev')['data']['following_count'])->toBe(3) + ->and($this->cache->get('users/grace'))->toBeNull(); + + $this->cache->followChanged('grace', false); + + expect($this->cache->get('users/local_dev')['data']['following_count'])->toBe(2); +}); + +it('marks them followed in every kept list of people that says so', function () { + $grace = userJson(['id' => 'g1', 'username' => 'grace', 'is_followed' => false, 'follows_you' => true]); + $linus = userJson(['id' => 'l1', 'username' => 'linus', 'is_followed' => false]); + $this->cache->put('users/ada/followers', [], pageJson([$grace, $linus])); + $this->cache->put('users/ada/following', [], pageJson([$grace])); + $this->cache->put('notifications', [], pageJson([notificationJson('followed', ['actor' => $grace, 'actors' => [userJson(['id' => 'g1', 'username' => 'grace'])]])])); + $this->cache->put('log/following', [], pageJson([commitJson(['author' => userJson(['id' => 'g1', 'username' => 'grace'])])])); + + $this->cache->followChanged('grace', true); + + expect(array_column($this->cache->get('users/ada/followers')['data'], 'is_followed'))->toBe([true, false]) + ->and($this->cache->get('users/ada/followers')['data'][0]['follows_you'])->toBeTrue() + ->and($this->cache->get('users/ada/following')['data'][0]['is_followed'])->toBeTrue() + ->and($this->cache->get('notifications')['data'][0]['actor']['is_followed'])->toBeTrue() + ->and($this->cache->get('notifications')['data'][0]['actors'][0])->not->toHaveKey('is_followed') + ->and($this->cache->get('log/following')['data'][0]['author'])->not->toHaveKey('is_followed'); + + $this->cache->followChanged('grace', false); + + expect($this->cache->get('users/ada/followers')['data'][0]['is_followed'])->toBeFalse(); +}); + it('keeps the Apple link on the kept you', function () { $this->cache->put('me', [], ['data' => userJson(), 'apple_linked' => false]); diff --git a/tests/Feature/Screens/ConnectionsTest.php b/tests/Feature/Screens/ConnectionsTest.php new file mode 100644 index 0000000..87f1454 --- /dev/null +++ b/tests/Feature/Screens/ConnectionsTest.php @@ -0,0 +1,283 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * Someone in a list of followers or following, as the API sends them to a signed request. + * + * @param array $overrides + * @return array + */ +function connectionJson(string $username, array $overrides = []): array +{ + return userJson(['id' => "id-{$username}", 'username' => $username, 'name' => ucfirst($username), 'bio' => null, 'lane' => 2, 'is_followed' => false, 'follows_you' => false, ...$overrides]); +} + +/** + * A page of `GET /users/{username}/followers` or `/following`. + * + * @param list> $people + * @return array + */ +function connectionsJson(array $people, ?string $nextCursor = null, string $path = 'users/ada/followers'): array +{ + return pageJson($people, $nextCursor, "http://devnull.test/api/v1/{$path}", perPage: 30); +} + +/** + * @param array $extra + * @return array + */ +function profileWithCounts(array $extra = []): array +{ + return [ + 'data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace', 'lane' => 1], commits: 1, followers: 5, following: 2), + 'is_following' => false, + 'activity' => null, + 'streaks' => ['longest' => 0, 'current' => 0], + 'pinned_commit' => null, + ...$extra, + ]; +} + +it('opens the followers and the following from the counts on a profile', function () { + fakeApi([ + 'GET /api/v1/users/ada' => [profileWithCounts()], + 'GET /api/v1/users/ada/commits' => [pageJson([])], + ]); + + Native::visit('/log/users/ada')->tap('count-followers')->assertNavigatedTo('/log/users/ada/followers'); + Native::visit('/log/users/ada')->tap('count-following')->assertNavigatedTo('/log/users/ada/following'); +}); + +it('opens your own lists from the counts on the Profile tab', function () { + fakeApi([ + 'GET /api/v1/users/local_dev' => [profileWithCounts(['data' => userWithCountsJson(followers: 1, following: 1)])], + 'GET /api/v1/users/local_dev/commits' => [pageJson([])], + 'GET /api/v1/me' => [currentUserJson()], + ]); + + Native::visit('/profile')->tap('count-following')->assertNavigatedTo('/profile/users/local_dev/following'); +}); + +it('lists them as the web does, with their lane, bio, whether they follow you and a Follow button', function () { + fakeApi([ + 'GET /api/v1/users/ada/followers' => [connectionsJson([ + connectionJson('grace', ['bio' => 'Found the first bug.', 'lane' => 4, 'is_followed' => true, 'follows_you' => true]), + connectionJson('linus'), + connectionJson('local_dev', ['id' => '0199e5c4-0000-7000-8000-000000000001', 'name' => 'Local Dev']), + ])], + ]); + + $screen = Native::visit('/log/users/ada/followers') + ->assertNavTitle("@ada's followers") + ->assertSee('Grace') + ->assertSee('@grace') + ->assertSee('Found the first bug.') + ->assertSee('Linus'); + + expect(treeShows(nodeByRef($screen, 'person-grace'), 'Following'))->toBeTrue() + ->and(treeShows(nodeByRef($screen, 'person-linus'), 'Follow'))->toBeTrue() + ->and(treeShows(nodeByRef($screen, 'person-linus'), 'Following'))->toBeFalse() + ->and(treeShows(nodeByRef($screen, 'person-grace'), 'Follows you'))->toBeTrue() + ->and(treeShows(nodeByRef($screen, 'person-linus'), 'Follows you'))->toBeFalse() + ->and(findNode(nodeByRef($screen, 'person-grace'), fn (array $node): bool => ($node['layout']['width'] ?? null) === 48.0 + && ($node['style']['bg_color'] ?? null) === config('native-ui.theme.light.lane-4')))->not->toBeNull(); + + // Your own row has no button, as on the web. + $screen->assertMissingElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'follow-local_dev'); +}); + +it('titles the people someone follows as theirs', function () { + fakeApi([ + 'GET /api/v1/users/ada/following' => [connectionsJson([connectionJson('grace')], path: 'users/ada/following')], + ]); + + Native::visit('/log/users/ada/following')->assertNavTitle("@ada's following")->assertSee('Grace'); +}); + +it('leaves out Follows you on your own followers, where everyone does', function () { + fakeApi([ + 'GET /api/v1/users/local_dev/followers' => [connectionsJson([connectionJson('grace', ['follows_you' => true])], path: 'users/local_dev/followers')], + ]); + + $screen = Native::visit('/profile/users/local_dev/followers')->assertSee('Grace'); + + expect(treeShows(nodeByRef($screen, 'person-grace'), 'Follows you'))->toBeFalse(); +}); + +it('speaks git when there is nobody, as the web words it', function (string $type, string $command, string $message) { + fakeApi([ + "GET /api/v1/users/ada/{$type}" => [connectionsJson([], path: "users/ada/{$type}")], + ]); + + Native::visit("/log/users/ada/{$type}")->assertSee($command)->assertSee($message); +})->with([ + 'followers' => ['followers', 'fatal: no followers yet', 'Nobody is following @ada yet.'], + 'following' => ['following', 'fatal: no following yet', "@ada isn't following anyone yet."], +]); + +it('opens their profile from a row', function () { + fakeApi([ + 'GET /api/v1/users/ada/followers' => [connectionsJson([connectionJson('grace')])], + ]); + + Native::visit('/log/users/ada/followers')->tap('person-grace')->assertNavigatedTo('/log/users/grace'); +}); + +it('follows and unfollows straight away', function () { + fakeApi([ + 'GET /api/v1/users/ada/followers' => [connectionsJson([connectionJson('grace')])], + 'POST /api/v1/users/grace/follow' => [null, 204], + 'DELETE /api/v1/users/grace/follow' => [null, 204], + ]); + + $screen = Native::visit('/log/users/ada/followers')->tap('follow-grace'); + + expect(treeShows(nodeByRef($screen, 'person-grace'), 'Following'))->toBeTrue(); + + $screen->tap('follow-grace'); + + expect(treeShows(nodeByRef($screen, 'person-grace'), 'Following'))->toBeFalse(); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === 'http://devnull.test/api/v1/users/grace/follow'); + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' && $request->url() === 'http://devnull.test/api/v1/users/grace/follow'); +}); + +it('puts the follow back when the server refuses', function () { + fakeApi([ + 'GET /api/v1/users/ada/followers' => [connectionsJson([connectionJson('grace')])], + 'POST /api/v1/users/grace/follow' => [['message' => 'Too Many Attempts.'], 429], + ]); + + $screen = Native::visit('/log/users/ada/followers')->tap('follow-grace')->assertSee('Too Many Attempts.'); + + expect($screen->instance()->people[0]->isFollowed)->toBeFalse(); +}); + +it('unfollows from your own following, keeping them in the list so it can be undone, and counts one fewer', function () { + keep('users/local_dev', ['data' => userWithCountsJson(following: 2), 'is_following' => false]); + fakeApi([ + 'GET /api/v1/users/local_dev/following' => [connectionsJson([connectionJson('grace', ['is_followed' => true])], path: 'users/local_dev/following')], + 'DELETE /api/v1/users/grace/follow' => [null, 204], + ]); + + $screen = Native::visit('/profile/users/local_dev/following')->tap('follow-grace')->assertSee('Grace'); + + expect(treeShows(nodeByRef($screen, 'person-grace'), 'Following'))->toBeFalse() + ->and(kept('users/local_dev')['data']['following_count'])->toBe(1) + ->and(kept('users/local_dev/following')['data'][0]['is_followed'])->toBeFalse(); +}); + +it('has the profile count the follow when you go back to it', function () { + fakeApi([ + 'GET /api/v1/users/local_dev' => [profileWithCounts(['data' => userWithCountsJson(followers: 1, following: 2)])], + 'GET /api/v1/users/local_dev/commits' => [pageJson([])], + 'GET /api/v1/me' => [currentUserJson()], + 'GET /api/v1/users/local_dev/followers' => [connectionsJson([connectionJson('grace', ['follows_you' => true])], path: 'users/local_dev/followers')], + 'POST /api/v1/users/grace/follow' => [null, 204], + ]); + + $profile = Native::visit('/profile')->assertSee('2'); + $list = $profile->tap('count-followers')->follow()->tap('follow-grace'); + + holdRefreshes(); + $list->goBack(); + + expect($profile->instance()->profile->followingCount)->toBe(3); +}); + +it('loads more people by cursor', function () { + Http::fake(fn (Request $request) => str_contains($request->url(), 'cursor=older') + ? Http::response(connectionsJson([connectionJson('linus')])) + : Http::response(connectionsJson([connectionJson('grace')], 'older'))); + + $screen = Native::visit('/log/users/ada/followers') + ->assertElement('column', fn (array $node): bool => ($node['ref'] ?? null) === 'loading-more') + ->assertDontSee('Linus') + ->call('loadMore') + ->assertSee('Grace') + ->assertSee('Linus') + ->assertMissingElement('column', fn (array $node): bool => ($node['ref'] ?? null) === 'loading-more'); + + expect($screen->get('nextCursor'))->toBeNull(); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/users/ada/followers?cursor=older'); +}); + +it('opens on the people kept last time, then lays the fresh page over them', function () { + holdRefreshes(); + keep('users/ada/followers', connectionsJson([connectionJson('grace'), connectionJson('linus')])); + serveApi([ + 'GET /api/v1/users/ada/followers' => [connectionsJson([connectionJson('margaret'), connectionJson('grace')])], + ]); + + $screen = Native::visit('/log/users/ada/followers')->assertSee('Grace')->assertSee('Linus'); + + expect(showsLoading($screen->tree()))->toBeFalse() + ->and(count(Http::recorded()))->toBe(0); + + finishRefreshes($screen)->assertSee('Margaret')->assertSee('Grace')->assertDontSee('Linus'); + + expect(array_map(fn ($person): string => $person->user->username, $screen->instance()->people))->toBe(['margaret', 'grace']); +}); + +it('keeps the pages loaded further down when the first page comes in fresh', function () { + holdRefreshes(); + keep('users/ada/followers', connectionsJson([connectionJson('grace'), connectionJson('linus')], 'p2')); + serveApi(fn (Request $request) => str_contains($request->url(), 'cursor=p2') + ? Http::response(connectionsJson([connectionJson('margaret')])) + : Http::response(connectionsJson([connectionJson('alan'), connectionJson('grace'), connectionJson('linus')], 'p2'))); + + $screen = Native::visit('/log/users/ada/followers')->call('loadMore'); + + finishRefreshes($screen); + + expect(array_map(fn ($person): string => $person->user->username, $screen->instance()->people))->toBe(['alan', 'grace', 'linus', 'margaret']) + ->and($screen->get('nextCursor'))->toBeNull(); +}); + +it('shows skeleton rows the first time only, while nothing is known', function () { + holdRefreshes(); + serveApi([ + 'GET /api/v1/users/ada/followers' => [connectionsJson([connectionJson('grace')])], + ]); + + $screen = Native::visit('/log/users/ada/followers') + ->assertElement('column', fn (array $node): bool => ($node['ref'] ?? null) === 'loading-1'); + + finishRefreshes($screen)->assertSee('Grace'); + + expect(showsLoading($screen->tree()))->toBeFalse(); +}); + +it('asks again when pulled', function () { + fakeApi([ + 'GET /api/v1/users/ada/followers' => [connectionsJson([connectionJson('grace')])], + ]); + + $screen = Native::visit('/log/users/ada/followers'); + $sent = count(Http::recorded()); + + $screen->call('load'); + + expect(count(Http::recorded()))->toBe($sent + 1); +}); + +it('says so across a block, as a profile does', function (string $code, string $says) { + fakeApi([ + 'GET /api/v1/users/ada/followers' => [['message' => 'Nope.', 'code' => $code], 403], + ]); + + Native::visit('/log/users/ada/followers')->assertSee($says)->assertDontSee('Nope.'); +})->with([ + 'blocked by you' => ['user_blocked', 'fatal: @ada is blocked'], + 'blocked by them' => ['user_unavailable', "This profile isn't available."], +]); diff --git a/tests/Feature/Screens/LaneButtonsTest.php b/tests/Feature/Screens/LaneButtonsTest.php index 46630a8..471a012 100644 --- a/tests/Feature/Screens/LaneButtonsTest.php +++ b/tests/Feature/Screens/LaneButtonsTest.php @@ -80,6 +80,11 @@ function laneLog(): TestableComponent return Native::visit('/log/users/ada'); }, 'follow'], + 'Follow, in a profile\'s followers' => [function (): TestableComponent { + serveApi(['GET /api/v1/users/ada/followers' => [pageJson([userJson(['id' => 'u3', 'username' => 'grace', 'name' => 'Grace', 'is_followed' => false, 'follows_you' => false])])]]); + + return Native::visit('/log/users/ada/followers'); + }, 'follow-grace'], 'Reply' => [function (): TestableComponent { serveApi([ 'GET /api/v1/commits/c2' => [commitPageJson(commitJson(['id' => 'c2']))],