From 4bdc89be3eee8f7cec2c44ac886b9f5f084af783 Mon Sep 17 00:00:00 2001 From: Dennis Petersmann Date: Thu, 22 Oct 2026 21:20:28 +0200 Subject: [PATCH 1/2] Suggest who to follow in the Log, as the web does on smaller screens Three people you might want to follow sit under the fifth commit, each with why they're suggested, Follow and Not interested. Devnull sends three spares, so one dismissed gives their place to the next, and the round is asked again to top the spares up. The refresh moves to the next page of the day's picks. Don't show again hides it with an Undo, and a switch in settings brings it back. Needs GET /api/v1/me/suggested-users and POST /api/v1/users/{username}/dismiss from dev-null-sh/dev-null.sh#990. --- README.md | 1 - app/Devnull/Client.php | 14 + app/Devnull/Data/SuggestedUser.php | 60 +++ app/Devnull/Data/TimelineRow.php | 23 +- app/Devnull/ResponseCache.php | 39 +- app/Devnull/RowWindow.php | 1 + app/Devnull/Timeline.php | 39 +- app/Devnull/WhoToFollow.php | 58 +++ app/Icons/Lucide.php | 2 + .../Concerns/SuggestsWhoToFollow.php | 328 ++++++++++++++++ app/NativeComponents/Log.php | 17 +- app/NativeComponents/ThisDevice.php | 10 + resources/icons/eye-off.svg | 1 + .../views/native/partials/feed.blade.php | 17 +- .../views/native/partials/log-page.blade.php | 2 +- .../native/partials/who-to-follow.blade.php | 94 +++++ resources/views/native/this-device.blade.php | 13 + tests/Feature/Devnull/ClientCacheTest.php | 14 + .../Feature/Devnull/WhoToFollowStateTest.php | 37 ++ tests/Feature/Screens/LogCacheTest.php | 3 +- tests/Feature/Screens/WhoToFollowTest.php | 357 ++++++++++++++++++ tests/Unit/Devnull/SuggestedUserTest.php | 30 ++ tests/Unit/Devnull/TimelineTest.php | 23 +- 23 files changed, 1165 insertions(+), 18 deletions(-) create mode 100644 app/Devnull/Data/SuggestedUser.php create mode 100644 app/Devnull/WhoToFollow.php create mode 100644 app/NativeComponents/Concerns/SuggestsWhoToFollow.php create mode 100644 resources/icons/eye-off.svg create mode 100644 resources/views/native/partials/who-to-follow.blade.php create mode 100644 tests/Feature/Devnull/WhoToFollowStateTest.php create mode 100644 tests/Feature/Screens/WhoToFollowTest.php create mode 100644 tests/Unit/Devnull/SuggestedUserTest.php diff --git a/README.md b/README.md index 3524f82..abab00e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ These are the next things we want in the app. If you'd like to take one on, read - Styling for livestreams - Search - A notifications centre that matches the web -- Who to follow - A better welcome screen, with sign up and log in that don't mention GitHub, and maybe the public feed - Everything Remotes does on the web, including audio recordings and image uploads - WebSockets diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index c4da605..258ae62 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -62,6 +62,11 @@ public const string BlockedPath = 'me/blocked'; + /** + * Who to follow: three people to show and three spares, the next page of the day's picks for each ?round=. + */ + public const string SuggestedUsersPath = 'me/suggested-users'; + /** * Where pastéis mode is switched on and off for the signed-in account. */ @@ -429,6 +434,15 @@ public function unfollow(string $username): void $this->cache->followChanged(rawurlencode($username), false); } + /** + * Not interested, as the web's who to follow says it: devnull never suggests them again. + */ + public function dismissSuggestion(string $username): void + { + $this->send('POST', 'users/'.rawurlencode($username).'/dismiss'); + $this->cache->suggestionDismissed($username); + } + /** * Mutes someone, as the web's Mute does. It's silent: they aren't told, * and the follows between you stay. Their commits leave your logs. diff --git a/app/Devnull/Data/SuggestedUser.php b/app/Devnull/Data/SuggestedUser.php new file mode 100644 index 0000000..d374ba9 --- /dev/null +++ b/app/Devnull/Data/SuggestedUser.php @@ -0,0 +1,60 @@ +id = $user->id; + } + + /** + * Reads one of `GET /me/suggested-users`. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + user: User::fromArray($data), + followersCount: is_int($data['followers_count'] ?? null) ? $data['followers_count'] : 0, + followedByFollowingCount: is_int($data['followed_by_following_count'] ?? null) ? $data['followed_by_following_count'] : 0, + followsYou: ($data['follows_you'] ?? false) === true, + isFollowed: ($data['is_followed'] ?? false) === true, + ); + } + + public function withFollowed(bool $isFollowed): self + { + return new self($this->user, $this->followersCount, $this->followedByFollowingCount, $this->followsYou, $isFollowed); + } + + /** + * Why they're suggested, as the web words it: how many people you follow + * follow them, or else how many follow them at all. + */ + public function reason(): ?string + { + return match (true) { + $this->followedByFollowingCount > 0 => 'Followed by '.Display::compactCount($this->followedByFollowingCount).' you follow', + $this->followersCount === 1 => '1 follower', + $this->followersCount > 1 => Display::compactCount($this->followersCount).' followers', + default => null, + }; + } +} diff --git a/app/Devnull/Data/TimelineRow.php b/app/Devnull/Data/TimelineRow.php index d03c784..fb1a500 100644 --- a/app/Devnull/Data/TimelineRow.php +++ b/app/Devnull/Data/TimelineRow.php @@ -3,7 +3,7 @@ namespace App\Devnull\Data; /** - * One row on the graph rail: a commit, a day marker or the last-visit marker. + * One row on the graph rail: a commit, a day marker, the last-visit marker or who to follow. * * $previousLane is the lane of the segment running into the row from above, * or null when nothing runs into it. In a feed a commit carries on its @@ -56,6 +56,22 @@ public static function lastVisit(int|string|null $previousLane): self return new self('last-visit', $previousLane); } + /** + * Who to follow, between two commits, with no rail running through it. + */ + public static function whoToFollow(): self + { + return new self('who-to-follow', null); + } + + /** + * This commit row starting a run of its own, as the first under a row that breaks the rail. + */ + public function startingRun(): self + { + return new self($this->kind, null, $this->commit, label: $this->label); + } + public function isCommit(): bool { return $this->kind === 'commit'; @@ -70,4 +86,9 @@ public function isLastVisitMarker(): bool { return $this->kind === 'last-visit'; } + + public function isWhoToFollow(): bool + { + return $this->kind === 'who-to-follow'; + } } diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index 1adcd54..48c3fa0 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -38,6 +38,7 @@ final class ResponseCache '#^me$#', '#^me/muted$#', '#^me/blocked$#', + '#^me/suggested-users$#', '#^notifications$#', '#^notifications/[^/]+/group$#', '#^log/[^/]+$#', @@ -56,6 +57,7 @@ final class ResponseCache * The kept answers that list people with whether you follow each one. */ private const array FollowedPeople = [ + '#^me/suggested-users$#', '#^users/[^/]+/(followers|following)$#', '#^notifications$#', '#^notifications/[^/]+/group$#', @@ -444,6 +446,15 @@ private function patchFollowedPeople(string $username, bool $following): void } } + /** + * You said you're not interested in someone who to follow suggested: they + * leave every kept page of suggestions, and a spare moves up. + */ + public function suggestionDismissed(string $username): void + { + $this->dropFromSuggestions(fn (array $user): bool => ($user['username'] ?? null) === $username); + } + /** * You muted or unmuted someone, and devnull answered with them and * whether you mute them now: their profile says so, and they join the top @@ -532,10 +543,12 @@ private static function changedUser(array $json, string $state): array /** * Takes someone's commits out of every kept log, and out of what an empty - * Following log suggests. + * Following log suggests, and them out of who to follow. */ private function dropFromLogs(string $authorId): void { + $this->dropFromSuggestions(fn (array $user): bool => ($user['id'] ?? null) === $authorId); + $userId = $this->userId(); foreach ($userId === null ? [] : $this->index($userId) as $meta) { @@ -555,6 +568,30 @@ private function dropFromLogs(string $authorId): void } } + /** + * Takes the people $drops picks out of every kept page of who to follow. + * + * @param Closure(array): bool $drops + */ + private function dropFromSuggestions(Closure $drops): void + { + $userId = $this->userId(); + + foreach ($userId === null ? [] : $this->index($userId) as $meta) { + if ($meta['path'] !== Client::SuggestedUsersPath) { + continue; + } + + $this->patch($meta['path'], $meta['query'], function (array $json) use ($drops): array { + if (is_array($json['data'] ?? null)) { + $json['data'] = array_values(array_filter($json['data'], fn (mixed $user): bool => ! is_array($user) || ! $drops($user))); + } + + return $json; + }); + } + } + /** * A kept list of people, with $user on top, or without them. * diff --git a/app/Devnull/RowWindow.php b/app/Devnull/RowWindow.php index d5d3e35..79c0270 100644 --- a/app/Devnull/RowWindow.php +++ b/app/Devnull/RowWindow.php @@ -180,6 +180,7 @@ private static function key(TimelineRow $row): string return match (true) { $row->isDayMarker() => 'day-'.$row->label, $row->isLastVisitMarker() => 'last-visit', + $row->isWhoToFollow() => 'who-to-follow', default => 'commit-'.$row->commit?->id, }; } diff --git a/app/Devnull/Timeline.php b/app/Devnull/Timeline.php index b1d83b4..4ddcff7 100644 --- a/app/Devnull/Timeline.php +++ b/app/Devnull/Timeline.php @@ -30,10 +30,12 @@ final class Timeline * A feed, like the web's CommitFeed: a day marker wherever the day changes and * the last-visit marker above the first commit you had already seen. * + * Given $whoToFollowAfter, who to follow sits under that many commits, or under the last when there are fewer. + * * @param list $commits * @return list */ - public static function feed(array $commits, ?string $firstSeenId = null, ?CarbonInterface $now = null): array + public static function feed(array $commits, ?string $firstSeenId = null, ?CarbonInterface $now = null, ?int $whoToFollowAfter = null): array { $now ??= CarbonImmutable::now(); $rows = []; @@ -59,7 +61,7 @@ public static function feed(array $commits, ?string $firstSeenId = null, ?Carbon $previousDay = $day; } - return self::joinRuns($rows); + return self::joinRuns(self::withWhoToFollow($rows, $whoToFollowAfter)); } /** @@ -73,7 +75,7 @@ public static function feed(array $commits, ?string $firstSeenId = null, ?Carbon * @param list $ranked * @return list */ - public static function ranked(array $latest, array $ranked): array + public static function ranked(array $latest, array $ranked, ?int $whoToFollowAfter = null): array { $rows = []; $previous = null; @@ -97,7 +99,36 @@ public static function ranked(array $latest, array $ranked): array $previous = $commit; } - return self::joinRuns($rows); + return self::joinRuns(self::withWhoToFollow($rows, $whoToFollowAfter)); + } + + /** + * Puts who to follow under the commit $after commits down, or under the + * last when there are fewer, as the web's log does on smaller screens. The + * rail stops above it, so the commit below starts a run of its own, as it + * does under a day marker. + * + * @param list $rows + * @return list + */ + private static function withWhoToFollow(array $rows, ?int $after): array + { + $commits = array_keys(array_filter($rows, fn (TimelineRow $row): bool => $row->isCommit())); + + if ($after === null || $commits === []) { + return $rows; + } + + $at = $commits[min(max(1, $after), count($commits)) - 1] + 1; + $below = $rows[$at] ?? null; + + if ($below?->isCommit() === true) { + $rows[$at] = $below->startingRun(); + } + + array_splice($rows, $at, 0, [TimelineRow::whoToFollow()]); + + return $rows; } /** diff --git a/app/Devnull/WhoToFollow.php b/app/Devnull/WhoToFollow.php new file mode 100644 index 0000000..604880b --- /dev/null +++ b/app/Devnull/WhoToFollow.php @@ -0,0 +1,58 @@ +key($userId, 'hidden')) !== true; + } + + public function show(?string $userId, bool $shows): void + { + if ($userId === null) { + return; + } + + $shows ? Cache::forget($this->key($userId, 'hidden')) : Cache::forever($this->key($userId, 'hidden'), true); + } + + /** + * The round showing today. devnull picks afresh each day, so a new day starts at the first. + */ + public function round(?string $userId): int + { + $kept = $userId === null ? null : Cache::get($this->key($userId, 'round')); + + return is_array($kept) && ($kept['day'] ?? null) === $this->today() && is_int($kept['round'] ?? null) ? $kept['round'] : 0; + } + + /** + * The round showing now, once its people have landed. + */ + public function rememberRound(?string $userId, int $round): void + { + if ($userId !== null) { + Cache::forever($this->key($userId, 'round'), ['day' => $this->today(), 'round' => $round]); + } + } + + private function today(): string + { + return CarbonImmutable::now('UTC')->toDateString(); + } + + private function key(string $userId, string $name): string + { + return "devnull.who-to-follow.{$userId}.{$name}"; + } +} diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index 2a123ea..7eaf245 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -71,6 +71,7 @@ enum Lucide case Camera; case ChartBar; case Eye; + case EyeOff; case Plus; case Maximize2; @@ -169,6 +170,7 @@ public function custom(bool $filled = false): ?string self::Camera => 'camera', self::ChartBar => 'chart-bar', self::Eye => 'eye', + self::EyeOff => 'eye-off', self::Plus => 'plus', self::Maximize2 => 'maximize-2', self::Terminal => 'terminal', diff --git a/app/NativeComponents/Concerns/SuggestsWhoToFollow.php b/app/NativeComponents/Concerns/SuggestsWhoToFollow.php new file mode 100644 index 0000000..98a08ca --- /dev/null +++ b/app/NativeComponents/Concerns/SuggestsWhoToFollow.php @@ -0,0 +1,328 @@ + + */ + public array $suggestedUsers = []; + + /** + * What who to follow says instead of people: 'hidden' just after you hid + * it, with an Undo, and 'emptied' once devnull has nobody left to suggest + * after you followed or dismissed the rest. + */ + public ?string $whoToFollowNote = null; + + /** + * The next few people are on their way, so the refresh waits for them. + */ + public bool $suggestingOtherPeople = false; + + /** + * The round whose people the screen is waiting for. An answer for any + * other round, sent before a refresh, is left alone. + */ + protected ?int $wantedRound = null; + + /** + * Goes up with every follow and dismissal here, so an answer sent before + * one of them is asked again rather than taken. + */ + protected int $whoToFollowChanges = 0; + + /** + * The people you dismissed while the Log was open, which an answer that + * crossed with the dismissal can't bring back. + * + * @var list + */ + protected array $dismissedSuggestions = []; + + /** + * The suggestions kept for the round showing, then asked again. A + * refresh asked for less than 30 seconds ago isn't sent twice. + */ + protected function openWhoToFollow(): void + { + $userId = $this->me()?->id; + + if (! app(WhoToFollow::class)->shows($userId) || $this->suggestingOtherPeople) { + return; + } + + $round = app(WhoToFollow::class)->round($userId); + $this->wantedRound = $round; + $kept = $this->cached(Client::SuggestedUsersPath, ['round' => (string) $round]); + + // The kept answer has any follow or dismissal made since, here or on another screen. + if ($kept !== null) { + $this->takeSuggestedUsers($kept); + } + + $this->askForSuggestedUsers($round); + } + + /** + * The refresh button: the next few people, as the web's moves to the next + * page of the day's picks. Who's showing stays until they land, and the + * round only moves on then. + */ + public function suggestOtherPeople(): void + { + if ($this->suggestingOtherPeople) { + return; + } + + $this->suggestingOtherPeople = true; + $this->wantedRound = app(WhoToFollow::class)->round($this->me()?->id) + 1; + $this->askForSuggestedUsers($this->wantedRound, again: true); + } + + /** + * Follows someone suggested, or unfollows them again, straight away, and puts it back if devnull says no. + */ + public function toggleSuggestedFollow(string $username): void + { + $suggested = $this->findSuggested($username); + + if ($suggested === null) { + return; + } + + $following = ! $suggested->isFollowed; + $this->whoToFollowChanges++; + $this->setSuggestedFollowed($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->setSuggestedFollowed($username, ! $following); + } + } + + /** + * Not interested: they leave for good, and the first spare takes their + * place. Then devnull is asked for the round again, as the web tops its + * suggestions up, so the spares never run out while it has people left. + * Who's showing stays where they are: the answer only adds new spares. + */ + public function dismissSuggestion(string $username): void + { + $before = $this->suggestedUsers; + $slot = array_search($username, array_map(fn (SuggestedUser $suggested): string => $suggested->user->username, $before), true); + + if ($slot === false) { + return; + } + + $remaining = $before; + $spare = $slot < self::ShownSuggestions ? array_splice($remaining, self::ShownSuggestions, 1) : []; + array_splice($remaining, $slot, 1, $spare); + $this->suggestedUsers = $remaining; + $this->whoToFollowNote = $remaining === [] ? 'emptied' : null; + $this->whoToFollowChanges++; + + $done = $this->attempt(function (Client $devnull) use ($username): bool { + $devnull->dismissSuggestion($username); + + return true; + }); + + if ($done !== true) { + $this->suggestedUsers = $before; + $this->whoToFollowNote = null; + + return; + } + + $this->dismissedSuggestions[] = $username; + + if ($this->wantedRound !== null && ! $this->suggestingOtherPeople) { + $this->askForSuggestedUsers($this->wantedRound, again: true, topUp: true); + } + } + + /** + * Don't show again. It stays in place with an Undo until the Log opens again. + */ + public function hideWhoToFollow(): void + { + app(WhoToFollow::class)->show($this->me()?->id, false); + $this->whoToFollowNote = 'hidden'; + } + + public function undoHideWhoToFollow(): void + { + app(WhoToFollow::class)->show($this->me()?->id, true); + $this->whoToFollowNote = $this->suggestedUsers === [] ? 'emptied' : null; + } + + public function openSuggestedUser(string $username): void + { + $suggested = $this->findSuggested($username); + + $this->openInTab('/users/'.rawurlencode($username), $suggested === null ? [] : ['user' => $suggested->user]); + } + + /** + * Whether the log has who to follow in it: it's on, and it has people + * to show, or something to say about why it hasn't. + */ + protected function showsWhoToFollow(): bool + { + return $this->whoToFollowNote !== null + || ($this->suggestedUsers !== [] && app(WhoToFollow::class)->shows($this->me()?->id)); + } + + /** + * What the who to follow row draws. + * + * @return array{people: list, note: string|null, refreshing: bool} + */ + protected function whoToFollow(): array + { + return [ + 'people' => $this->whoToFollowNote === 'hidden' ? [] : array_slice($this->suggestedUsers, 0, self::ShownSuggestions), + 'note' => $this->whoToFollowNote, + 'refreshing' => $this->suggestingOtherPeople, + ]; + } + + /** + * Asks devnull for a round of suggestions in the background. An answer + * for a round you've moved on from is left alone, and one sent before a + * follow or a dismissal here is asked for again, so it can't undo either. + */ + private function askForSuggestedUsers(int $round, bool $again = false, bool $topUp = false): void + { + $changes = $this->whoToFollowChanges; + + $this->refresh(Client::SuggestedUsersPath, ['round' => (string) $round], function (array $json) use ($round, $changes, $topUp): void { + if ($round !== $this->wantedRound) { + return; + } + + if ($changes !== $this->whoToFollowChanges) { + $this->askForSuggestedUsers($round, again: true, topUp: $topUp); + + return; + } + + if ($topUp) { + $this->topUpSuggestedUsers($json); + + return; + } + + if ($this->suggestingOtherPeople) { + $this->suggestingOtherPeople = false; + app(WhoToFollow::class)->rememberRound($this->me()?->id, $round); + } + + $this->takeSuggestedUsers($json); + }, again: $again, quiet: true, failed: function () use ($round): void { + if ($this->suggestingOtherPeople && $round === $this->wantedRound) { + $this->suggestingOtherPeople = false; + $this->wantedRound = app(WhoToFollow::class)->round($this->me()?->id); + } + }); + } + + /** + * @param array $json + */ + private function takeSuggestedUsers(array $json): void + { + $this->showSuggestedUsers($this->freshSuggestedUsers($json)); + } + + /** + * Keeps who's showing, followed or not, in their places, and puts the + * people devnull has that aren't there yet under them as spares, as the + * web merges its top-up by id. + * + * @param array $json + */ + private function topUpSuggestedUsers(array $json): void + { + $showing = array_map(fn (SuggestedUser $suggested): string => $suggested->id, $this->suggestedUsers); + $new = array_filter($this->freshSuggestedUsers($json), fn (SuggestedUser $suggested): bool => ! in_array($suggested->id, $showing, true)); + + $this->showSuggestedUsers([...$this->suggestedUsers, ...array_values($new)]); + } + + /** + * The people in an answer, without anyone dismissed here since it was sent. + * + * @param array $json + * @return list + */ + private function freshSuggestedUsers(array $json): array + { + return array_values(array_filter( + array_map(SuggestedUser::fromArray(...), array_filter(is_array($json['data'] ?? null) ? $json['data'] : [], is_array(...))), + fn (SuggestedUser $suggested): bool => ! in_array($suggested->user->username, $this->dismissedSuggestions, true), + )); + } + + /** + * @param list $fresh + */ + private function showSuggestedUsers(array $fresh): void + { + $this->suggestedUsers = $fresh; + + if ($this->whoToFollowNote !== 'hidden') { + $this->whoToFollowNote = $fresh === [] && $this->dismissedSuggestions !== [] ? 'emptied' : null; + } + } + + private function findSuggested(string $username): ?SuggestedUser + { + foreach ($this->suggestedUsers as $suggested) { + if ($suggested->user->username === $username) { + return $suggested; + } + } + + return null; + } + + private function setSuggestedFollowed(string $username, bool $following): void + { + $this->suggestedUsers = array_map(fn (SuggestedUser $suggested): SuggestedUser => $suggested->user->username === $username + ? $suggested->withFollowed($following) + : $suggested, $this->suggestedUsers); + } +} diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index 04e28ad..4613ad4 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -23,6 +23,7 @@ use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\ShowsCommits; +use App\NativeComponents\Concerns\SuggestsWhoToFollow; use App\NativeComponents\Concerns\TalksToDevnull; use Carbon\CarbonImmutable; use Closure; @@ -98,6 +99,7 @@ class Log extends NativeComponent use OpensInTab; use RefreshesInBackground; use ShowsCommits; + use SuggestsWhoToFollow; use TalksToDevnull; /** @@ -263,6 +265,7 @@ public function onResume(): void $this->showKept(); $this->revalidate(); $this->prefetchNextPage(); + $this->openWhoToFollow(); } /** @@ -456,6 +459,7 @@ protected function revalidate(): void private function open(): void { $this->isRanked() ? $this->rank() : $this->revalidate(); + $this->openWhoToFollow(); } /** @@ -1042,7 +1046,9 @@ private function page(string $log, array $state, ?User $viewer, int $listKey): a $commits = $state['commits']; /** @var list $latestCommits */ $latestCommits = $state['latestCommits']; - $rows = $this->timeline($log, $latestCommits, $commits, $log === self::Ranked ? null : LastVisit::firstSeenId($commits, $state['lastVisitAt'], $viewer?->id)); + // Only the log you're on has who to follow: the others show their first few commits. + $showsWhoToFollow = $log === $this->log && $this->showsWhoToFollow(); + $rows = $this->timeline($log, $latestCommits, $commits, $log === self::Ranked ? null : LastVisit::firstSeenId($commits, $state['lastVisitAt'], $viewer?->id), $showsWhoToFollow); return [ 'log' => $log, @@ -1059,6 +1065,7 @@ private function page(string $log, array $state, ?User $viewer, int $listKey): a 'isEmpty' => $commits === [] && $latestCommits === [], 'lastCommit' => $commits[array_key_last($commits) ?? -1] ?? $latestCommits[array_key_last($latestCommits) ?? -1] ?? null, 'rows' => $rows, + 'whoToFollow' => $showsWhoToFollow ? $this->whoToFollow() : null, ]; } @@ -1069,10 +1076,11 @@ private function page(string $log, array $state, ?User $viewer, int $listKey): a * @param list $commits * @return list */ - private function timeline(string $log, array $latestCommits, array $commits, ?string $firstSeenId): array + private function timeline(string $log, array $latestCommits, array $commits, ?string $firstSeenId, bool $showsWhoToFollow = false): array { + $whoToFollowAfter = $showsWhoToFollow ? self::WhoToFollowAfter : null; // Commits are readonly, so the same ones lay out the same way until the day changes. - $key = implode(',', [Display::dayKey(CarbonImmutable::now()), $firstSeenId, count($latestCommits), ...array_map(spl_object_id(...), [...$latestCommits, ...$commits])]); + $key = implode(',', [Display::dayKey(CarbonImmutable::now()), $firstSeenId, $whoToFollowAfter, count($latestCommits), ...array_map(spl_object_id(...), [...$latestCommits, ...$commits])]); if (($this->timelines[$log][0] ?? null) !== $key) { $latest = array_flip(array_map(fn (Commit $commit): string => $commit->id, $latestCommits)); @@ -1082,8 +1090,9 @@ private function timeline(string $log, array $latestCommits, array $commits, ?st $latestCommits, // The web lists a commit put above the ranking only there. array_values(array_filter($commits, fn (Commit $commit): bool => ! isset($latest[$commit->id]))), + $whoToFollowAfter, ) - : Timeline::feed($commits, firstSeenId: $firstSeenId)]; + : Timeline::feed($commits, firstSeenId: $firstSeenId, whoToFollowAfter: $whoToFollowAfter)]; } return $this->timelines[$log][1]; diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php index 6b7a8b4..8146e91 100644 --- a/app/NativeComponents/ThisDevice.php +++ b/app/NativeComponents/ThisDevice.php @@ -13,6 +13,7 @@ use App\Devnull\Hsl; use App\Devnull\Lane; use App\Devnull\ResponseCache; +use App\Devnull\WhoToFollow; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\SignsInWithApple; use App\NativeComponents\Concerns\TalksToDevnull; @@ -294,6 +295,14 @@ public function saveLane(): void }); } + /** + * The web's Show and Hide for who to follow, kept on this phone as the web keeps it in a cookie. + */ + public function setShowsWhoToFollow(bool $shows): void + { + app(WhoToFollow::class)->show($this->me()?->id, $shows); + } + /** * The people you mute and block, as the web's Safety settings list them. */ @@ -569,6 +578,7 @@ public function render(): View 'lanes' => $current === null ? [] : $this->laneOptions($current), 'laneChanged' => $current !== null && $this->chosenLane !== $current->preferredLane, 'pasteisMode' => $current?->pasteisMode ?? false, + 'showsWhoToFollow' => app(WhoToFollow::class)->shows($this->me()?->id), 'remotes' => $current?->remotes, 'remote' => self::remote(), 'offersApple' => AppleSignIn::isAvailable(), diff --git a/resources/icons/eye-off.svg b/resources/icons/eye-off.svg new file mode 100644 index 0000000..a061e92 --- /dev/null +++ b/resources/icons/eye-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/views/native/partials/feed.blade.php b/resources/views/native/partials/feed.blade.php index f9923cc..562b7fe 100644 --- a/resources/views/native/partials/feed.blade.php +++ b/resources/views/native/partials/feed.blade.php @@ -1,8 +1,8 @@ {{-- - A feed's rows from App\Devnull\Timeline: day markers, the last-visit marker and commits on the rail. Under a commit - hangs its thread, inside its row's hairline, as on the web: the newest reply devnull previews, with "Show more" - when there are others, or the replies opened in place. The commit's rail runs on past them when its author's run - goes on below. + A feed's rows from App\Devnull\Timeline: day markers, the last-visit marker, who to follow ($whoToFollow, see + partials/who-to-follow) and commits on the rail. Under a commit hangs its thread, inside its row's hairline, as on + the web: the newest reply devnull previews, with "Show more" when there are others, or the replies opened in place. + The commit's rail runs on past them when its author's run goes on below. Given a $rowCache (see App\Devnull\RowCache), commits and replies that haven't changed since the last frame are drawn from it rather than built again. Given a $rowWindow (see App\Devnull\RowWindow), rows far from the screen @@ -11,6 +11,7 @@ @php $rowCache ??= null; $rowWindow ??= null; + $whoToFollow ??= null; @endphp @foreach ($rows as $feedIndex => $row) @if ($row->isDayMarker()) @@ -21,6 +22,14 @@ $rowWindow->placeholder('day-'.$row->label); @endphp @endif + @elseif ($row->isWhoToFollow()) + @if ($rowWindow?->place($feedIndex) ?? true) + @include('native.partials.who-to-follow', ['whoToFollow' => $whoToFollow]) + @else + @php + $rowWindow->placeholder('who-to-follow'); + @endphp + @endif @elseif ($row->isLastVisitMarker()) @if ($rowWindow?->place($feedIndex) ?? true) @include('native.partials.last-visit-marker', ['lane' => $row->previousLane]) diff --git a/resources/views/native/partials/log-page.blade.php b/resources/views/native/partials/log-page.blade.php index fa97fbb..76a2345 100644 --- a/resources/views/native/partials/log-page.blade.php +++ b/resources/views/native/partials/log-page.blade.php @@ -39,7 +39,7 @@ class="w-full flex-1 bg-theme-surface" @endif @endif - @include('native.partials.feed', ['rows' => $page['rows'], 'threads' => $page['threads'], 'rowCache' => $page['rowCache'], 'rowWindow' => $page['rowWindow']]) + @include('native.partials.feed', ['rows' => $page['rows'], 'threads' => $page['threads'], 'rowCache' => $page['rowCache'], 'rowWindow' => $page['rowWindow'], 'whoToFollow' => $page['whoToFollow']]) @if ($page['lastCommit']) @if ($page['nextCursor'] !== null) diff --git a/resources/views/native/partials/who-to-follow.blade.php b/resources/views/native/partials/who-to-follow.blade.php new file mode 100644 index 0000000..0482fed --- /dev/null +++ b/resources/views/native/partials/who-to-follow.blade.php @@ -0,0 +1,94 @@ +{{-- + Who to follow, in the log under its fifth commit, as the web shows it on smaller screens: `$ who --should-i-follow` + in muted mono with a refresh and Don't show again beside it, then three people in a bordered list. Each has their + avatar, name, their handle after a dot in their lane, the first line of their bio as a `#` comment and why they're + suggested, with Follow and Not interested as icons on the right. A tap anywhere else opens them. + + $whoToFollow['note'] says what it shows instead: 'hidden' just after Don't show again, with an Undo, or 'emptied' + once nobody is left to suggest. The rail stops above it (see Timeline), so its gutter is empty. +--}} +@php($people = $whoToFollow['people']) +@php($note = $whoToFollow['note']) + + + + + + $ who --should-i-follow{{ $note === 'hidden' ? ' > /dev/null' : '' }} + @if ($note === null) + {{-- While the next few are on their way the refresh is faded, with nothing to press, as the web disables it. --}} + @if ($whoToFollow['refreshing']) + + + + @else + + + + + + @endif + + + + + + @endif + + + @if ($note === 'hidden') + + Bring it back in Settings. + Undo + + @elseif ($note === 'emptied') + + fatal: no one left to suggest + You've followed or dismissed everyone we'd suggest. New people show up here as they start committing. + + @else + + @foreach ($people as $suggested) + @php($user = $suggested->user) + @php($reason = $suggested->reason()) + + + + @include('native.partials.avatar', ['user' => $user, 'size' => 36]) + + {{ $user->name }} + + + {{ $user->handle() }} + + @if (trim((string) $user->bio) !== '') + # {{ trim($user->bio) }} + @endif + @if ($reason) + {{ $reason }} + @endif + + + + + + @php($followIcon = $suggested->isFollowed ? \App\Icons\Lucide::UserCheck : \App\Icons\Lucide::UserPlus) + + + + + + + + + + @unless ($loop->last) + + @endunless + @endforeach + + @endif + + + + diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index 35944ac..b8975b9 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -182,6 +182,19 @@ @endif + @if ($user) + {{-- The web's Who to follow setting, in its words, as a switch like the others here. --}} + + + + Who to follow + Three people you might want to follow, in your log. + + + + + @endif + @include('native.partials.settings-heading', [ 'title' => 'Safety', diff --git a/tests/Feature/Devnull/ClientCacheTest.php b/tests/Feature/Devnull/ClientCacheTest.php index fb33dea..fdbfb25 100644 --- a/tests/Feature/Devnull/ClientCacheTest.php +++ b/tests/Feature/Devnull/ClientCacheTest.php @@ -274,6 +274,7 @@ keep('me/muted', pageJson([userJson(['id' => 'u3', 'username' => 'grace'])])); keep('log/following', [...pageJson([commitJson(['id' => 'c2', 'author' => $ada]), commitJson(['id' => 'c1'])]), 'suggestions' => [commitJson(['id' => 'c4', 'author' => $ada])]]); keep('log/trending', [...pageJson([commitJson(['id' => 'c3', 'author' => $ada])]), 'is_ranked' => true]); + keep(Client::SuggestedUsersPath, ['data' => [$ada, userJson(['id' => 'u3', 'username' => 'grace'])]], ['round' => '0']); serveApi([ 'POST /api/v1/users/ada/mute' => [['data' => $ada, 'is_muting' => true]], 'DELETE /api/v1/users/ada/mute' => [['data' => $ada, 'is_muting' => false]], @@ -286,6 +287,7 @@ ->and(array_column(kept('log/following')['data'], 'id'))->toBe(['c1']) ->and(kept('log/following')['suggestions'])->toBe([]) ->and(kept('log/trending')['data'])->toBe([]) + ->and(array_column(kept(Client::SuggestedUsersPath, ['round' => '0'])['data'], 'username'))->toBe(['grace']) // Their own profile still shows their commits, as on the web. ->and(array_column(kept('users/ada/commits')['data'], 'id'))->toBe(['c2']); @@ -378,3 +380,15 @@ 'a block' => ['POST /api/v1/users/ada/block', fn (Client $devnull) => $devnull->block('ada')], 'an answer without the user' => ['POST /api/v1/users/ada/mute', fn (Client $devnull) => $devnull->unmute('ada')], ]); + +it('takes someone not interesting out of every kept page of who to follow', function () { + keep(Client::SuggestedUsersPath, ['data' => [userJson(['id' => 'u2', 'username' => 'ada']), userJson(['id' => 'u3', 'username' => 'grace'])]], ['round' => '0']); + keep(Client::SuggestedUsersPath, ['data' => [userJson(['id' => 'u2', 'username' => 'ada'])]], ['round' => '1']); + serveApi(['POST /api/v1/users/ada/dismiss' => [null, 204]]); + + $this->devnull->dismissSuggestion('ada'); + + expect(array_column(kept(Client::SuggestedUsersPath, ['round' => '0'])['data'], 'username'))->toBe(['grace']) + ->and(kept(Client::SuggestedUsersPath, ['round' => '1'])['data'])->toBe([]) + ->and(sentTo('POST', 'users/ada/dismiss'))->toHaveCount(1); +}); diff --git a/tests/Feature/Devnull/WhoToFollowStateTest.php b/tests/Feature/Devnull/WhoToFollowStateTest.php new file mode 100644 index 0000000..731002e --- /dev/null +++ b/tests/Feature/Devnull/WhoToFollowStateTest.php @@ -0,0 +1,37 @@ +show('u1', false); + + expect($whoToFollow->shows('u1'))->toBeFalse() + ->and($whoToFollow->shows('u2'))->toBeTrue() + ->and($whoToFollow->shows(null))->toBeFalse(); + + $whoToFollow->show('u1', true); + + expect($whoToFollow->shows('u1'))->toBeTrue(); +}); + +it('remembers the round of the day, and starts the next day at the first', function () { + $whoToFollow = app(WhoToFollow::class); + CarbonImmutable::setTestNow('2026-10-22T10:00:00Z'); + + expect($whoToFollow->round('u1'))->toBe(0); + + $whoToFollow->rememberRound('u1', 2); + $whoToFollow->rememberRound(null, 5); + + expect($whoToFollow->round('u1'))->toBe(2) + ->and($whoToFollow->round('u2'))->toBe(0); + + CarbonImmutable::setTestNow('2026-10-23T00:30:00Z'); + + expect($whoToFollow->round('u1'))->toBe(0); + + CarbonImmutable::setTestNow(); +}); diff --git a/tests/Feature/Screens/LogCacheTest.php b/tests/Feature/Screens/LogCacheTest.php index bf3c6b2..0fa1b75 100644 --- a/tests/Feature/Screens/LogCacheTest.php +++ b/tests/Feature/Screens/LogCacheTest.php @@ -120,7 +120,8 @@ function shownCommitIds(TestableComponent $screen): array expect(shownCommitIds($screen))->toBe(['c3', 'c2', 'c1']) ->and(count(Http::recorded()))->toBe($requests) - ->and(refreshesSent($screen))->toBe(2) + // The log, you for the unread badge, and who to follow. + ->and(refreshesSent($screen))->toBe(3) // A new list key is how the log gets back to the top. ->and($screen->get('listKey'))->toBe($listKey + 1); }); diff --git a/tests/Feature/Screens/WhoToFollowTest.php b/tests/Feature/Screens/WhoToFollowTest.php new file mode 100644 index 0000000..5f58c11 --- /dev/null +++ b/tests/Feature/Screens/WhoToFollowTest.php @@ -0,0 +1,357 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * Someone who to follow suggests, as `GET /me/suggested-users` sends them. + * + * @param array $overrides + * @return array + */ +function suggestedJson(string $username, array $overrides = []): array +{ + return [ + ...userJson(['id' => 'id-'.$username, 'username' => $username, 'name' => ucfirst($username), 'bio' => null, 'lane' => 1]), + 'followers_count' => 0, + 'followed_by_following_count' => 0, + 'follows_you' => false, + 'is_followed' => false, + ...$overrides, + ]; +} + +/** + * A Following log of $count commits, and who to follow answering with $people. + * + * @param list> $people + * @param array $routes + */ +function serveLogWithSuggestions(array $people, int $count = 8, array $routes = []): void +{ + serveApi([ + 'GET /api/v1/log/following' => [pageJson(array_map(fn (int $index): array => commitJson(['id' => "c{$index}", 'body' => "Commit {$index}"]), range(1, $count)))], + 'GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0]], + 'GET /api/v1/me/suggested-users' => [['data' => $people]], + ...$routes, + ]); +} + +/** + * The refs of the log's rows, in order. + * + * @return list + */ +function logRowRefs(TestableComponent $screen): array +{ + return array_values(array_filter(array_map(fn (array $row): ?string => $row['ref'] ?? null, showingLog($screen)['children'] ?? []))); +} + +/** + * The people who to follow shows, by username. + * + * @return list + */ +function shownSuggestions(TestableComponent $screen): array +{ + return array_map(fn (array $node): string => substr($node['ref'], strlen('suggested-')), findNodes(nodeByRef($screen, 'who-to-follow'), fn (array $node): bool => preg_match('/^suggested-(?!follow-|dismiss-)/', $node['ref'] ?? '') === 1)); +} + +/** + * The rounds asked for so far. + * + * @return list + */ +function suggestionRoundsAsked(): array +{ + return Http::recorded(fn (Request $request): bool => str_contains($request->url(), '/api/v1/me/suggested-users')) + ->map(fn (array $pair): ?string => $pair[0]->data()['round'] ?? null) + ->values() + ->all(); +} + +it('suggests three people under the fifth commit, each with why', function () { + serveLogWithSuggestions([ + suggestedJson('ada', ['bio' => 'Compilers and coffee', 'followed_by_following_count' => 2]), + suggestedJson('grace', ['followers_count' => 1200]), + suggestedJson('linus', ['followers_count' => 1]), + suggestedJson('spare'), + ]); + + $screen = Native::visit('/log') + ->assertSee('$ who --should-i-follow') + ->assertSee('# Compilers and coffee') + ->assertSee('Followed by 2 you follow') + ->assertSee('1.2k followers') + ->assertSee('1 follower') + ->assertDontSee('Spare'); + + $rows = logRowRefs($screen); + + expect(shownSuggestions($screen))->toBe(['ada', 'grace', 'linus']) + ->and(array_search('who-to-follow', $rows, true))->toBe(array_search('commit-c5', $rows, true) + 1) + ->and(suggestionRoundsAsked())->toBe(['0']); +}); + +it('sits under the last commit of a shorter log', function () { + serveLogWithSuggestions([suggestedJson('ada')], count: 2); + + $rows = logRowRefs(Native::visit('/log')); + + expect(array_slice($rows, -2))->toBe(['who-to-follow', 'root-commit']); +}); + +it('leaves who to follow out while there is nobody to suggest', function () { + serveLogWithSuggestions([]); + + Native::visit('/log')->assertDontSee('$ who --should-i-follow'); +}); + +it('shows the people kept from last time at once, then asks again', function () { + holdRefreshes(); + keep('log/following', pageJson([commitJson(['id' => 'c1'])])); + keep('me/suggested-users', ['data' => [suggestedJson('ada')]], ['round' => '0']); + + $screen = Native::visit('/log'); + + expect(shownSuggestions($screen))->toBe(['ada']); + + serveLogWithSuggestions([suggestedJson('grace')]); + + expect(shownSuggestions(finishRefreshes($screen)))->toBe(['grace']); +}); + +it('follows someone suggested in place, and unfollows them again', function () { + serveLogWithSuggestions([suggestedJson('ada')], routes: [ + 'POST /api/v1/users/ada/follow' => [null, 204], + 'DELETE /api/v1/users/ada/follow' => [null, 204], + ]); + + $screen = Native::visit('/log'); + + expect(nodeByRef($screen, 'suggested-follow-ada')['props']['a11y_label'])->toBe('Follow @ada'); + + $screen->tap('suggested-follow-ada'); + + expect($screen->instance()->suggestedUsers[0]->isFollowed)->toBeTrue() + ->and(sentTo('POST', 'users/ada/follow'))->toHaveCount(1) + ->and(kept('me/suggested-users', ['round' => '0'])['data'][0]['is_followed'])->toBeTrue(); + + $screen->tap('suggested-follow-ada'); + + expect($screen->instance()->suggestedUsers[0]->isFollowed)->toBeFalse() + ->and(sentTo('DELETE', 'users/ada/follow'))->toHaveCount(1); +}); + +it('says follow back for someone who follows you', function () { + serveLogWithSuggestions([suggestedJson('ada', ['follows_you' => true])]); + + $screen = Native::visit('/log'); + + expect(nodeByRef($screen, 'suggested-follow-ada')['props']['a11y_label'])->toBe('Follow back @ada'); +}); + +it('puts the follow back when devnull refuses it', function () { + serveLogWithSuggestions([suggestedJson('ada')], routes: [ + 'POST /api/v1/users/ada/follow' => [['message' => 'Too many follows.'], 429], + ]); + + $screen = Native::visit('/log')->tap('suggested-follow-ada'); + + expect($screen->instance()->suggestedUsers[0]->isFollowed)->toBeFalse(); +}); + +it('gives the place of someone not interesting to the first spare, then tops the spares up', function () { + serveLogWithSuggestions([suggestedJson('ada'), suggestedJson('grace'), suggestedJson('linus'), suggestedJson('spare'), suggestedJson('other')], routes: [ + 'POST /api/v1/users/grace/dismiss' => [null, 204], + ]); + + $screen = Native::visit('/log'); + holdRefreshes(); + $screen->tap('suggested-dismiss-grace'); + + expect(shownSuggestions($screen))->toBe(['ada', 'spare', 'linus']) + ->and(sentTo('POST', 'users/grace/dismiss'))->toHaveCount(1) + ->and(array_column(kept('me/suggested-users', ['round' => '0'])['data'], 'username'))->toBe(['ada', 'linus', 'spare', 'other']); + + serveLogWithSuggestions([suggestedJson('ada'), suggestedJson('linus'), suggestedJson('spare'), suggestedJson('other'), suggestedJson('newcomer')]); + finishRefreshes($screen); + + expect(array_map(fn ($suggested) => $suggested->user->username, $screen->instance()->suggestedUsers))->toBe(['ada', 'spare', 'linus', 'other', 'newcomer']); +}); + +it('keeps someone just followed in place when another is dismissed and devnull no longer suggests them', function () { + serveLogWithSuggestions([suggestedJson('ada'), suggestedJson('grace'), suggestedJson('linus'), suggestedJson('spare')], routes: [ + 'POST /api/v1/users/ada/follow' => [null, 204], + 'POST /api/v1/users/grace/dismiss' => [null, 204], + ]); + + $screen = Native::visit('/log')->tap('suggested-follow-ada'); + + serveLogWithSuggestions([suggestedJson('linus'), suggestedJson('spare'), suggestedJson('newcomer')], routes: [ + 'POST /api/v1/users/grace/dismiss' => [null, 204], + ]); + $screen->tap('suggested-dismiss-grace'); + + expect(shownSuggestions($screen))->toBe(['ada', 'spare', 'linus']) + ->and($screen->instance()->suggestedUsers[0]->isFollowed)->toBeTrue() + ->and(array_map(fn ($suggested) => $suggested->user->username, $screen->instance()->suggestedUsers))->toBe(['ada', 'spare', 'linus', 'newcomer']); +}); + +it('asks devnull again once the last one is dismissed, and shows whoever it still has', function () { + serveLogWithSuggestions([suggestedJson('ada')], routes: [ + 'POST /api/v1/users/ada/dismiss' => [null, 204], + ]); + + $screen = Native::visit('/log'); + holdRefreshes(); + $screen->tap('suggested-dismiss-ada'); + serveLogWithSuggestions([suggestedJson('grace')]); + finishRefreshes($screen); + + expect(shownSuggestions($screen))->toBe(['grace']) + ->and($screen->instance()->whoToFollowNote)->toBeNull(); +}); + +it('says nobody is left once everyone is dismissed', function () { + serveLogWithSuggestions([suggestedJson('ada')], routes: [ + 'POST /api/v1/users/ada/dismiss' => [null, 204], + ]); + + Native::visit('/log') + ->tap('suggested-dismiss-ada') + ->assertSee('fatal: no one left to suggest'); +}); + +it('brings back someone dismissed when devnull refuses it', function () { + serveLogWithSuggestions([suggestedJson('ada'), suggestedJson('grace'), suggestedJson('linus'), suggestedJson('spare')], routes: [ + 'POST /api/v1/users/grace/dismiss' => [['message' => 'Something went wrong.'], 500], + ]); + + $screen = Native::visit('/log')->tap('suggested-dismiss-grace'); + + expect(shownSuggestions($screen))->toBe(['ada', 'grace', 'linus']); +}); + +it('suggests other people from the next round, and keeps to it', function () { + serveLogWithSuggestions([suggestedJson('ada')]); + + $screen = Native::visit('/log'); + + serveLogWithSuggestions([suggestedJson('grace')]); + + $screen->tap('who-to-follow-refresh'); + + expect(shownSuggestions($screen))->toBe(['grace']) + ->and(suggestionRoundsAsked())->toBe(['0', '1']) + ->and(app(WhoToFollow::class)->round('0199e5c4-0000-7000-8000-000000000001'))->toBe(1); +}); + +it('leaves an answer for the round before alone once the next is asked for', function () { + holdRefreshes(); + serveApi(function (Request $request) { + $path = parse_url($request->url(), PHP_URL_PATH); + + return match ($path) { + '/api/v1/me/suggested-users' => Http::response(['data' => [suggestedJson(($request->data()['round'] ?? '0') === '1' ? 'grace' : 'ada')]]), + '/api/v1/log/following' => Http::response(pageJson([commitJson(['id' => 'c1'])])), + default => Http::response(['data' => userWithCountsJson(), 'unread_notifications_count' => 0]), + }; + }); + keep('log/following', pageJson([commitJson(['id' => 'c1'])])); + keep('me/suggested-users', ['data' => [suggestedJson('kept')]], ['round' => '0']); + + $screen = Native::visit('/log')->tap('who-to-follow-refresh'); + + expect(nodeByRef($screen, 'who-to-follow-refresh')['props']['a11y_label'] ?? null)->toBe('Suggesting other people'); + + finishRefreshes($screen); + + expect(shownSuggestions($screen))->toBe(['grace']) + ->and($screen->instance()->suggestingOtherPeople)->toBeFalse() + ->and(app(WhoToFollow::class)->round('0199e5c4-0000-7000-8000-000000000001'))->toBe(1); +}); + +it('keeps the round and who is showing when the next few fail to come', function () { + serveLogWithSuggestions([suggestedJson('ada')]); + + $screen = Native::visit('/log'); + + serveLogWithSuggestions([], routes: ['GET /api/v1/me/suggested-users' => [['message' => 'Server Error'], 500]]); + + $screen->tap('who-to-follow-refresh'); + + expect(shownSuggestions($screen))->toBe(['ada']) + ->and($screen->instance()->suggestingOtherPeople)->toBeFalse() + ->and(app(WhoToFollow::class)->round('0199e5c4-0000-7000-8000-000000000001'))->toBe(0); +}); + +it('never brings back someone dismissed with an answer that crossed the dismissal', function () { + holdRefreshes(); + keep('log/following', pageJson([commitJson(['id' => 'c1'])])); + keep('me/suggested-users', ['data' => [suggestedJson('ada'), suggestedJson('grace')]], ['round' => '0']); + serveLogWithSuggestions([suggestedJson('ada'), suggestedJson('grace')], routes: [ + 'POST /api/v1/users/grace/dismiss' => [null, 204], + ]); + + $screen = Native::visit('/log')->tap('suggested-dismiss-grace'); + + finishRefreshes($screen); + + expect(shownSuggestions($screen))->toBe(['ada']); +}); + +it('hides who to follow with an undo, and leaves it out from then on', function () { + serveLogWithSuggestions([suggestedJson('ada')]); + + $screen = Native::visit('/log') + ->tap('who-to-follow-hide') + ->assertSee('$ who --should-i-follow > /dev/null') + ->assertSee('Bring it back in Settings.'); + + expect(shownSuggestions($screen))->toBe([]); + + $screen->tap('who-to-follow-undo'); + + expect(shownSuggestions($screen))->toBe(['ada']); + + $screen->tap('who-to-follow-hide'); + + $requestsBefore = count(suggestionRoundsAsked()); + + Native::visit('/log')->assertDontSee('$ who --should-i-follow'); + + expect(suggestionRoundsAsked())->toHaveCount($requestsBefore); +}); + +it('brings who to follow back from settings', function () { + app(WhoToFollow::class)->show('0199e5c4-0000-7000-8000-000000000001', false); + fakeApi(['GET /api/v1/me' => [['data' => currentUserJson()]]]); + + $settings = Native::visit('/profile/device'); + + expect(nodeByRef($settings, 'who-to-follow-switch')['props']['value'] ?? null)->toBeFalse(); + + $settings->toggle('who-to-follow-switch', true); + + expect(app(WhoToFollow::class)->shows('0199e5c4-0000-7000-8000-000000000001'))->toBeTrue() + ->and(nodeByRef($settings, 'who-to-follow-switch')['props']['value'] ?? null)->toBeTrue(); +}); + +it('only puts who to follow in the log you are on', function () { + serveLogWithSuggestions([suggestedJson('ada')]); + keep('log/latest', pageJson(array_map(fn (int $index): array => commitJson(['id' => "l{$index}"]), range(1, 6)), path: 'http://devnull.test/api/v1/log/latest')); + + $screen = Native::visit('/log'); + + expect(findNodes(nodeByRef($screen, 'log-latest'), fn (array $node): bool => ($node['ref'] ?? null) === 'who-to-follow'))->toBe([]) + ->and(logRowRefs($screen))->toContain('who-to-follow'); +}); diff --git a/tests/Unit/Devnull/SuggestedUserTest.php b/tests/Unit/Devnull/SuggestedUserTest.php new file mode 100644 index 0000000..a1997ea --- /dev/null +++ b/tests/Unit/Devnull/SuggestedUserTest.php @@ -0,0 +1,30 @@ + $overrides + */ +function suggestedUser(array $overrides = []): SuggestedUser +{ + return SuggestedUser::fromArray(['id' => 'u2', 'username' => 'ada', 'followers_count' => 0, 'followed_by_following_count' => 0, ...$overrides]); +} + +it('says why someone is suggested as the web does', function (array $counts, ?string $reason) { + expect(suggestedUser($counts)->reason())->toBe($reason); +})->with([ + 'people you follow follow them' => [['followed_by_following_count' => 2, 'followers_count' => 40], 'Followed by 2 you follow'], + 'one follower' => [['followers_count' => 1], '1 follower'], + 'many followers, compact' => [['followers_count' => 1250], '1.3k followers'], + 'nobody' => [[], null], +]); + +it('reads whether you follow them and whether they follow you', function () { + $suggested = suggestedUser(['follows_you' => true]); + + expect($suggested->id)->toBe('u2') + ->and($suggested->followsYou)->toBeTrue() + ->and($suggested->isFollowed)->toBeFalse() + ->and($suggested->withFollowed(true)->isFollowed)->toBeTrue() + ->and(SuggestedUser::fromArray(['id' => 'u3', 'username' => 'grace', 'followers_count' => 'many'])->followersCount)->toBe(0); +}); diff --git a/tests/Unit/Devnull/TimelineTest.php b/tests/Unit/Devnull/TimelineTest.php index 25fbaa4..e594e9d 100644 --- a/tests/Unit/Devnull/TimelineTest.php +++ b/tests/Unit/Devnull/TimelineTest.php @@ -21,7 +21,7 @@ function timelineCommit(string $id, string $userId, string $createdAt, array $ov } /** - * Each row as a short string: "day:Today", "visit", or "c1:avatar" / "c1:dot". + * Each row as a short string: "day:Today", "visit", "follow", or "c1:avatar" / "c1:dot". * * @param list $rows * @return list @@ -31,6 +31,7 @@ function describeRows(array $rows): array return array_map(fn (TimelineRow $row): string => match (true) { $row->isDayMarker() => "day:{$row->label}", $row->isLastVisitMarker() => 'visit', + $row->isWhoToFollow() => 'follow', default => $row->commit->id.':'.($row->continuesRun ? 'dot' : 'avatar'), }, $rows); } @@ -115,6 +116,26 @@ function describeRows(array $rows): array ->and($rows[4]->endsWithDot())->toBeTrue(); }); +it('puts who to follow under the fifth commit, and the commit below it starts a run of its own', function () { + $commits = array_map(fn (int $index): Commit => timelineCommit("c{$index}", 'ada', '2026-10-15T1'.(9 - $index).':00:00Z'), range(1, 7)); + + $rows = Timeline::feed($commits, now: $this->now, whoToFollowAfter: 5); + + expect(describeRows($rows))->toBe(['day:Today', 'c1:avatar', 'c2:dot', 'c3:dot', 'c4:dot', 'c5:dot', 'follow', 'c6:avatar', 'c7:dot']) + ->and($rows[5]->continuesNext)->toBeFalse() + ->and($rows[7]->previousLane)->toBeNull() + ->and($rows[7]->continuesNext)->toBeTrue(); +}); + +it('puts who to follow under the last commit of a shorter feed, and in Trending', function () { + $feed = Timeline::feed([timelineCommit('c1', 'ada', '2026-10-15T10:00:00Z')], now: $this->now, whoToFollowAfter: 5); + $ranked = Timeline::ranked([], [timelineCommit('c2', 'grace', '2026-10-13T11:00:00Z'), timelineCommit('c1', 'ada', '2026-10-15T09:00:00Z')], whoToFollowAfter: 1); + + expect(describeRows($feed))->toBe(['day:Today', 'c1:avatar', 'follow']) + ->and(describeRows($ranked))->toBe(['c2:avatar', 'follow', 'c1:avatar']) + ->and(describeRows(Timeline::feed([], whoToFollowAfter: 5)))->toBe([]); +}); + it('never runs on from or into a deleted commit', function () { $tombstone = Commit::fromArray(['id' => 't1', 'deleted_at' => '2026-10-15T09:30:00Z', 'replies_count' => 1]); From 8cdd041208d5fb5293172c4423db94e639c09bff Mon Sep 17 00:00:00 2001 From: Dennis Petersmann Date: Thu, 22 Oct 2026 21:38:48 +0200 Subject: [PATCH 2/2] Keep who to follow only while the Log is open, as the web keeps it for the visit devnull picks afresh each day and the screen asks again anyway, so a kept page saved no request and could show yesterday's picks, or an answer that crossed a follow or a dismissal, on the next visit. Coming back to the Log asks again instead, and the response cache is as it was. --- app/Devnull/Client.php | 1 - app/Devnull/ResponseCache.php | 39 +------------------ .../Concerns/SuggestsWhoToFollow.php | 21 ++++------ app/NativeComponents/Log.php | 2 +- tests/Feature/Devnull/ClientCacheTest.php | 14 ------- tests/Feature/Devnull/ClientTest.php | 1 + tests/Feature/Screens/WhoToFollowTest.php | 32 +++++++-------- 7 files changed, 27 insertions(+), 83 deletions(-) diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 258ae62..fd778fa 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -440,7 +440,6 @@ public function unfollow(string $username): void public function dismissSuggestion(string $username): void { $this->send('POST', 'users/'.rawurlencode($username).'/dismiss'); - $this->cache->suggestionDismissed($username); } /** diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index 48c3fa0..1adcd54 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -38,7 +38,6 @@ final class ResponseCache '#^me$#', '#^me/muted$#', '#^me/blocked$#', - '#^me/suggested-users$#', '#^notifications$#', '#^notifications/[^/]+/group$#', '#^log/[^/]+$#', @@ -57,7 +56,6 @@ final class ResponseCache * The kept answers that list people with whether you follow each one. */ private const array FollowedPeople = [ - '#^me/suggested-users$#', '#^users/[^/]+/(followers|following)$#', '#^notifications$#', '#^notifications/[^/]+/group$#', @@ -446,15 +444,6 @@ private function patchFollowedPeople(string $username, bool $following): void } } - /** - * You said you're not interested in someone who to follow suggested: they - * leave every kept page of suggestions, and a spare moves up. - */ - public function suggestionDismissed(string $username): void - { - $this->dropFromSuggestions(fn (array $user): bool => ($user['username'] ?? null) === $username); - } - /** * You muted or unmuted someone, and devnull answered with them and * whether you mute them now: their profile says so, and they join the top @@ -543,12 +532,10 @@ private static function changedUser(array $json, string $state): array /** * Takes someone's commits out of every kept log, and out of what an empty - * Following log suggests, and them out of who to follow. + * Following log suggests. */ private function dropFromLogs(string $authorId): void { - $this->dropFromSuggestions(fn (array $user): bool => ($user['id'] ?? null) === $authorId); - $userId = $this->userId(); foreach ($userId === null ? [] : $this->index($userId) as $meta) { @@ -568,30 +555,6 @@ private function dropFromLogs(string $authorId): void } } - /** - * Takes the people $drops picks out of every kept page of who to follow. - * - * @param Closure(array): bool $drops - */ - private function dropFromSuggestions(Closure $drops): void - { - $userId = $this->userId(); - - foreach ($userId === null ? [] : $this->index($userId) as $meta) { - if ($meta['path'] !== Client::SuggestedUsersPath) { - continue; - } - - $this->patch($meta['path'], $meta['query'], function (array $json) use ($drops): array { - if (is_array($json['data'] ?? null)) { - $json['data'] = array_values(array_filter($json['data'], fn (mixed $user): bool => ! is_array($user) || ! $drops($user))); - } - - return $json; - }); - } - } - /** * A kept list of people, with $user on top, or without them. * diff --git a/app/NativeComponents/Concerns/SuggestsWhoToFollow.php b/app/NativeComponents/Concerns/SuggestsWhoToFollow.php index 98a08ca..86fc949 100644 --- a/app/NativeComponents/Concerns/SuggestsWhoToFollow.php +++ b/app/NativeComponents/Concerns/SuggestsWhoToFollow.php @@ -66,10 +66,13 @@ trait SuggestsWhoToFollow protected array $dismissedSuggestions = []; /** - * The suggestions kept for the round showing, then asked again. A - * refresh asked for less than 30 seconds ago isn't sent twice. + * Asks devnull for the round showing, as the web loads who to follow + * after the page. They're only kept while the Log is open, as the web + * keeps them for the visit: devnull picks afresh each day, so a kept + * page would be yesterday's. Coming back to the Log asks again, since a + * follow, mute or block elsewhere changes who's suggested. */ - protected function openWhoToFollow(): void + protected function openWhoToFollow(bool $again = false): void { $userId = $this->me()?->id; @@ -77,16 +80,8 @@ protected function openWhoToFollow(): void return; } - $round = app(WhoToFollow::class)->round($userId); - $this->wantedRound = $round; - $kept = $this->cached(Client::SuggestedUsersPath, ['round' => (string) $round]); - - // The kept answer has any follow or dismissal made since, here or on another screen. - if ($kept !== null) { - $this->takeSuggestedUsers($kept); - } - - $this->askForSuggestedUsers($round); + $this->wantedRound = app(WhoToFollow::class)->round($userId); + $this->askForSuggestedUsers($this->wantedRound, again: $again); } /** diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index 4613ad4..d577e92 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -265,7 +265,7 @@ public function onResume(): void $this->showKept(); $this->revalidate(); $this->prefetchNextPage(); - $this->openWhoToFollow(); + $this->openWhoToFollow(again: true); } /** diff --git a/tests/Feature/Devnull/ClientCacheTest.php b/tests/Feature/Devnull/ClientCacheTest.php index fdbfb25..fb33dea 100644 --- a/tests/Feature/Devnull/ClientCacheTest.php +++ b/tests/Feature/Devnull/ClientCacheTest.php @@ -274,7 +274,6 @@ keep('me/muted', pageJson([userJson(['id' => 'u3', 'username' => 'grace'])])); keep('log/following', [...pageJson([commitJson(['id' => 'c2', 'author' => $ada]), commitJson(['id' => 'c1'])]), 'suggestions' => [commitJson(['id' => 'c4', 'author' => $ada])]]); keep('log/trending', [...pageJson([commitJson(['id' => 'c3', 'author' => $ada])]), 'is_ranked' => true]); - keep(Client::SuggestedUsersPath, ['data' => [$ada, userJson(['id' => 'u3', 'username' => 'grace'])]], ['round' => '0']); serveApi([ 'POST /api/v1/users/ada/mute' => [['data' => $ada, 'is_muting' => true]], 'DELETE /api/v1/users/ada/mute' => [['data' => $ada, 'is_muting' => false]], @@ -287,7 +286,6 @@ ->and(array_column(kept('log/following')['data'], 'id'))->toBe(['c1']) ->and(kept('log/following')['suggestions'])->toBe([]) ->and(kept('log/trending')['data'])->toBe([]) - ->and(array_column(kept(Client::SuggestedUsersPath, ['round' => '0'])['data'], 'username'))->toBe(['grace']) // Their own profile still shows their commits, as on the web. ->and(array_column(kept('users/ada/commits')['data'], 'id'))->toBe(['c2']); @@ -380,15 +378,3 @@ 'a block' => ['POST /api/v1/users/ada/block', fn (Client $devnull) => $devnull->block('ada')], 'an answer without the user' => ['POST /api/v1/users/ada/mute', fn (Client $devnull) => $devnull->unmute('ada')], ]); - -it('takes someone not interesting out of every kept page of who to follow', function () { - keep(Client::SuggestedUsersPath, ['data' => [userJson(['id' => 'u2', 'username' => 'ada']), userJson(['id' => 'u3', 'username' => 'grace'])]], ['round' => '0']); - keep(Client::SuggestedUsersPath, ['data' => [userJson(['id' => 'u2', 'username' => 'ada'])]], ['round' => '1']); - serveApi(['POST /api/v1/users/ada/dismiss' => [null, 204]]); - - $this->devnull->dismissSuggestion('ada'); - - expect(array_column(kept(Client::SuggestedUsersPath, ['round' => '0'])['data'], 'username'))->toBe(['grace']) - ->and(kept(Client::SuggestedUsersPath, ['round' => '1'])['data'])->toBe([]) - ->and(sentTo('POST', 'users/ada/dismiss'))->toHaveCount(1); -}); diff --git a/tests/Feature/Devnull/ClientTest.php b/tests/Feature/Devnull/ClientTest.php index e8c714a..fd99d7b 100644 --- a/tests/Feature/Devnull/ClientTest.php +++ b/tests/Feature/Devnull/ClientTest.php @@ -195,6 +195,7 @@ function receivedMessage(Request $request): string })->with([ ['follow', 'POST', '/api/v1/users/local_dev/follow'], ['unfollow', 'DELETE', '/api/v1/users/local_dev/follow'], + ['dismissSuggestion', 'POST', '/api/v1/users/local_dev/dismiss'], ['deleteCommit', 'DELETE', '/api/v1/commits/local_dev'], ]); diff --git a/tests/Feature/Screens/WhoToFollowTest.php b/tests/Feature/Screens/WhoToFollowTest.php index 5f58c11..927639a 100644 --- a/tests/Feature/Screens/WhoToFollowTest.php +++ b/tests/Feature/Screens/WhoToFollowTest.php @@ -115,18 +115,18 @@ function suggestionRoundsAsked(): array Native::visit('/log')->assertDontSee('$ who --should-i-follow'); }); -it('shows the people kept from last time at once, then asks again', function () { - holdRefreshes(); - keep('log/following', pageJson([commitJson(['id' => 'c1'])])); - keep('me/suggested-users', ['data' => [suggestedJson('ada')]], ['round' => '0']); +it('keeps nothing between visits, and asks again when you come back to the Log', function () { + serveLogWithSuggestions([suggestedJson('ada')]); $screen = Native::visit('/log'); - expect(shownSuggestions($screen))->toBe(['ada']); + expect(kept('me/suggested-users', ['round' => '0']))->toBeNull(); serveLogWithSuggestions([suggestedJson('grace')]); + $screen->call('onResume'); - expect(shownSuggestions(finishRefreshes($screen)))->toBe(['grace']); + expect(shownSuggestions($screen))->toBe(['grace']) + ->and(suggestionRoundsAsked())->toBe(['0', '0']); }); it('follows someone suggested in place, and unfollows them again', function () { @@ -142,8 +142,7 @@ function suggestionRoundsAsked(): array $screen->tap('suggested-follow-ada'); expect($screen->instance()->suggestedUsers[0]->isFollowed)->toBeTrue() - ->and(sentTo('POST', 'users/ada/follow'))->toHaveCount(1) - ->and(kept('me/suggested-users', ['round' => '0'])['data'][0]['is_followed'])->toBeTrue(); + ->and(sentTo('POST', 'users/ada/follow'))->toHaveCount(1); $screen->tap('suggested-follow-ada'); @@ -179,8 +178,7 @@ function suggestionRoundsAsked(): array $screen->tap('suggested-dismiss-grace'); expect(shownSuggestions($screen))->toBe(['ada', 'spare', 'linus']) - ->and(sentTo('POST', 'users/grace/dismiss'))->toHaveCount(1) - ->and(array_column(kept('me/suggested-users', ['round' => '0'])['data'], 'username'))->toBe(['ada', 'linus', 'spare', 'other']); + ->and(sentTo('POST', 'users/grace/dismiss'))->toHaveCount(1); serveLogWithSuggestions([suggestedJson('ada'), suggestedJson('linus'), suggestedJson('spare'), suggestedJson('other'), suggestedJson('newcomer')]); finishRefreshes($screen); @@ -267,9 +265,10 @@ function suggestionRoundsAsked(): array }; }); keep('log/following', pageJson([commitJson(['id' => 'c1'])])); - keep('me/suggested-users', ['data' => [suggestedJson('kept')]], ['round' => '0']); - $screen = Native::visit('/log')->tap('who-to-follow-refresh'); + $screen = finishRefreshes(Native::visit('/log')); + holdRefreshes(); + $screen->tap('who-to-follow-refresh'); expect(nodeByRef($screen, 'who-to-follow-refresh')['props']['a11y_label'] ?? null)->toBe('Suggesting other people'); @@ -295,14 +294,15 @@ function suggestionRoundsAsked(): array }); it('never brings back someone dismissed with an answer that crossed the dismissal', function () { - holdRefreshes(); - keep('log/following', pageJson([commitJson(['id' => 'c1'])])); - keep('me/suggested-users', ['data' => [suggestedJson('ada'), suggestedJson('grace')]], ['round' => '0']); serveLogWithSuggestions([suggestedJson('ada'), suggestedJson('grace')], routes: [ 'POST /api/v1/users/grace/dismiss' => [null, 204], ]); - $screen = Native::visit('/log')->tap('suggested-dismiss-grace'); + $screen = Native::visit('/log'); + holdRefreshes(); + // Coming back to the Log asks again, and grace is dismissed while that answer is on its way. + $screen->call('onResume'); + $screen->tap('suggested-dismiss-grace'); finishRefreshes($screen);