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..fd778fa 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,14 @@ 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'); + } + /** * 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/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..86fc949 --- /dev/null +++ b/app/NativeComponents/Concerns/SuggestsWhoToFollow.php @@ -0,0 +1,323 @@ + + */ + 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 = []; + + /** + * 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(bool $again = false): void + { + $userId = $this->me()?->id; + + if (! app(WhoToFollow::class)->shows($userId) || $this->suggestingOtherPeople) { + return; + } + + $this->wantedRound = app(WhoToFollow::class)->round($userId); + $this->askForSuggestedUsers($this->wantedRound, again: $again); + } + + /** + * 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..d577e92 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(again: true); } /** @@ -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/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/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..927639a --- /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('keeps nothing between visits, and asks again when you come back to the Log', function () { + serveLogWithSuggestions([suggestedJson('ada')]); + + $screen = Native::visit('/log'); + + expect(kept('me/suggested-users', ['round' => '0']))->toBeNull(); + + serveLogWithSuggestions([suggestedJson('grace')]); + $screen->call('onResume'); + + expect(shownSuggestions($screen))->toBe(['grace']) + ->and(suggestionRoundsAsked())->toBe(['0', '0']); +}); + +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); + + $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); + + 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'])])); + + $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'); + + 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 () { + serveLogWithSuggestions([suggestedJson('ada'), suggestedJson('grace')], routes: [ + 'POST /api/v1/users/grace/dismiss' => [null, 204], + ]); + + $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); + + 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]);