From bc8a6b46d71014f29446dff5bbe4004d26617967 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 17 Oct 2026 16:14:27 +0100 Subject: [PATCH 1/3] Open a profile's followers and following from its counts Tapping "N followers" or "N following" on a profile pushes a list of those people, as the web's connections page lists them: newest follow first, each with their avatar ringed in their lane, name, handle, two lines of bio, Follows you where it applies, and Follow or Following. A row opens their profile. Follow flips straight away and goes back if devnull refuses. On your own Following an unfollowed row stays as Follow, like the web, until the list refreshes. The web can't remove a follower, so the app can't either. Lists load more as you scroll, refresh when pulled, and open on the first page kept last time. A follow anywhere now marks that person in every kept list of people and counts on your profile even when theirs was never opened. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Client.php | 20 ++ app/Devnull/Data/Connection.php | 43 +++ app/Devnull/ResponseCache.php | 76 ++++- app/NativeComponents/Connections.php | 236 +++++++++++++++ app/NativeComponents/Profile.php | 10 + resources/views/native/connections.blade.php | 29 ++ .../partials/connection-loading.blade.php | 15 + .../native/partials/connection.blade.php | 39 +++ resources/views/native/profile.blade.php | 20 +- routes/mobile.php | 6 +- tests/Feature/Devnull/ClientCacheTest.php | 19 ++ tests/Feature/Devnull/ResponseCacheTest.php | 38 +++ tests/Feature/Screens/ConnectionsTest.php | 283 ++++++++++++++++++ 13 files changed, 820 insertions(+), 14 deletions(-) create mode 100644 app/Devnull/Data/Connection.php create mode 100644 app/NativeComponents/Connections.php create mode 100644 resources/views/native/connections.blade.php create mode 100644 resources/views/native/partials/connection-loading.blade.php create mode 100644 resources/views/native/partials/connection.blade.php create mode 100644 tests/Feature/Screens/ConnectionsTest.php 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..ccfd480 --- /dev/null +++ b/app/NativeComponents/Connections.php @@ -0,0 +1,236 @@ + */ + 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(), + ]); + } +} 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..4cbe435 --- /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')]) + @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..d361f5a --- /dev/null +++ b/resources/views/native/partials/connection.blade.php @@ -0,0 +1,39 @@ +{{-- + 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) + {{ $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."], +]); From 49613ea23af8de0d654557474936d11cfe32a704 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 17 Oct 2026 17:21:09 +0100 Subject: [PATCH 2/3] Keep Follow and Following the same width in follower lists A button sizes itself to its label, so a width class on it did nothing and Following came out wider than Follow. The button now fills a slot of its own. Bios also lose the extra line spacing, which looked loose at 14pt. Co-Authored-By: Claude Opus 5.5 --- resources/views/native/partials/connection.blade.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/resources/views/native/partials/connection.blade.php b/resources/views/native/partials/connection.blade.php index d361f5a..9c6ef9e 100644 --- a/resources/views/native/partials/connection.blade.php +++ b/resources/views/native/partials/connection.blade.php @@ -27,12 +27,15 @@ @endif @if ($user->bio) - {{ $user->bio }} + {{ $user->bio }} @endif @if (! $isMe) - {{ $person->isFollowed ? 'Following' : 'Follow' }} + {{-- A button sizes to its label, so it fills a slot of its own. --}} + + {{ $person->isFollowed ? 'Following' : 'Follow' }} + @endif From 8d9a08f783eadb90d1877f16abb452f0eb230dbc Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 18 Oct 2026 19:58:42 +0100 Subject: [PATCH 3/3] Draw Follow in follower lists in your lane colour Main buttons now wear your lane, and the profile's Follow already does. The Follow button on each row of a followers or following list does too, and it's in the lane buttons test with the others. Following stays secondary. Co-Authored-By: Claude Opus 5.5 --- app/NativeComponents/Connections.php | 1 + resources/views/native/connections.blade.php | 2 +- resources/views/native/partials/connection.blade.php | 2 +- tests/Feature/Screens/LaneButtonsTest.php | 5 +++++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/NativeComponents/Connections.php b/app/NativeComponents/Connections.php index ccfd480..fb00ac6 100644 --- a/app/NativeComponents/Connections.php +++ b/app/NativeComponents/Connections.php @@ -231,6 +231,7 @@ public function render(): View 'isBlocked' => $this->unavailable === ErrorCode::UserBlocked->value, 'isOwn' => $this->isMe($this->username), 'viewer' => $this->me(), + 'myColour' => $this->myColour(), ]); } } diff --git a/resources/views/native/connections.blade.php b/resources/views/native/connections.blade.php index 4cbe435..35ad283 100644 --- a/resources/views/native/connections.blade.php +++ b/resources/views/native/connections.blade.php @@ -18,7 +18,7 @@ @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')]) + @include('native.partials.connection', ['person' => $person, 'isMe' => $person->user->username === $viewer?->username, 'showsFollowsYou' => ! ($isOwn && $type === 'followers'), 'myColour' => $myColour]) @endforeach @if ($nextCursor !== null) diff --git a/resources/views/native/partials/connection.blade.php b/resources/views/native/partials/connection.blade.php index 9c6ef9e..c2611b3 100644 --- a/resources/views/native/partials/connection.blade.php +++ b/resources/views/native/partials/connection.blade.php @@ -34,7 +34,7 @@ @if (! $isMe) {{-- A button sizes to its label, so it fills a slot of its own. --}} - {{ $person->isFollowed ? 'Following' : 'Follow' }} + {{ $person->isFollowed ? 'Following' : 'Follow' }} @endif 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']))],