From cd9fb439bdb66a028fd1d4128662a5147ba8538c Mon Sep 17 00:00:00 2001 From: Ricardo Valfreixo Date: Fri, 23 Oct 2026 11:33:39 +0200 Subject: [PATCH 1/7] Bring the native notification centre in line with the web --- README.md | 1 - app/Devnull/Client.php | 22 +- app/Devnull/Data/Notification.php | 94 ++- app/Devnull/NotificationFilters.php | 126 +++ app/Icons/Lucide.php | 3 + app/NativeComponents/Notifications.php | 650 ++++++++++++--- resources/icons/chevrons-up.svg | 1 + .../views/native/notifications.blade.php | 79 +- .../views/native/partials/avatar.blade.php | 8 +- .../partials/notification-filter.blade.php | 7 + .../partials/notification-group.blade.php | 11 +- .../native/partials/notification.blade.php | 151 +++- .../notifications-filters-sheet.blade.php | 15 + .../partials/notifications-page.blade.php | 121 ++- tests/Feature/Devnull/ClientTest.php | 38 + .../Devnull/NotificationFiltersTest.php | 89 ++ tests/Feature/Screens/LaneButtonsTest.php | 3 +- .../Screens/NotificationsCacheTest.php | 33 + .../Feature/Screens/NotificationsPollTest.php | 762 ++++++++++++++++++ tests/Feature/Screens/NotificationsTest.php | 377 +++++++-- tests/Feature/Screens/ShownTabsTest.php | 14 +- tests/Pest.php | 13 +- tests/Unit/Devnull/NotificationTest.php | 131 +++ 23 files changed, 2382 insertions(+), 367 deletions(-) create mode 100644 app/Devnull/NotificationFilters.php create mode 100644 resources/icons/chevrons-up.svg create mode 100644 resources/views/native/partials/notification-filter.blade.php create mode 100644 resources/views/native/partials/notifications-filters-sheet.blade.php create mode 100644 tests/Feature/Devnull/NotificationFiltersTest.php create mode 100644 tests/Feature/Screens/NotificationsPollTest.php create mode 100644 tests/Unit/Devnull/NotificationTest.php diff --git a/README.md b/README.md index 0a5dfe6..8dcf54b 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 - A native image viewer, instead of opening images in a browser sheet - 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 diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index c4da605..e1030b0 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -482,14 +482,19 @@ public function blocked(?string $cursor = null): Page /** * Your notifications grouped as the web groups them, filtered by the web's - * ?type= (all, replies, quotes, stars, mentions, follows or moderation). - * Listing any of them marks them all read. + * ?type= (all, none, one or more of replies, quotes, stars, mentions, + * follows and moderation comma-separated, or all). Listing any of them + * marks them all read. + * + * A poll passes $through, the oldest group still showing, so devnull sends + * everything since then on top of a page, as the web asks every 30s. It is + * left off otherwise, which asks for the same first page as ever. * * @return Page */ - public function notifications(string $type = 'all', ?string $cursor = null): Page + public function notifications(string $type = 'all', ?string $cursor = null, ?string $through = null): Page { - return Page::fromArray($this->get('notifications', self::notificationsQuery($type, $cursor)), Notification::fromArray(...)); + return Page::fromArray($this->get('notifications', self::notificationsQuery($type, $cursor, $through)), Notification::fromArray(...)); } /** @@ -503,13 +508,16 @@ public function notificationGroup(string $id, string $type = 'all', ?string $cur } /** - * All is the default, so it's left off, as the web's own links leave it off. + * All is the default, so it's left off, as the web's own links leave it + * off. What isn't sent is left off too: a cursor for the first page, and + * $through for every ask but a poll's, which keeps the first page it asks + * for the same one as ever. * * @return array */ - public static function notificationsQuery(string $type, ?string $cursor = null): array + public static function notificationsQuery(string $type, ?string $cursor = null, ?string $through = null): array { - return ['type' => $type === 'all' ? null : $type, 'cursor' => $cursor]; + return ['type' => $type === 'all' ? null : $type, 'cursor' => $cursor, 'through' => $through]; } /** diff --git a/app/Devnull/Data/Notification.php b/app/Devnull/Data/Notification.php index 6f45aa9..e61e0ea 100644 --- a/app/Devnull/Data/Notification.php +++ b/app/Devnull/Data/Notification.php @@ -9,6 +9,14 @@ * first few people in the group, how many events and people there are and * how many are unread. Expanded, a group lists its notifications one by one * in the same shape. The commit's parent only comes along when you may see it. + * + * The whole commit comes along too, as the API sends it: its images beside a + * star on an image-only commit, and the commit of yours a quote quotes, which + * devnull only sends along when it loaded it — absent rather than null, so a + * quote with none of it sent shows no box at all, as on the web. A level-up + * tells of no level on the API, whose NotificationResource leaves the level, + * rank and its description out; they come along only when actually sent, as + * the web's own page has them, and none of them is made up. */ final readonly class Notification { @@ -33,6 +41,11 @@ public function __construct( public string $groupId = '', public int $eventCount = 1, public ?bool $actorIsFollowed = null, + public ?Commit $commit = null, + public bool $quotedCommitSent = false, + public ?int $level = null, + public ?string $rank = null, + public ?string $rankDescription = null, ) {} /** @@ -50,6 +63,9 @@ public static function fromArray(array $data): self ? (int) $data['unread_count'] > 0 : array_key_exists('read_at', $data) && $data['read_at'] === null; $id = (string) ($data['id'] ?? ''); + $level = $data['level'] ?? null; + $rank = $data['rank'] ?? null; + $rankDescription = $data['rank_description'] ?? null; return new self( id: $id, @@ -69,6 +85,11 @@ public static function fromArray(array $data): self groupId: is_string($data['group_id'] ?? null) && $data['group_id'] !== '' ? $data['group_id'] : $id, eventCount: max(1, (int) ($data['event_count'] ?? 1)), actorIsFollowed: is_bool($actorJson['is_followed'] ?? null) ? $actorJson['is_followed'] : null, + commit: $commit === [] ? null : Commit::fromArray($commit), + quotedCommitSent: array_key_exists('quoted_commit', $commit), + level: is_numeric($level) ? (int) $level : null, + rank: is_string($rank) && trim($rank) !== '' ? $rank : null, + rankDescription: is_string($rankDescription) && trim($rankDescription) !== '' ? $rankDescription : null, ); } @@ -92,6 +113,16 @@ public function isFollow(): bool return $this->type === 'followed'; } + /** + * You reached a level, which the web words without naming you as its + * actor, and opens on your profile. Its level and rank only came along + * when devnull sent them, which its signed API does not. + */ + public function isLevelUp(): bool + { + return $this->type === 'leveled_up'; + } + /** * A moderator approved or rejected your commit. Nobody else is named. */ @@ -113,6 +144,57 @@ public function isAboutYourCommit(): bool return $this->isStar() || $this->isModeration(); } + /** + * The commit is the actor's own words: a reply, a quote or a mention, so + * the web writes their name into the sentence and shows it as their commit. + */ + public function isActorsCommit(): bool + { + return in_array($this->type, ['replied', 'quoted', 'mentioned'], true); + } + + /** + * The images of your commit a star came with, as the web shows them beside + * it: only when devnull loaded them, which its signed API does not today. + * + * @return list + */ + public function starredImages(): array + { + return $this->isStar() && $this->commit !== null ? $this->commit->images : []; + } + + /** + * A star on your commit that says nothing but its images, as the web words + * it "starred your image" and shows nothing of the commit beside them. + * The body it reads is the one the row shows, so a commit whose segments + * weren't sent still counts as readable, worded as it reads. + */ + public function isImageOnlyStar(): bool + { + return $this->starredImages() !== [] && trim($this->body ?? '') === ''; + } + + /** + * Whether the commit of yours this one quotes came along at all, as the + * web shows the box under a quote only when it did, which its signed API + * does not load today. + */ + public function showsQuotedCommit(): bool + { + return $this->type === 'quoted' && $this->quotedCommitSent; + } + + /** + * The commit of yours a quote quotes: a tombstone once it was deleted, or + * null once you may no longer see it, either of which the web still boxes, + * in the web's words. + */ + public function quotedOwnCommit(): ?Commit + { + return $this->showsQuotedCommit() ? $this->commit?->quotedCommit : null; + } + /** * Whether the group has more than one event, so it opens to list them, as on the web. */ @@ -135,11 +217,12 @@ public function isNewSince(?CarbonImmutable $readAt): bool } /** - * The commit a tap opens, as on the web: the notification's own commit. A follow opens a profile instead. + * The commit a tap opens, as on the web: the notification's own commit. A + * follow, or a level-up, opens a profile instead. */ public function commitToOpen(): ?string { - return $this->isFollow() ? null : $this->commitId; + return $this->isFollow() || $this->isLevelUp() ? null : $this->commitId; } /** @@ -174,15 +257,20 @@ public function others(): string /** * What happened, as the web words it. For a person it follows their name and their handle or the others. + * A star on an image-only commit says it was your image, or images. A + * level-up says nothing of which level, since the API tells it not. */ public function sentence(): string { return match ($this->type) { 'followed' => 'followed you', 'mentioned' => 'mentioned you', - 'starred' => 'starred your commit', + 'starred' => $this->isImageOnlyStar() + ? 'starred your '.(count($this->starredImages()) === 1 ? 'image' : 'images') + : 'starred your commit', 'quoted' => 'quoted your commit', 'approved', 'rejected' => "Your commit was {$this->type}", + 'leveled_up' => 'You reached a new level', default => 'replied to your commit', }; } diff --git a/app/Devnull/NotificationFilters.php b/app/Devnull/NotificationFilters.php new file mode 100644 index 0000000..87e6c0b --- /dev/null +++ b/app/Devnull/NotificationFilters.php @@ -0,0 +1,126 @@ + + */ + public function enabled(?string $userId): array + { + $kept = $userId === null ? null : Cache::get($this->key($userId)); + + if (is_array($kept) && array_is_list($kept) && self::areCategories($kept)) { + return self::normalize($kept); + } + + $filter = app(ShownTabs::class)->get($userId, 'notifications', ['all', ...self::Categories], 'all'); + + return $filter === 'all' ? self::Categories : [$filter]; + } + + /** + * The ?type= the selection asks for: all, none, or the categories the web + * sends them as, comma-separated in its order. + */ + public function type(?string $userId): string + { + return self::typeOf($this->enabled($userId)); + } + + /** + * Keeps the selection on for the account, dropping anything that isn't one + * of the web's categories, so nothing else is ever kept or sent. + * + * @param list $categories + */ + public function remember(?string $userId, array $categories): void + { + if ($userId !== null) { + Cache::forever($this->key($userId), self::normalize($categories)); + } + } + + /** + * The ?type= a selection asks for, as the web sends it: all six is all, + * none is none, and anything between is the categories comma-separated in + * the web's order. + * + * @param list $categories + */ + public static function typeOf(array $categories): string + { + $enabled = self::normalize($categories); + + return match (count($enabled)) { + 0 => 'none', + count(self::Categories) => 'all', + default => implode(',', $enabled), + }; + } + + /** + * A selection as the web's order runs it, without repeats and without + * anything that isn't one of its categories. The ?type= a selection was + * sent as reads back as the selection it asks for: all is every one of + * them, none is not one. + * + * @param array|string $categories the categories, or the comma-separated ?type= they were sent as + * @return list + */ + public static function normalize(array|string $categories): array + { + $sent = match ($categories) { + 'all' => self::Categories, + 'none' => [], + default => is_string($categories) ? explode(',', $categories) : $categories, + }; + + return array_values(array_intersect(self::Categories, array_unique($sent))); + } + + /** + * Whether every one of these is one of the web's categories: a string + * each, and nothing else, so nothing else kept in the cache is ever taken + * for a selection. + * + * @param list $categories + */ + private static function areCategories(array $categories): bool + { + foreach ($categories as $category) { + if (! is_string($category) || ! in_array($category, self::Categories, true)) { + return false; + } + } + + return true; + } + + private function key(string $userId): string + { + return "devnull.notification-filters.{$userId}"; + } +} diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index e209092..f475052 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -48,6 +48,7 @@ enum Lucide case UserPlus; case UserCheck; case AtSign; + case ChevronsUp; case FoldVertical; case Award; case Ellipsis; @@ -93,6 +94,7 @@ public static function forNotification(string $type): self { return match ($type) { 'followed' => self::UserPlus, + 'leveled_up' => self::ChevronsUp, 'quoted' => self::Quote, 'starred' => self::Star, 'mentioned' => self::AtSign, @@ -146,6 +148,7 @@ public function custom(bool $filled = false): ?string self::UserPlus => 'user-plus', self::UserCheck => 'user-check', self::AtSign => 'at-sign', + self::ChevronsUp => 'chevrons-up', self::FoldVertical => 'fold-vertical', self::Award => 'award', self::Ellipsis => 'ellipsis', diff --git a/app/NativeComponents/Notifications.php b/app/NativeComponents/Notifications.php index 1957711..f45b67e 100644 --- a/app/NativeComponents/Notifications.php +++ b/app/NativeComponents/Notifications.php @@ -8,34 +8,46 @@ use App\Devnull\Exceptions\ApiException; use App\Devnull\Exceptions\KeychainUnavailable; use App\Devnull\Exceptions\Unauthenticated; -use App\Devnull\ShownTabs; +use App\Devnull\NotificationFilters; +use App\Devnull\ResponseCache; use App\NativeComponents\Concerns\DrawsSkeletonFirst; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\TalksToDevnull; use Carbon\CarbonImmutable; use Illuminate\View\View; +use Native\Mobile\Attributes\Poll; use Native\Mobile\Edge\NativeComponent; /** - * Follows, stars, replies, quotes, mentions, and moderation updates for your - * commits, grouped as the web groups them, on a Rail Gray rail. + * Follows, stars, replies, quotes, mentions, level-ups and moderation updates + * for your commits, grouped as the web groups them, on a Rail Gray rail. * - * The web's filters run along the top: All, Replies, Quotes, Stars, Mentions, - * Follows and Moderation, each kept on its own for next time. A group of - * more than one opens in place to list them all, as the web expands it. + * The web's filters run along the top, several on at once: Replies, Quotes and + * Stars as chips you tap, and the rest under a More button, as the web's phone + * layout runs them. What's on is remembered per account (see + * NotificationFilters), sent as one ?type= — all six ask for everything, none + * asks for nothing, and anything between goes comma-separated in the web's + * order. A new selection starts a fresh list at the top, since a list kept + * while every row changed under its scroll offset drew blank on iOS; the one + * list keeps its key while it refreshes, pages and polls. * - * It opens on the notifications as they were last time, all read by then, - * then asks devnull behind them. devnull marks everything read as it lists - * any of it, and says when, so the groups read since then are the new ones: - * their node is ringed in blue, above the last-visit marker. New ones go in - * at the top quietly, since a group's id moves on as people join it. + * It opens on the notifications as they were last time, all read by then, then + * asks devnull behind them. devnull marks everything read as it lists any of + * it, and says when, so the groups read since then are the new ones: their + * node is ringed in blue, above the last-visit marker. * - * The filters are pages you swipe between, as on the Log (see Log), and the - * pager draws the filters along the top and moves them with your finger. The - * filters either side of the one you're on show the first few groups kept - * for them, ready for a swipe. Each has its own list, and a filter you leave - * starts again at the top of a fresh one. + * While it's showing, devnull is asked every 30 seconds for everything since + * the oldest group still showing (through=), as the web asks. Groups that + * came in, and groups whose newest event moved on since they were drawn, + * wait in the "N new notifications" pill rather than going in under you; a + * tap puts them on top and goes back to the top. A group's id stays put as + * people join it — only its newest event's id moves — so groups are told + * apart and counted by it, its fresh copy taking the old one's place rather + * than sitting beside it, and a group opened in place follows the new id. + * + * A group of more than one opens in place to list them all, as the web + * expands it. */ class Notifications extends NativeComponent { @@ -50,10 +62,12 @@ class Notifications extends NativeComponent protected bool $forceFullFrames = true; /** - * The web's filters, their labels and what each says when it has nothing. + * The web's categories, their labels and what each says alone, when + * nothing else is on and it has nothing. + * + * @var array */ - public const array Types = [ - 'all' => ['All', 'Nothing here yet. Follows, stars, replies, quotes, mentions, and moderation updates for your commits will show up here.'], + public const array Categories = [ 'replies' => ['Replies', 'No replies yet. When someone replies to one of your commits, it shows up here.'], 'quotes' => ['Quotes', 'No quotes yet. When someone quotes one of your commits, it shows up here.'], 'stars' => ['Stars', 'No stars yet. When someone stars one of your commits, it shows up here.'], @@ -63,23 +77,33 @@ class Notifications extends NativeComponent ]; /** - * How many kept groups a filter beside the one you're on shows, enough to fill the screen while you swipe to it. + * The categories the web's phone layout shows as chips, before its More. */ - public const int PreviewGroups = 6; + public const array QuickFilters = ['replies', 'quotes', 'stars']; - public string $type = 'all'; + public const string AllEmpty = 'Nothing here yet. Follows, stars, replies, quotes, mentions, level-ups, and moderation updates for your commits will show up here.'; + + public const string CombinedEmpty = 'No notifications in the selected categories yet.'; + + public const string NoneEmpty = 'All categories are turned off. Enable a category to see notifications.'; /** - * Mounts a fresh list for the filter you're on when it changes, at the top. + * The categories on, in the web's order. All six when nothing was kept. + * + * @var list */ - public int $listKey = 0; + public array $enabled = []; /** - * The list key of each filter you're not on. - * - * @var array + * The ?type= the selection on asks for, so a filter's answers can be told + * from another selection's arriving late. + */ + public string $type = 'all'; + + /** + * Mounts a fresh list when the selection changes, at the top. */ - public array $listKeys = []; + public int $listKey = 0; /** @var list */ public array $notifications = []; @@ -115,15 +139,48 @@ class Notifications extends NativeComponent */ public array $failedGroups = []; + /** + * The groups a poll brought in since the oldest one showing, fetched and + * waiting for the pill, newest first. Each poll replaces them with its own + * answer, so the same groups are never counted twice. + * + * @var list + */ + public array $staged = []; + + /** + * The More sheet, holding the filters the web's phone layout keeps in its menu. + */ + public bool $moreOpen = false; + + /** + * A poll still out. Another is never sent beside it. + */ + protected bool $polling = false; + + /** + * Which ask the poll under way was made under. Selections and resumes + * move it on, so a poll from before one of those lands too late to + * apply, or to free the place of a poll since sent — its own type being + * current again (a switch there and back) would not make it current. + */ + protected int $pollToken = 0; + public function mount(): void { - $this->type = app(ShownTabs::class)->get($this->me()?->id, 'notifications', array_keys(self::Types), 'all'); + $this->enabled = app(NotificationFilters::class)->enabled($this->me()?->id); + $this->type = NotificationFilters::typeOf($this->enabled); $this->fillInAfterFirstFrame('revalidate'); } public function onResume(): void { $this->forgetRefreshes(); + // Whatever was under way was dropped with the answers it was owed, so + // its callback is stale: this moves the poll on, so neither applies + // nor frees the one that goes out next. + $this->pollToken++; + $this->polling = false; $this->revalidate(); } @@ -133,56 +190,85 @@ public function navTitle(): string } /** - * Shows a filter, from a link such as an empty filter's Show all, and the - * pager follows: its own list, from what was kept for it, then asked again. - * The one you're on goes back to its top. + * Shows a selection of the categories, from a link such as an empty + * filter's Show all: all, one, none, or several as the comma-separated + * ?type= they were sent as. The one already showing goes back to its top. + * + * Something that names no selection the web's filters could have made — + * a stale link, a mistyped ?type= — changes nothing, rather than quietly + * selecting none of them and remembering that for the account. */ public function show(string $type): void { - if (! array_key_exists($type, self::Types)) { + if (! self::isSelection($type)) { return; } - if ($type === $this->type) { + $enabled = NotificationFilters::normalize($type); + + if ($enabled === $this->enabled) { $this->listKey++; return; } - $this->switchTo($type); + $this->applySelection($enabled); } /** - * The pager settling on a filter, after a swipe or a tap along the top. The one it's already on changes nothing. + * Whether $type names a selection the web's filters could have made: all, + * none, or a comma list of nothing but its categories. */ - public function showPage(int $index): void + private static function isSelection(string $type): bool { - $type = array_keys(self::Types)[$index] ?? null; - - if ($type !== null && $type !== $this->type) { - $this->switchTo($type); + if ($type === 'all' || $type === 'none') { + return true; } + + return array_diff(explode(',', $type), array_keys(self::Categories)) === []; } /** - * A tap on the filter you're on, which the pager sends instead of moving: back to the top of it. + * A filter chip, or a line in the More sheet: turns a category on or off + * with the rest, as the web's checkboxes do. */ - public function backToTop(int $index): void + public function toggle(string $category): void + { + if (! array_key_exists($category, self::Categories)) { + return; + } + + $on = in_array($category, $this->enabled, true); + + $this->applySelection($on + ? array_values(array_diff($this->enabled, [$category])) + : [...$this->enabled, $category]); + } + + public function openMoreFilters(): void { - $this->show(array_keys(self::Types)[$index] ?? ''); + $this->moreOpen = true; + } + + public function moreFiltersClosed(): void + { + $this->moreOpen = false; } /** - * The filter you go to keeps the list its first groups were drawn in, which - * can't have been scrolled, and fills it. The one you leave starts again at - * the top of a fresh one. + * The selection goes on for the account, and the list starts over at the + * top of a fresh one: what was kept for it, then asked again. A poll's + * answer for the selection before is no longer wanted, and what it had + * staged for it goes. + * + * @param list $categories */ - private function switchTo(string $type): void + private function applySelection(array $categories): void { - app(ShownTabs::class)->remember($this->me()?->id, 'notifications', $type); - $this->listKeys[$this->type] = $this->listKey + 1; - $this->listKey = $this->listKeys[$type] ?? 0; - $this->type = $type; + $this->enabled = NotificationFilters::normalize($categories); + $this->type = NotificationFilters::typeOf($this->enabled); + app(NotificationFilters::class)->remember($this->me()?->id, $this->enabled); + $this->notifications = []; $this->nextCursor = null; $this->loaded = false; @@ -191,6 +277,12 @@ private function switchTo(string $type): void $this->expanded = []; $this->expandedCursors = []; $this->failedGroups = []; + $this->staged = []; + // A poll under way for the selection before is stale from here on: it + // neither applies nor frees the place of the one that goes out next. + $this->pollToken++; + $this->polling = false; + $this->listKey++; $this->showKept(); $this->revalidate(); } @@ -232,16 +324,119 @@ public function loadMore(): void $page = $this->attempt(fn ($devnull) => $devnull->notifications($this->type, $this->nextCursor)); if ($page !== null) { - $this->notifications = [...$this->notifications, ...$page->items]; - $this->nextCursor = $page->nextCursor; - $this->refreshUnreadNotifications(); + $this->appendPage($page); } } + /** + * The web's poll for new notifications, every 30 seconds while the screen + * is showing. devnull is asked for everything since the oldest group still + * showing (through=), which it answers on top of a page, so what's new + * arrives fetched. Nothing showing yet asks for the first page quietly. + * + * It is always sent when the timer comes due, never held back by a recent + * ask of the same thing, but never beside itself: one at a time. + */ + #[Poll(30000)] + public function checkForNewNotifications(): void + { + if ($this->polling) { + return; + } + + $through = $this->notifications[count($this->notifications) - 1]->createdAt ?? null; + $type = $this->type; + $token = ++$this->pollToken; + $current = fn (): bool => $token === $this->pollToken; + + if ($through === null || ! $this->loaded) { + $this->polling = true; + + $this->refresh('notifications', Client::notificationsQuery($type), function (array $json) use ($type, $current): void { + if ($current()) { + $this->polling = false; + $this->takeFresh($type, $json); + } + }, quiet: true, again: true, failed: function () use ($current): void { + if ($current()) { + $this->polling = false; + } + }); + + return; + } + + $query = Client::notificationsQuery($type, through: $through->toIso8601ZuluString()); + + $this->polling = true; + + $this->refresh('notifications', $query, function (array $json) use ($type, $query, $current): void { + // A poll from before a selection change or a resume lands too + // late to change anything, not even the pill, or to free the + // place of a poll since sent. + if ($current()) { + $this->polling = false; + + // An answer for a selection you've left changes nothing, not even the pill. + if ($type === $this->type) { + $this->takePolled($json); + } + } + + // A poll's answer is about that moment, keyed by the through= it + // was asked with: every poll would keep another one. The list it + // staged is in hand, so nothing of it is kept. + app(ResponseCache::class)->forget('notifications', $query); + }, quiet: true, again: true, failed: function () use ($current): void { + if ($current()) { + $this->polling = false; + } + }); + } + + /** + * The pill: "3 new notifications". What it counts is groups, not the + * events or the people in them. + */ + public function newNotificationsLabel(): string + { + $count = count($this->staged); + + return $count.' '.($count === 1 ? 'new notification' : 'new notifications'); + } + + /** + * The pill: puts what a poll staged on top, and goes back to the top. + * A group already showing takes its pending copy's place — its newest + * event moved on, say — rather than a second row beside it, and what's + * loaded further down stays, with its cursor. What the group's row had + * learnt of you following its person, a copy that didn't come with the + * answer keeps; a group opened in place follows its leader's new id. + */ + public function showNewNotifications(): void + { + if ($this->staged === []) { + return; + } + + $before = $this->groupLeaders($this->notifications); + $followed = $this->followedByPerson(); + $staged = $this->groupIds($this->staged); + + $this->notifications = [...$this->carryFollowState($this->staged, $followed), ...array_values(array_filter( + $this->notifications, + fn (Notification $group): bool => ! isset($staged[$group->groupId]), + ))]; + $this->staged = []; + $this->listKey++; + $this->migrateExpanded($before); + } + /** * A tap on a notification opens its commit, or the profile of whoever - * followed you. A tap on a group of followers lists them in place instead, - * as its toggle does, rather than opening only the latest of them. + * followed you or of the level it says you reached. A tap on a group of + * followers lists them in place instead, as its toggle does, rather than + * opening only the latest of them. */ public function open(string $id): void { @@ -264,12 +459,21 @@ public function open(string $id): void : '/users/'.$notification->actor->username); } + /** + * The quoted commit of yours under a quote, which its card opens, as the + * web's does: the commit quoted, not the one quoting it. + */ + public function openCommit(string $id): void + { + $this->openInTab('/commits/'.$id); + } + /** * Names and avatars open their profile independently of the notification's commit. */ public function openActor(string $username): void { - foreach ([...$this->notifications, ...array_merge([], ...array_values($this->expanded))] as $notification) { + foreach ([...$this->notifications, ...$this->staged, ...array_merge([], ...array_values($this->expanded))] as $notification) { if ($notification->isModeration()) { continue; } @@ -374,7 +578,11 @@ private function setFollowed(string $username, bool $following): void ? $notification->withActorFollowed($following) : $notification; + // What a poll staged goes with the row: a newer copy of the same + // person waiting in the pill must not undo the follow back, or its + // rollback, when the pill is tapped. $this->notifications = array_map($update, $this->notifications); + $this->staged = array_map($update, $this->staged); $this->expanded = array_map(fn (array $items): array => array_map($update, $items), $this->expanded); } @@ -383,7 +591,7 @@ private function refreshGroup(string $id): void $type = $this->type; $this->refresh($this->groupPath($id), Client::notificationsQuery($type), function (array $json) use ($id, $type): void { - // Folded away, or another filter, while it was on its way. + // Folded away, or another selection, while it was on its way. if ($type === $this->type && array_key_exists($id, $this->expanded)) { $this->takeGroup($id, $json); } @@ -397,8 +605,55 @@ private function takeGroup(string $id, array $json): void { $page = Page::fromArray($json, Notification::fromArray(...)); - $this->expanded[$id] = $page->items; - $this->setGroupCursor($id, $page->nextCursor); + // A group asked for again keeps what was loaded under its page: the + // fresh first page covers the members it covers — whatever was + // deleted among them gone — and the older ones stay past it, with + // the cursor that pages on from them (see mergedGroup). + [$items, $cursor] = $this->mergedGroup($this->expanded[$id] ?? [], $page, $this->expandedCursors[$id] ?? null); + + $this->expanded[$id] = $items; + $this->setGroupCursor($id, $cursor); + } + + /** + * A group's members as a refresh left them, told apart by event: the + * fresh first page replaces the members it covers, newest first, and + * the members loaded under the page stay past the last one it also has, + * never twice, with the cursor they were paged on from when any remain. + * A group with nothing loaded under its page — opening one kept, say — + * is simply the page. A page that shares nothing with what was loaded + * says too much came in to line the two up, and stands for the group. + * + * @param list $loaded + * @param Page $page + * @return array{0: list, 1: ?string} + */ + private function mergedGroup(array $loaded, Page $page, ?string $retainedCursor): array + { + if ($loaded === []) { + return [$page->items, $page->nextCursor]; + } + + $fresh = array_fill_keys(array_map(fn (Notification $item): string => $item->id, $page->items), true); + + $deepest = -1; + + foreach ($loaded as $index => $item) { + if (isset($fresh[$item->id])) { + $deepest = $index; + } + } + + $tail = $deepest < 0 ? [] : array_values(array_filter( + array_slice($loaded, $deepest + 1), + fn (Notification $item): bool => ! isset($fresh[$item->id]), + )); + + if ($tail === []) { + return [$page->items, $page->nextCursor]; + } + + return [[...$page->items, ...$tail], $retainedCursor]; } private function setGroupCursor(string $id, ?string $cursor): void @@ -428,6 +683,110 @@ private function find(string $id): ?Notification return null; } + /** + * @param list $groups + * @return array + */ + private function groupIds(array $groups): array + { + return array_fill_keys(array_map(fn (Notification $group): string => $group->groupId, $groups), true); + } + + /** + * Each group's newest event, which is the id a group is told apart by its + * fresh copy arriving under: same group, newest moved on, is an update to + * one showing rather than a new one beside it. + * + * @param list $groups + * @return array + */ + private function groupLeaders(array $groups): array + { + $leaders = []; + + foreach ($groups as $group) { + $leaders[$group->groupId] = $group->id; + } + + return $leaders; + } + + /** + * What the rows showing know of you following each group's person, so a + * copy arriving without the word doesn't take it away again. + * + * @return array + */ + private function followedByPerson(): array + { + $followed = []; + + foreach ($this->notifications as $group) { + if ($group->isFollow()) { + $followed[$group->actor->username] = $group->actorIsFollowed; + } + } + + return $followed; + } + + /** + * @param list $groups + * @param array $followed by the person who followed you + * @return list + */ + private function carryFollowState(array $groups, array $followed): array + { + return array_map(function (Notification $group) use ($followed): Notification { + // Only the same person's word carries: a group's newest person + // moves on as others join it, and their copy says nothing of you + // following the one before them. + if ($group->isFollow() + && $group->actorIsFollowed === null + && array_key_exists($group->actor->username, $followed) + && $followed[$group->actor->username] !== null) { + return $group->withActorFollowed($followed[$group->actor->username]); + } + + return $group; + }, $groups); + } + + /** + * A group opened in place is keyed by its newest event, which moves on as + * people join it. When a fresh or pending copy takes a group's place, what + * was opened under the old one moves with it — its members, its older + * cursor, a failed open — so it stays open, and is asked for again under + * the id devnull knows the group by now. + * + * @param array $before each group's newest id before the change + */ + private function migrateExpanded(array $before): void + { + foreach ($this->groupLeaders($this->notifications) as $groupId => $id) { + $old = $before[$groupId] ?? null; + + if ($old === null || $old === $id || ! array_key_exists($old, $this->expanded)) { + continue; + } + + $this->expanded[$id] = $this->expanded[$old]; + unset($this->expanded[$old]); + + if (array_key_exists($old, $this->expandedCursors)) { + $this->expandedCursors[$id] = $this->expandedCursors[$old]; + unset($this->expandedCursors[$old]); + } + + $this->failedGroups = array_values(array_map( + fn (string $failed): string => $failed === $old ? $id : $failed, + $this->failedGroups, + )); + + $this->refreshGroup($id); + } + } + private function showKept(): void { if (($json = $this->cached('notifications', Client::notificationsQuery($this->type))) === null) { @@ -442,112 +801,145 @@ private function showKept(): void } /** - * The fresh first page replaces what's showing. Groups loaded further down - * stay, past the last one the fresh page also has. + * A page asked for under the list goes in under it. A group is only ever + * in once, however its newest event moved on, so paging under a pill that + * put a fresh copy of one on top never duplicates it. + */ + private function appendPage(Page $page): void + { + $shown = $this->groupIds($this->notifications); + + foreach ($page->items as $group) { + if (isset($shown[$group->groupId])) { + continue; + } + + $this->notifications[] = $group; + $shown[$group->groupId] = true; + } + + $this->nextCursor = $page->nextCursor; + $this->refreshUnreadNotifications(); + } + + /** + * The answer to a poll: the groups in it that aren't showing yet, and the + * ones whose newest event moved on since they were last drawn, wait for + * the pill, as the latest word on what came in. A group back exactly as + * it shows — the oldest one, through= being inclusive of it — is nothing + * waiting, and neither is anything else unchanged. What arrived replaces + * what an earlier poll staged, so nothing is counted twice, and a poll + * that brought nothing in leaves the pill away. + * + * @param array $json + */ + private function takePolled(array $json): void + { + $page = Page::fromArray($json, Notification::fromArray(...)); + $showing = $this->groupLeaders($this->notifications); + + $this->staged = array_values(array_filter( + $page->items, + fn (Notification $group): bool => ! isset($showing[$group->groupId]) || $showing[$group->groupId] !== $group->id, + )); + + // Listing any of it marks it read, so the badge is asked for again. + $this->refreshUnreadNotifications(); + } + + /** + * The fresh first page replaces what's showing, told apart by group: a + * group's newest event moves on as people join it, and its fresh copy + * takes the old one's place rather than sitting above it. Groups loaded + * further down stay, past the last one the fresh page also has, with the + * cursor that pages them. Groups a poll staged that the page brought in + * are showing now, so they no longer wait for the pill. * * @param array $json */ private function takeFresh(string $type, array $json): void { - // An answer for a filter you've left. + // An answer for a selection you've left. if ($type !== $this->type) { return; } $page = Page::fromArray($json, Notification::fromArray(...)); - $fresh = array_flip(array_map(fn (Notification $group): string => $group->id, $page->items)); + $before = $this->groupLeaders($this->notifications); + $fresh = $this->groupIds($page->items); $deepest = -1; foreach ($this->notifications as $index => $group) { - if (isset($fresh[$group->id])) { + if (isset($fresh[$group->groupId])) { $deepest = $index; } } $later = $deepest < 0 ? [] : array_values(array_filter( array_slice($this->notifications, $deepest + 1), - fn (Notification $group): bool => ! isset($fresh[$group->id]), + fn (Notification $group): bool => ! isset($fresh[$group->groupId]), )); $this->notifications = [...$page->items, ...$later]; $this->nextCursor = $later === [] ? $page->nextCursor : $this->nextCursor; $this->readAt ??= is_string($json['read_at'] ?? null) ? $json['read_at'] : null; $this->loaded = true; + $this->migrateExpanded($before); + + if ($this->staged !== []) { + $shown = $this->groupIds($this->notifications); + $this->staged = array_values(array_filter( + $this->staged, + fn (Notification $group): bool => ! isset($shown[$group->groupId]), + )); + } + $this->refreshUnreadNotifications(); } public function render(): View { $readAt = $this->readAt === null ? null : CarbonImmutable::parse($this->readAt); - $types = array_keys(self::Types); - $index = (int) array_search($this->type, $types, true); - $from = max(0, $index - 1); - $to = min(count($types) - 1, $index + 1); - - $pages = []; - - foreach (array_slice($types, $from, $to - $from + 1) as $type) { - $pages[] = $type === $this->type - ? $this->page($type, $this->notifications, $this->nextCursor, $this->loaded, $this->error, $readAt, $this->listKey) - : $this->preview($type, $readAt); - } + $all = $this->enabled === array_keys(self::Categories); return view('native.notifications', [ - 'myColour' => $this->myColour(), - 'types' => self::Types, + 'categories' => self::Categories, + 'quickFilters' => self::QuickFilters, + 'moreFilters' => array_values(array_diff(array_keys(self::Categories), self::QuickFilters)), + 'enabled' => $this->enabled, + 'moreOpen' => $this->moreOpen, + 'newLabel' => $this->newNotificationsLabel(), + 'page' => [ + 'end' => match (true) { + $this->notifications === [] => null, + $this->nextCursor !== null => 'more', + default => 'oldest', + }, + 'listKey' => $this->listKey, + 'notifications' => $this->notifications, + 'loaded' => $this->loaded, + 'error' => $this->error, + 'lastVisitBefore' => self::firstSeen($this->notifications, $readAt), + 'expanded' => $this->expanded, + 'expandedCursors' => $this->expandedCursors, + 'failedGroups' => $this->failedGroups, + ], + 'stagedCount' => count($this->staged), + 'emptyCommand' => $all + ? '$ cat /dev/notifications' + : '$ cat /dev/notifications | grep -E \''.($this->enabled === [] ? '^$' : implode('|', $this->enabled)).'\'', + 'emptyCopy' => match (true) { + $this->enabled === [] => self::NoneEmpty, + $all => self::AllEmpty, + count($this->enabled) === 1 => self::Categories[$this->enabled[0]][1], + default => self::CombinedEmpty, + }, + 'allOn' => $all, 'readAt' => $readAt, - 'pages' => $pages, - 'pageIndex' => $index, - 'pagesFrom' => $from, - 'pagesTo' => $to, ]); } - /** - * A filter beside the one you're on: the first few groups kept for it, as it will open. - * - * @return array - */ - private function preview(string $type, ?CarbonImmutable $readAt): array - { - $json = $this->skeletonFrame ? null : $this->cached('notifications', Client::notificationsQuery($type)); - $kept = is_array($json['data'] ?? null) ? $json['data'] : []; - $page = $json === null ? null : Page::fromArray(['data' => array_slice($kept, 0, self::PreviewGroups)] + $json, Notification::fromArray(...)); - - // Groups past the preview are left out, with nothing under them yet. - return $this->page($type, $page->items ?? [], $page?->nextCursor, $page !== null, null, $readAt, $this->listKeys[$type] ?? 0, cut: count($kept) > self::PreviewGroups); - } - - /** - * What a filter's page shows. - * - * @param list $notifications - * @return array - */ - private function page(string $type, array $notifications, ?string $nextCursor, bool $loaded, ?string $error, ?CarbonImmutable $readAt, int $listKey, bool $cut = false): array - { - $showing = $type === $this->type; - - return [ - 'end' => match (true) { - $cut || $notifications === [] => null, - $nextCursor !== null => 'more', - default => 'oldest', - }, - 'type' => $type, - 'showing' => $showing, - 'listKey' => $listKey, - 'notifications' => $notifications, - 'loaded' => $loaded, - 'error' => $error, - 'lastVisitBefore' => self::firstSeen($notifications, $readAt), - 'expanded' => $showing ? $this->expanded : [], - 'expandedCursors' => $showing ? $this->expandedCursors : [], - 'failedGroups' => $showing ? $this->failedGroups : [], - ]; - } - /** * The first group you'd seen before, under the new ones, for the last-visit marker. * diff --git a/resources/icons/chevrons-up.svg b/resources/icons/chevrons-up.svg new file mode 100644 index 0000000..3ab5122 --- /dev/null +++ b/resources/icons/chevrons-up.svg @@ -0,0 +1 @@ + diff --git a/resources/views/native/notifications.blade.php b/resources/views/native/notifications.blade.php index cf5f5af..7aaff4b 100644 --- a/resources/views/native/notifications.blade.php +++ b/resources/views/native/notifications.blade.php @@ -1,38 +1,51 @@ +{{-- + The inbox. Under the title, the web's filter row: the three its phone layout shows, and its More, which opens the + sheet with the rest (see notifications-filters-sheet). Several are on at once; each chip carries its selected + state natively, so a screen reader says which are on, as the web's aria-pressed does — a chip's press is the + server's to answer, its state handed back (see NativeUIChipRenderer). The More chip says whether any of its + filters are on the same way, with the ellipsis the web's menu button draws. The row wraps at larger type sizes + (flex-wrap, which the row carries on both phones), so no chip is ever clipped short. + + The list below is one list, whatever's on: the selection starts it afresh at the top, so a list kept while every + row changed under its scroll offset drew blank on iOS, and it keeps its key while it refreshes, pages and polls. + + A poll's arrivals wait in the pill pinned over the top of the list, under the filters, so it's in reach however + far down you've read. It floats over the rows rather than sitting in the list, so the list's height, rows and + scroll hold still while it comes and goes — only a tap on it, which puts what it holds on top and goes back to + the top, changes the list. Its place is always in the tree, hidden until there's something to show, so nothing + is added beside a scrolled list at runtime. Keyed so the banners above coming and going change none of it. +--}} @include('native.partials.error') @include('native.partials.refresh-failed') - {{-- - The filters as pages you swipe between, as on the Log: the one you're on and one either side, which show the - first few groups kept for them. The web's filters run along the top as the Log's page tabs, each as wide as its - label, in a row that scrolls sideways. The pager draws them, as on the Log: the underline follows your finger, - and the row keeps the filter you're on in view, as the web does. It says which filter it settled on once the - swipe has come to rest, and a tap on the filter you're on takes it back to its top. Keyed so the banners above - coming and going don't make it a new pager. - --}} - - @foreach ($pages as $page) - @include('native.partials.notifications-page', ['page' => $page]) - @endforeach - + + + @foreach ($quickFilters as $category) + @include('native.partials.notification-filter', ['category' => $category]) + @endforeach + @php($moreOn = array_intersect($moreFilters, $enabled) !== []) + + + + + + + @include('native.partials.notifications-page') + + + @if ($stagedCount > 0) + {{ $newLabel }} + @endif + + + + @include('native.partials.notifications-filters-sheet') diff --git a/resources/views/native/partials/avatar.blade.php b/resources/views/native/partials/avatar.blade.php index cbb31e4..8f335f3 100644 --- a/resources/views/native/partials/avatar.blade.php +++ b/resources/views/native/partials/avatar.blade.php @@ -1,8 +1,12 @@ -{{-- A person as a circle: their GitHub avatar on the wash, kept on the device for a day (see ImageCache), or their initial while it loads. --}} +{{-- + A person as a circle: their GitHub avatar on the wash, kept on the device for a day (see ImageCache), or their + initial while it loads. An $alt announces the image to a screen reader; without one it stays decorative. +--}} @php($size ??= 40) +@php($alt ??= '') {{ mb_strtoupper(mb_substr($user->name, 0, 1)) }} @if ($user->avatarUrl) - + @endif diff --git a/resources/views/native/partials/notification-filter.blade.php b/resources/views/native/partials/notification-filter.blade.php new file mode 100644 index 0000000..08ebfa0 --- /dev/null +++ b/resources/views/native/partials/notification-filter.blade.php @@ -0,0 +1,7 @@ +{{-- + One of the web's filter chips, as the native chip draws them: on in the theme's primary, off in its outline, + selected state and all, which a screen reader announces as selected or not — the web's aria-pressed. Its press + goes to the screen, which owns the selection and hands the chip its state back. +--}} +@php($on = in_array($category, $enabled, true)) + diff --git a/resources/views/native/partials/notification-group.blade.php b/resources/views/native/partials/notification-group.blade.php index d5bdbc8..85de09c 100644 --- a/resources/views/native/partials/notification-group.blade.php +++ b/resources/views/native/partials/notification-group.blade.php @@ -1,13 +1,16 @@ {{-- A group opened in place, as the web expands it: each of its notifications on a compact line behind a 1pt Rail Gray - guide line, with a 28pt avatar, the name, the icon and time, and the words someone wrote. A follower has no icon, - as on the web, only the time and their follow back. Each opens its own commit, or profile for a follow. Older ones come with "Show older stars"; one that wouldn't load says so in git's - voice, with Try again. + guide line, with a 28pt avatar, the name, the icon and time, and the words someone wrote. As on the web, a star + opens the person who gave it — the commit it was given to is the one the group is about — while a reply, a quote + or a mention opens the commit it names. A follower has no icon, as on the web, only the time and their follow + back. Older ones come with "Show older stars"; one that wouldn't load says so in git's voice, with Try again. --}} @php($noun = $group->groupNoun()) @foreach ($items as $item) - + @php($opensPerson = $item->isStar() || $item->isFollow()) + @php($press = $opensPerson ? "openActor('{$item->actor->username}')" : "open('{$item->id}')") + @include('native.partials.avatar', ['user' => $item->actor, 'size' => 28]) diff --git a/resources/views/native/partials/notification.blade.php b/resources/views/native/partials/notification.blade.php index d518e15..d9b987b 100644 --- a/resources/views/native/partials/notification.blade.php +++ b/resources/views/native/partials/notification.blade.php @@ -1,9 +1,21 @@ {{-- - A group of notifications on the inbox's Rail Gray rail, as the web draws it. Its node is a 36pt hollow circle - holding the type's icon in the person's lane (a moderator's stays muted), ringed in Commit Blue when it's new - ($isNew). A line of stacked avatars and the time, then the sentence: the person's name, their handle when they act - alone, otherwise "and N others", and what they did. Someone else's words follow at 15pt like a commit; your own - commit sits under a star or a verdict in a muted hairline box. A rejection gives the moderator's reason. + A group of notifications on the inbox's Rail Gray rail, as the web draws it, each kind in its own shape. + + A reply, a quote or a mention is someone's own words: their avatar rings the node in their lane (a link to them, + when they act alone), their name and handle head the row with the type's icon in their lane beside the time, what + they did runs under it as a link to the commit, and their commit follows. A quote may bring the commit of yours + it quotes along, boxed as the web's card — who wrote it, when, the words, opening the commit quoted — a tombstone + once it was deleted or can no longer be seen, and nothing at all when it didn't. + + A star, a follow or a level-up runs the other way: the type's icon in the node, the people stacked up top with the + time (a level chip instead, when devnull sent it; a follow of theirs alone offers the follow back), then who did + what — a star of a commit with images shows the first of them beside it whatever the commit says, and one that + says nothing but its images is worded so and shows no box, your own commit sits under a star or a verdict in a + muted hairline box, and a level-up says only that you reached one, since the API tells no more. A moderator's + verdict is muted, a rejection gives their reason. + + A row that arrived since your last visit says so to a screen reader: its type icon, or the avatar in its node, + carries "New" — the only place the row's own label would say it, which pressables don't yet announce. A group of more than one opens in place from a Commit Blue toggle ("5 stars"), listing each event behind a Rail Gray guide line, each follower with their own follow back. A group of followers has none of its own, as the web @@ -15,34 +27,87 @@ weight from font_weight over the face it was given, so the face alone draws regular. --}} @php($icon = \App\Icons\Lucide::forNotification($notification->type)) -@php($iconColour = $notification->isModeration() ? 'text-theme-ink-muted' : 'text-theme-'.\App\Devnull\Lane::token($notification->actor->lane())) +@php($laneToken = \App\Devnull\Lane::token($notification->actor->lane())) +@php($iconColour = $notification->isModeration() ? 'text-theme-ink-muted' : 'text-theme-'.$laneToken) @php($open = array_key_exists($notification->id, $expanded)) @php($failed = in_array($notification->id, $failedGroups, true)) @php($noun = $notification->groupNoun()) - +@php($actorsCommit = $notification->isActorsCommit()) +@php($level = $notification->isLevelUp() && $notification->level !== null && $notification->rank !== null ? $notification : null) +@php($images = $notification->starredImages()) + - - @if ($notification->isModeration()) + @if ($actorsCommit) + {{-- Their name and handle head the row, the type's icon in their lane beside the time, as the web draws it; the avatar rings the node below. --}} + + + + + + {{ \App\Devnull\Display::relativeTime($notification->createdAt) }} + + + {{-- What they did, as the web's link to the commit. --}} + {{ ucfirst($notification->sentence()) }} + + @if ($notification->body !== null) + {{ $notification->body }} + @endif + + {{-- The commit of yours this one quotes, boxed as the web's card: who wrote it and when it was, and the words, opening the commit quoted. One that was deleted, or can no longer be seen, is a tombstone that opens nothing. --}} + @if ($notification->showsQuotedCommit()) + @include('native.partials.quoted-commit', ['commit' => $notification->commit]) + @endif + @elseif ($notification->isModeration()) + {{ $notification->type }} - @else - @include('native.partials.stacked-avatars', ['id' => $notification->id, 'actors' => $notification->actors, 'more' => max(0, $notification->actorsCount - count($notification->actors))]) - @endif - - {{ \App\Devnull\Display::relativeTime($notification->createdAt) }} - @if ($notification->isFollow() && $notification->actorsCount === 1 && $notification->actorIsFollowed !== null) - @include('native.partials.follow-back', ['actor' => $notification->actor, 'isFollowing' => $notification->actorIsFollowed]) - @endif - + + {{ \App\Devnull\Display::relativeTime($notification->createdAt) }} + - @if ($notification->isModeration()) {{ $notification->sentence() }}. + + @if ($notification->body !== null) + + {{ $notification->body }} + + @endif + + @if ($notification->isRejection()) + + @if ($notification->reason) + reason: {{ $notification->reason }} + @endif + Only you can see this update and your rejected commit. + + @endif @else - + {{-- A star, a follow or a level-up: the people, then who did what. --}} + + @if ($level !== null) + + Lv. {{ $notification->level }} · {{ $notification->rank }} + + @else + @include('native.partials.stacked-avatars', ['id' => $notification->id, 'actors' => $notification->actors, 'more' => max(0, $notification->actorsCount - count($notification->actors))]) + @endif + + {{ \App\Devnull\Display::relativeTime($notification->createdAt) }} + @if ($notification->isFollow() && $notification->actorsCount === 1 && $notification->actorIsFollowed !== null) + @include('native.partials.follow-back', ['actor' => $notification->actor, 'isFollowing' => $notification->actorIsFollowed]) + @endif + + + + @if ($notification->isLevelUp()) + {{ $notification->sentence() }}. + @else + @if ($notification->others() === '') @@ -51,27 +116,24 @@ @endif - @endif + @endif + {{-- The first of the commit's images, as the web always shows it beside a star, whatever the commit says. --}} + @if ($images !== []) + + @endif + + + @if ($notification->isLevelUp() && $notification->rankDescription !== null) + {{ $notification->rankDescription }} + @endif - @if ($notification->body !== null && ! $notification->isFollow()) - @if ($notification->isAboutYourCommit()) + @if ($notification->isStar() && $notification->body !== null && ! $notification->isImageOnlyStar()) {{ $notification->body }} - @else - {{ $notification->body }} @endif @endif - @if ($notification->isRejection()) - - @if ($notification->reason) - reason: {{ $notification->reason }} - @endif - Only you can see this update and your rejected commit. - - @endif - @if ($notification->isGrouped()) @@ -94,9 +156,24 @@ @endif - - - + @if ($actorsCommit) + {{-- Their avatar in the node, in their lane's ring: a link to them when they act alone, saying "New" to a screen reader when it is. --}} + @if ($notification->isGrouped()) + + @include('native.partials.avatar', ['user' => $notification->actor, 'size' => 28, 'alt' => $isNew ? 'New' : null]) + + @else + + + @include('native.partials.avatar', ['user' => $notification->actor, 'size' => 28, 'alt' => $isNew ? 'New' : null]) + + + @endif + @else + + + + @endif diff --git a/resources/views/native/partials/notifications-filters-sheet.blade.php b/resources/views/native/partials/notifications-filters-sheet.blade.php new file mode 100644 index 0000000..9571219 --- /dev/null +++ b/resources/views/native/partials/notifications-filters-sheet.blade.php @@ -0,0 +1,15 @@ +{{-- + The More chip's sheet, standing in for the web phone layout's dropdown menu: the rest of its filter row, one chip + a line, each carrying its own selected state as its checkboxes do. It stays open across a tap, as the menu does, + and the list behind starts over for the selection each tap makes. Always mounted, so opening it changes nothing + but its own visibility (see colour-sheet). +--}} + + @if ($moreOpen) + + @foreach ($moreFilters as $category) + @include('native.partials.notification-filter', ['category' => $category]) + @endforeach + + @endif + diff --git a/resources/views/native/partials/notifications-page.blade.php b/resources/views/native/partials/notifications-page.blade.php index 5666940..33db7fb 100644 --- a/resources/views/native/partials/notifications-page.blade.php +++ b/resources/views/native/partials/notifications-page.blade.php @@ -1,69 +1,66 @@ {{-- - One filter's page in the Notifications pager (see App\NativeComponents\Notifications::page()): the filter you're on, - or the first few groups kept for one beside it, ready for a swipe. + The inbox's one list, whatever the selection on. Its key changes when the selection changes, which starts it again + at the top, since a list kept while every row changed under its scroll offset drew blank on iOS; it holds still + while the list refreshes, pages and polls, so its rows keep their identity. - Each filter has its own list, and one you leave starts again at the top of a fresh one: a list kept while every row - changed under its scroll offset drew blank on iOS. Only the filter you're on asks for its next page, since a page - laid out off screen still shows its rows, and every page keeps pull to refresh, since iOS builds a new list when a - list gains or loses it. + A poll's groups wait in the pill pinned over the list (see notifications.blade.php), never in it, so nothing of + the list's own height or rows changes while they wait. --}} - - - @if (! $page['loaded'] && $page['error'] === null) - - - - @endif - - @if ($page['loaded'] && $page['notifications'] === [] && $page['error'] === null) - - {{ $page['type'] === 'all' ? '$ cat /dev/notifications' : '$ cat /dev/notifications | grep '.$page['type'] }} - {{ $types[$page['type']][1] }} - @if ($page['type'] !== 'all') - - Show all notifications - - @endif - - @endif + + @if (! $page['loaded'] && $page['error'] === null) + + + + @endif - @foreach ($page['notifications'] as $index => $notification) - @php($previous = $page['notifications'][$index - 1] ?? null) - @if ($notification->id === $page['lastVisitBefore']) - @include('native.partials.last-visit-marker', ['lane' => \App\Devnull\Lane::Rail, 'label' => 'Notifications above are new since your last visit']) + @if ($page['loaded'] && $page['notifications'] === [] && $page['error'] === null) + + {{ $emptyCommand }} + {{ $emptyCopy }} + @if (! $allOn) + + Show all notifications + @endif - @if ($notification->createdAt && ($previous?->createdAt === null || \App\Devnull\Display::dayKey($previous->createdAt) !== \App\Devnull\Display::dayKey($notification->createdAt))) - @include('native.partials.day-marker', ['label' => \App\Devnull\Display::dayLabel($notification->createdAt), 'lane' => $previous === null ? null : \App\Devnull\Lane::Rail]) - @endif - @include('native.partials.notification', [ - 'notification' => $notification, - 'hasPrevious' => $previous !== null, - 'isNew' => $notification->isNewSince($readAt), - 'expanded' => $page['expanded'], - 'expandedCursors' => $page['expandedCursors'], - 'failedGroups' => $page['failedGroups'], - ]) - @endforeach + + @endif - @if ($page['end'] === 'more') - - - - @elseif ($page['end'] === 'oldest') - - - - That's the oldest notification. There's nothing before it. - - @include('native.partials.rail', ['previous' => \App\Devnull\Lane::Rail, 'lane' => \App\Devnull\Lane::Rail, 'node' => 'root']) - + @foreach ($page['notifications'] as $index => $notification) + @php($previous = $page['notifications'][$index - 1] ?? null) + @if ($notification->id === $page['lastVisitBefore']) + @include('native.partials.last-visit-marker', ['lane' => \App\Devnull\Lane::Rail, 'label' => 'Notifications above are new since your last visit']) + @endif + @if ($notification->createdAt && ($previous?->createdAt === null || \App\Devnull\Display::dayKey($previous->createdAt) !== \App\Devnull\Display::dayKey($notification->createdAt))) + @include('native.partials.day-marker', ['label' => \App\Devnull\Display::dayLabel($notification->createdAt), 'lane' => $previous === null ? null : \App\Devnull\Lane::Rail]) @endif - - + @include('native.partials.notification', [ + 'notification' => $notification, + 'hasPrevious' => $previous !== null, + 'isNew' => $notification->isNewSince($readAt), + 'expanded' => $page['expanded'], + 'expandedCursors' => $page['expandedCursors'], + 'failedGroups' => $page['failedGroups'], + ]) + @endforeach + + @if ($page['end'] === 'more') + + + + @elseif ($page['end'] === 'oldest') + + + + That's the oldest notification. There's nothing before it. + + @include('native.partials.rail', ['previous' => \App\Devnull\Lane::Rail, 'lane' => \App\Devnull\Lane::Rail, 'node' => 'root']) + + @endif + diff --git a/tests/Feature/Devnull/ClientTest.php b/tests/Feature/Devnull/ClientTest.php index e8c714a..9a230c0 100644 --- a/tests/Feature/Devnull/ClientTest.php +++ b/tests/Feature/Devnull/ClientTest.php @@ -13,6 +13,7 @@ use App\Devnull\Exceptions\Unauthenticated; use App\Devnull\ReportReason; use App\Devnull\RequestSigner; +use App\Devnull\ResponseCache; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; @@ -74,6 +75,43 @@ function receivedMessage(Request $request): string Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/notifications'); }); +it('asks for several notification filters as the web sends them, comma-separated, and none as none', function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([]))]); + + app(Client::class)->notifications('replies,stars'); + app(Client::class)->notifications('none'); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/notifications?type=replies%2Cstars' + && str_ends_with(receivedMessage($request), "\nGET\n/api/v1/notifications?type=replies%2Cstars\n")); + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/notifications?type=none' + && str_ends_with(receivedMessage($request), "\nGET\n/api/v1/notifications?type=none\n")); +}); + +it('asks for a poll\'s notifications since the oldest one showing, without changing the first page otherwise', function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([]))]); + + app(Client::class)->notifications('replies', null, '2026-10-15T15:00:00Z'); + app(Client::class)->notifications(); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/notifications?type=replies&through=2026-10-15T15%3A00%3A00Z' + && str_ends_with(receivedMessage($request), "\nGET\n/api/v1/notifications?type=replies&through=2026-10-15T15%3A00%3A00Z\n")); + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/notifications'); + + // Without a through, the query is the one it always was, so the first page kept stays the one that's found. + expect(Client::notificationsQuery('replies'))->toBe(['type' => 'replies', 'cursor' => null, 'through' => null]) + ->and(ResponseCache::key('u1', 'notifications', Client::notificationsQuery('replies'))) + ->toBe(ResponseCache::key('u1', 'notifications', ['type' => 'replies'])); +}); + +it('opens a group under several filters, comma-separated as the listing sends them', function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([]))]); + + app(Client::class)->notificationGroup('n-1', 'follows,moderation'); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/notifications/n-1/group?type=follows%2Cmoderation' + && str_ends_with(receivedMessage($request), "\nGET\n/api/v1/notifications/n-1/group?type=follows%2Cmoderation\n")); +}); + it('asks for a profile\'s replies with ?replies=1 and signs it', function () { Http::fake(['devnull.test/*' => Http::response(pageJson([]))]); diff --git a/tests/Feature/Devnull/NotificationFiltersTest.php b/tests/Feature/Devnull/NotificationFiltersTest.php new file mode 100644 index 0000000..d413c94 --- /dev/null +++ b/tests/Feature/Devnull/NotificationFiltersTest.php @@ -0,0 +1,89 @@ +filters = app(NotificationFilters::class); +}); + +it('sends the categories the web\'s way: all six as all, none as none, and anything between comma-separated in its order', function (array $categories, string $type) { + expect(NotificationFilters::typeOf($categories))->toBe($type); +})->with([ + 'all' => [['replies', 'quotes', 'stars', 'mentions', 'follows', 'moderation'], 'all'], + 'none' => [[], 'none'], + 'one' => [['mentions'], 'mentions'], + 'several' => [['mentions', 'replies'], 'replies,mentions'], +]); + +it('puts a selection in the web\'s order, without repeats and without anything that isn\'t one of its categories', function (array|string $sent, array $kept) { + expect(NotificationFilters::normalize($sent))->toBe($kept); +})->with([ + 'reordered' => [['moderation', 'replies', 'stars'], ['replies', 'stars', 'moderation']], + 'repeated' => [['stars', 'stars'], ['stars']], + 'the comma-separated form' => ['follows,stars,follows', ['stars', 'follows']], + 'all, the web\'s word for every one of them' => ['all', ['replies', 'quotes', 'stars', 'mentions', 'follows', 'moderation']], + 'none, the web\'s word for not one of them' => ['none', []], + 'unknown words dropped' => [['stars', 'nonsense', '0'], ['stars']], + 'nothing known left' => [['nonsense'], []], +]); + +it('keeps each person their own selection, with all of them on until they pick', function () { + $this->filters->remember('u1', ['stars', 'replies']); + $this->filters->remember('u2', ['mentions']); + $this->filters->remember(null, ['follows']); + + expect($this->filters->enabled('u1'))->toBe(['replies', 'stars']) + ->and($this->filters->type('u1'))->toBe('replies,stars') + ->and($this->filters->enabled('u2'))->toBe(['mentions']) + ->and($this->filters->type('u2'))->toBe('mentions') + ->and($this->filters->enabled('u3'))->toBe(NotificationFilters::Categories) + ->and($this->filters->type('u3'))->toBe('all') + ->and($this->filters->enabled(null))->toBe(NotificationFilters::Categories); +}); + +it('keeps nothing selected as none, and every category as all', function () { + $this->filters->remember('u1', []); + $this->filters->remember('u2', NotificationFilters::Categories); + + expect($this->filters->enabled('u1'))->toBe([]) + ->and($this->filters->type('u1'))->toBe('none') + ->and($this->filters->type('u2'))->toBe('all'); +}); + +it('opens on the one filter an app that kept a single one had on, and on all of them when it kept All', function (string $kept, array $enabled) { + app(ShownTabs::class)->remember('u1', 'notifications', $kept); + + expect($this->filters->enabled('u1'))->toBe($enabled); +})->with([ + 'a single category' => ['mentions', ['mentions']], + 'all' => ['all', NotificationFilters::Categories], +]); + +it('keeps nothing a new selection would not keep, and nothing foreign the cache holds', function (mixed $held, array $enabled) { + Cache::forever('devnull.notification-filters.u1', $held); + + expect($this->filters->enabled('u1'))->toBe($enabled); +})->with([ + 'a category that isn\'t one of them, beside one that is' => [['replies', 'nonsense'], NotificationFilters::Categories], + 'a plain string' => ['replies', NotificationFilters::Categories], + 'someone\'s shape of it' => [['mentions' => true], NotificationFilters::Categories], + 'a number' => [3, NotificationFilters::Categories], + 'a list kept inside the list' => [['stars', ['mentions']], NotificationFilters::Categories], + 'a number kept among them' => [['stars', 2], NotificationFilters::Categories], + 'nothing kept but numbers' => [[1, 2], NotificationFilters::Categories], + 'nothing kept but null' => [[null], NotificationFilters::Categories], + 'an object' => [(object) ['replies' => true], NotificationFilters::Categories], +]); + +it('has the single filter a new selection replaces, once one is kept', function () { + app(ShownTabs::class)->remember('u1', 'notifications', 'mentions'); + + expect($this->filters->enabled('u1'))->toBe(['mentions']); + + $this->filters->remember('u1', ['quotes', 'stars']); + + expect($this->filters->enabled('u1'))->toBe(['quotes', 'stars']) + ->and($this->filters->type('u1'))->toBe('quotes,stars'); +}); diff --git a/tests/Feature/Screens/LaneButtonsTest.php b/tests/Feature/Screens/LaneButtonsTest.php index f77fe0c..2d6eff7 100644 --- a/tests/Feature/Screens/LaneButtonsTest.php +++ b/tests/Feature/Screens/LaneButtonsTest.php @@ -115,6 +115,7 @@ function laneLog(): TestableComponent 'rose, checked in white' => 4, ]); +// The Notifications screen keeps the web's phone filter row rather than paged tabs, so its filters carry no lane underline (see NotificationsTest). it('draws the tab underline in your lane, in its light and dark shades', function (string $path, int $lane) { signIn(['lane' => $lane]); Http::fake(['devnull.test/*' => Http::response(pageJson([]))]); @@ -125,7 +126,7 @@ function laneLog(): TestableComponent ->and(strtolower($pager['props']['dark_tab_indicator_color']))->toBe(strtolower(config("native-ui.theme.dark.lane-{$lane}"))) // Only the underline: the labels stay ink. ->and($pager['props']['tab_active_color'])->toBe(config('native-ui.theme.light.ink')); -})->with(['/log', '/notifications'])->with([2, 5]); +})->with(['/log'])->with([2, 5]); it('draws the underline under your profile\'s tabs in your lane too', function () { signIn(['lane' => 5]); diff --git a/tests/Feature/Screens/NotificationsCacheTest.php b/tests/Feature/Screens/NotificationsCacheTest.php index fc7d5a8..8d50938 100644 --- a/tests/Feature/Screens/NotificationsCacheTest.php +++ b/tests/Feature/Screens/NotificationsCacheTest.php @@ -1,6 +1,7 @@ assertSee('followed you'); }); +it('takes a fresh copy of a group in place, however its newest event moved on, and keeps the pages under it', function () { + holdRefreshes(); + // Two groups kept, with a page under them. + keep('notifications', pageJson([ + notificationJson('starred', ['id' => 'a1', 'group_id' => 'g-a']), + notificationJson('followed', ['id' => 'b1', 'group_id' => 'g-b']), + ], 'older')); + serveApi(function (Request $request) { + if (str_contains($request->url(), 'cursor=older')) { + return Http::response(pageJson([notificationJson('mentioned', ['id' => 'c-mentioned', 'group_id' => 'g-older'])], 'oldest')); + } + + // The star group gained an event: its newest event is a2 now, its group still g-a. + return Http::response(pageJson([ + notificationJson('starred', ['id' => 'a2', 'group_id' => 'g-a', 'event_count' => 2]), + notificationJson('followed', ['id' => 'b1', 'group_id' => 'g-b', 'unread_count' => 0]), + ], 'older')); + }); + + $screen = Native::visit('/notifications')->call('loadMore'); + + expect(array_map(fn ($group): string => $group->id, $screen->instance()->notifications))->toBe(['a1', 'b1', 'c-mentioned']); + + finishRefreshes($screen); + + $groups = array_map(fn ($group): string => $group->groupId.'#'.$group->id, $screen->instance()->notifications); + + // The group's fresh copy takes its own place, not a second one above it, and the page under it stays. + expect($groups)->toBe(['g-a#a2', 'g-b#b1', 'g-older#c-mentioned']) + ->and($screen->instance()->nextCursor)->toBe('oldest'); +}); + it('keeps them and says it could not refresh when offline', function () { holdRefreshes(); keep('notifications', pageJson([notificationJson('followed')])); diff --git a/tests/Feature/Screens/NotificationsPollTest.php b/tests/Feature/Screens/NotificationsPollTest.php new file mode 100644 index 0000000..38ef200 --- /dev/null +++ b/tests/Feature/Screens/NotificationsPollTest.php @@ -0,0 +1,762 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * A page of notifications as devnull lists them, as the inbox tests build it. + * + * @param list> $groups + * @return array + */ +function pollInboxJson(array $groups, ?string $readAt = null, ?string $cursor = null): array +{ + return [ + ...pageJson($groups, $cursor, path: 'http://devnull.test/api/v1/notifications'), + 'filters' => ['type' => 'all'], + 'read_at' => $readAt ?? now()->toIso8601ZuluString(), + ]; +} + +/** + * @param array $overrides + * @return array + */ +function pollPersonJson(string $username, string $name): array +{ + return userJson(['id' => 'u-'.$username, 'username' => $username, 'name' => $name, 'avatar_url' => "https://avatars.test/{$username}"]); +} + +/** + * A group that came in on a poll, as devnull answers a through= ask: newer + * than everything showing, with its own group id. + * + * @param array $overrides + * @return array + */ +function arrivedJson(string $id, string $groupId, array $overrides = []): array +{ + return notificationJson('starred', ['id' => $id, 'group_id' => $groupId, 'created_at' => now()->subMinute()->toIso8601ZuluString(), ...$overrides]); +} + +/** + * @param array $overrides + * @return array + */ +function shownJson(string $groupId = 'g-shown', array $overrides = []): array +{ + return notificationJson('followed', ['id' => 'n-shown', 'group_id' => $groupId, 'created_at' => now()->subHours(3)->toIso8601ZuluString(), ...$overrides]); +} + +/** + * Whether a poll is still out, from inside the screen. + */ +function pollOut(TestableComponent $screen): bool +{ + return (new ReflectionProperty($screen->instance(), 'polling'))->getValue($screen->instance()); +} + +/** + * The pill, once a poll has staged groups: "2 new notifications". A native + * button, so its label rides as a prop. + */ +function pillLabel(TestableComponent $screen): ?string +{ + $pill = findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'new-notifications'); + + return $pill === null ? null : (string) ($pill['props']['label'] ?? ''); +} + +it('asks every 30 seconds for everything since the oldest group showing, as the web does, never twice at once', function () { + holdRefreshes(); + $oldest = now()->subHours(3)->toIso8601ZuluString(); + serveApi(fn () => Http::response(pollInboxJson([shownJson()]))); + + $screen = finishRefreshes(Native::visit('/notifications')); + $requests = refreshesSent($screen); + + // The timer comes due right after the same thing was asked for, and asks anyway. + $screen->firePoll('checkForNewNotifications'); + $pollSent = refreshesSent($screen); + + // A poll still out is never sent beside itself. + $screen->firePoll('checkForNewNotifications'); + + expect(refreshesSent($screen))->toBe($pollSent) + ->and($pollSent)->toBe($requests + 1); + + finishRefreshes($screen); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/notifications?through='.rawurlencode($oldest)); + + // The next tick, with the last one answered, goes out again. + $screen->firePoll('checkForNewNotifications'); + + expect(refreshesSent($screen))->toBeGreaterThan($pollSent); +}); + +it('asks for the selection on, not everything, when a filter is narrowed', function () { + holdRefreshes(); + serveApi(fn (Request $request) => Http::response(pollInboxJson(str_contains($request->url(), 'type=') ? [notificationJson('starred', ['group_id' => 'g-shown', 'id' => 'n-shown'])] : []))); + + $screen = Native::test(Notifications::class)->call('show', 'stars'); + finishRefreshes($screen)->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/notifications?type=stars&through='.rawurlencode(now()->subHours(3)->toIso8601ZuluString())); +}); +it('counts new and moved-on groups, not events or people, and keeps what came in waiting for the pill', function () { + holdRefreshes(); + serveApi(function (Request $request) { + return str_contains($request->url(), 'through=') + // Two groups came in; the one showing gained an event, which is an update to it, not a new one beside it. + ? Http::response(pollInboxJson([ + arrivedJson('n-new-2', 'g-new-2'), + arrivedJson('n-new-1', 'g-new-1'), + notificationJson('followed', ['id' => 'n-shown-again', 'group_id' => 'g-shown', 'event_count' => 3, 'unread_count' => 0]), + ])) + : Http::response(pollInboxJson([shownJson()])); + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + $rows = $screen->instance()->notifications; + $listKey = $screen->instance()->listKey; + + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + expect(pillLabel($screen))->toBe('3 new notifications') + // The rows showing never moved under the pill. + ->and($screen->instance()->notifications)->toBe($rows) + ->and($screen->instance()->listKey)->toBe($listKey) + ->and(count($screen->instance()->staged))->toBe(3); +}); + +it('puts what the pill holds on top, once, and goes back to the top', function () { + holdRefreshes(); + serveApi(function (Request $request) { + return str_contains($request->url(), 'through=') + ? Http::response(pollInboxJson([arrivedJson('n-new-1', 'g-new-1'), shownJson('g-shown', ['id' => 'n-shown-updated', 'event_count' => 2])])) + : Http::response(pollInboxJson([shownJson()])); + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen)->tap('new-notifications'); + + $groups = array_map(fn ($group): string => $group->groupId, $screen->instance()->notifications); + + // The group that gained an event takes its own place, not a second one beside it. + expect($groups)->toBe(['g-new-1', 'g-shown']) + ->and($screen->instance()->staged)->toBe([]) + ->and(pillLabel($screen))->toBeNull() + ->and($screen->instance()->listKey)->toBeGreaterThan(0); +}); + +it('keeps what a poll staged through a second poll and a pull to refresh', function () { + holdRefreshes(); + $pollAnswers = 0; + $firstPage = true; + + serveApi(function (Request $request) use (&$pollAnswers, &$firstPage) { + if (str_contains($request->url(), 'through=')) { + $pollAnswers++; + + return Http::response(pollInboxJson($pollAnswers === 1 + ? [arrivedJson('n-new-1', 'g-new-1')] + : [arrivedJson('n-new-1', 'g-new-1'), arrivedJson('n-new-2', 'g-new-2')])); + } + + // The first page holds only what was showing; a pull to refresh brings the newest of them into it. + $page = $firstPage ? [shownJson()] : [arrivedJson('n-new-1', 'g-new-1'), shownJson()]; + $firstPage = false; + + return Http::response(pollInboxJson($page)); + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + expect(pillLabel($screen))->toBe('1 new notification'); + + // The same groups again don't count twice; what else came in joins them. + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + expect(pillLabel($screen))->toBe('2 new notifications'); + + // A pull to refresh is at the top: what the fresh page brought in shows, and stops waiting. + $screen->call('load'); + finishRefreshes($screen); + + expect(pillLabel($screen))->toBe('1 new notification') + ->and(array_map(fn ($group): string => $group->groupId, $screen->instance()->notifications))->toBe(['g-new-1', 'g-shown']); + + $screen->tap('new-notifications'); + + expect(array_map(fn ($group): string => $group->groupId, $screen->instance()->notifications))->toBe(['g-new-2', 'g-new-1', 'g-shown']); +}); + +it('leaves the pill away when nothing came in', function () { + holdRefreshes(); + serveApi(function (Request $request) { + return Http::response(pollInboxJson([shownJson()])); + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + expect(pillLabel($screen))->toBeNull() + ->and($screen->instance()->staged)->toBe([]); +}); + +it('keeps the pages and groups loaded under the list through a poll and its pill', function () { + holdRefreshes(); + serveApi(function (Request $request) { + return match (true) { + str_contains($request->url(), 'cursor=older') => Http::response(pollInboxJson([notificationJson('mentioned', ['id' => 'n-older', 'group_id' => 'g-older', 'created_at' => now()->subDay()->toIso8601ZuluString()])], cursor: null)), + str_contains($request->url(), 'through=') => Http::response(pollInboxJson([arrivedJson('n-new-1', 'g-new-1')])), + default => Http::response(pollInboxJson([shownJson()], cursor: 'older')), + }; + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + $screen->call('loadMore'); + + expect($screen->instance()->nextCursor)->toBeNull() + ->and(count($screen->instance()->notifications))->toBe(2); + + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen)->tap('new-notifications'); + + $groups = array_map(fn ($group): string => $group->groupId, $screen->instance()->notifications); + + expect($groups)->toBe(['g-new-1', 'g-shown', 'g-older']) + // The page loaded under the list is still there, and none of them twice. + ->and(array_count_values($groups))->toBe(['g-new-1' => 1, 'g-shown' => 1, 'g-older' => 1]) + ->and($screen->instance()->nextCursor)->toBeNull(); +}); + +it('keeps a group opened in place through a poll and its pill', function () { + holdRefreshes(); + serveApi(function (Request $request) { + return match (true) { + str_contains($request->url(), '/group') => Http::response(pageJson([notificationJson('followed', ['id' => 'n-follow-2', 'actor' => pollPersonJson('grace', 'Grace')])], 'older')), + str_contains($request->url(), 'through=') => Http::response(pollInboxJson([arrivedJson('n-new-1', 'g-new-1')])), + default => Http::response(pollInboxJson([shownJson('g-shown', ['event_count' => 2, 'actors_count' => 2, 'actor' => pollPersonJson('grace', 'Grace'), 'actors' => [pollPersonJson('grace', 'Grace')]])])), + }; + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + $screen->tap('group-n-shown'); + + expect(array_keys($screen->instance()->expanded))->toBe(['n-shown']) + ->and($screen->instance()->expandedCursors)->toBe(['n-shown' => 'older']); + + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen)->tap('new-notifications')->assertSee('Hide followers'); + + expect(array_keys($screen->instance()->expanded))->toBe(['n-shown']) + ->and($screen->instance()->expandedCursors)->toBe(['n-shown' => 'older']); +}); + +it('drops what a poll staged when the selection changes, and its late answer changes nothing', function () { + holdRefreshes(); + serveApi(function (Request $request) { + return str_contains($request->url(), 'through=') + ? Http::response(pollInboxJson([arrivedJson('n-new-1', 'g-new-1')])) + : Http::response(pollInboxJson(str_contains($request->url(), 'type=stars') ? [notificationJson('starred', ['id' => 'n-star', 'group_id' => 'g-star'])] : [shownJson()])); + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + + // A poll for everything, still out when the selection narrows to stars. + $screen->firePoll('checkForNewNotifications'); + $screen->call('show', 'stars'); + + expect($screen->instance()->staged)->toBe([]) + ->and(pollOut($screen))->toBeFalse(); + + finishRefreshes($screen); + + // Its answer was for the selection before, so neither the rows nor the pill change. + expect(pillLabel($screen))->toBeNull() + ->and(array_map(fn ($group): string => $group->groupId, $screen->instance()->notifications))->toBe(['g-star']); +}); + +it('keeps what is showing, and says nothing, when a poll cannot get through', function () { + holdRefreshes(); + serveApi(function (Request $request) { + return str_contains($request->url(), 'through=') + ? Http::response(['message' => 'Server Error'], 500) + : Http::response(pollInboxJson([shownJson()], cursor: 'older')); + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + + $screen->firePoll('checkForNewNotifications'); + // A server error is asked once more before it gives up, as any refresh is. + finishRefreshes($screen); + + expect($screen->instance()->refreshFailed)->toBeFalse() + ->and($screen->instance()->error)->toBeNull() + ->and(pillLabel($screen))->toBeNull() + ->and(array_map(fn ($group): string => $group->groupId, $screen->instance()->notifications))->toBe(['g-shown']) + ->and($screen->instance()->nextCursor)->toBe('older') + ->and(pollOut($screen))->toBeFalse(); +}); + +it('keeps nothing of a poll in the cache, and the first page it would have shadowed kept as it was', function () { + holdRefreshes(); + $oldest = now()->subHours(3)->toIso8601ZuluString(); + + serveApi(function (Request $request) { + return str_contains($request->url(), 'through=') + ? Http::response(pollInboxJson([arrivedJson('n-new-1', 'g-new-1'), shownJson()])) + : Http::response(pollInboxJson([shownJson()])); + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + + expect(kept('notifications'))->not->toBeNull(); + + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + expect(kept('notifications', ['through' => $oldest]))->toBeNull() + ->and(kept('notifications'))->not->toBeNull(); +}); + +it('keeps the visit the poll is measured against, and asks for the badge again since a listing marks it read', function () { + holdRefreshes(); + $visit = now()->toIso8601ZuluString(); + $me = 0; + + serveApi(function (Request $request) use (&$me, $visit) { + return match (parse_url($request->url(), PHP_URL_PATH)) { + '/api/v1/me' => Http::response(['data' => userWithCountsJson(), 'unread_notifications_count' => $me++ === 0 ? 1 : 0]), + default => Http::response(str_contains($request->url(), 'through=') + ? pollInboxJson([arrivedJson('n-new-1', 'g-new-1'), shownJson()], readAt: now()->addSecond()->toIso8601ZuluString()) + : pollInboxJson([shownJson()], readAt: $visit)), + }; + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + + expect($screen->instance()->readAt)->toBe($visit); + + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + expect($screen->instance()->readAt)->toBe($visit) + ->and(Http::recorded(fn (Request $request): bool => parse_url($request->url(), PHP_URL_PATH) === '/api/v1/me'))->toHaveCount(2); +}); + +it('asks quietly for the first page when there is nothing showing yet', function () { + holdRefreshes(); + $firstPage = true; + + serveApi(function (Request $request) use (&$firstPage) { + if (str_contains($request->url(), 'through=')) { + return Http::response(pollInboxJson([arrivedJson('n-new-1', 'g-new-1')])); + } + + // The first ask finds nothing; the poll's quiet ask for the page finds one. + $page = $firstPage ? [] : [shownJson()]; + $firstPage = false; + + return Http::response(pollInboxJson($page)); + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + + expect($screen->instance()->notifications)->toBe([]); + + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + Http::assertNotSent(fn (Request $request): bool => str_contains($request->url(), 'through=')); + expect(pillLabel($screen))->toBeNull() + ->and(array_map(fn ($group): string => $group->groupId, $screen->instance()->notifications))->toBe(['g-shown']); +}); + +it('stages a group already showing whose newest event moved on, and puts its fresh copy on top once', function () { + holdRefreshes(); + serveApi(function (Request $request) { + return str_contains($request->url(), 'through=') + // The group showing gained two events: its newest is n-shown-3 now, its group still g-shown. + ? Http::response(pollInboxJson([notificationJson('followed', ['id' => 'n-shown-3', 'group_id' => 'g-shown', 'event_count' => 3])])) + : Http::response(pollInboxJson([shownJson()], cursor: 'older')); + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + $rows = $screen->instance()->notifications; + + // Regression: an update to a group showing used to be dropped on the floor forever. + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + expect(pillLabel($screen))->toBe('1 new notification') + ->and($screen->instance()->notifications)->toBe($rows); + + // The same answer again changes nothing: one pending update to the group, counted once. + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + expect(pillLabel($screen))->toBe('1 new notification'); + + $screen->tap('new-notifications'); + + $groups = array_map(fn ($group): string => $group->groupId.'#'.$group->id, $screen->instance()->notifications); + + expect($groups)->toBe(['g-shown#n-shown-3']) + ->and($screen->instance()->staged)->toBe([]) + // The page loaded under the list keeps its place and its cursor, with the group only once. + ->and($screen->instance()->nextCursor)->toBe('older'); +}); + +it('follows a group opened in place to its new newest event, keeping it open and asking under the id devnull knows now', function () { + holdRefreshes(); + serveApi(function (Request $request) { + $url = $request->url(); + + return match (true) { + str_contains($url, '/n2/group') => Http::response(pageJson([ + notificationJson('starred', ['id' => 'n2', 'group_id' => 'g-star']), + notificationJson('starred', ['id' => 'n1', 'group_id' => 'g-star', 'unread_count' => 0]), + notificationJson('starred', ['id' => 'n-old', 'group_id' => 'g-star', 'unread_count' => 0]), + ], 'older')), + str_contains($url, '/group') => Http::response(pageJson([ + notificationJson('starred', ['id' => 'n1', 'group_id' => 'g-star']), + notificationJson('starred', ['id' => 'n-old', 'group_id' => 'g-star']), + ], 'older')), + str_contains($url, 'through=') => Http::response(pollInboxJson([ + notificationJson('starred', ['id' => 'n2', 'group_id' => 'g-star', 'event_count' => 4]), + ])), + default => Http::response(pollInboxJson([notificationJson('starred', ['id' => 'n1', 'group_id' => 'g-star', 'event_count' => 3])])), + }; + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + + $screen->tap('group-n1'); + + expect(array_keys($screen->instance()->expanded))->toBe(['n1']) + ->and($screen->instance()->expandedCursors)->toBe(['n1' => 'older']); + + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen)->tap('new-notifications'); + + // The group stays open under its new newest event, with its older cursor, and is asked for again there. + expect(array_keys($screen->instance()->expanded))->toBe(['n2']) + ->and($screen->instance()->expandedCursors)->toBe(['n2' => 'older']) + ->and($screen->assertSee('Hide stars')); + + finishRefreshes($screen); + + expect(array_map(fn ($item): string => $item->id, $screen->instance()->expanded['n2']))->toBe(['n2', 'n1', 'n-old']) + ->and($screen->instance()->expandedCursors)->toBe(['n2' => 'older']); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/api/v1/notifications/n2/group')); + + // A member of it opens who gave the star, as ever. + $screen->tap('group-item-n-old')->assertNavigatedTo('/notifications/users/ada'); +}); + +it('follows a group opened in place to its new newest event through a pull to refresh too', function () { + holdRefreshes(); + $pulled = false; + + serveApi(function (Request $request) use (&$pulled) { + $url = $request->url(); + + return match (true) { + str_contains($url, '/n2/group') => Http::response(pageJson([ + notificationJson('starred', ['id' => 'n2', 'group_id' => 'g-star']), + notificationJson('starred', ['id' => 'n1', 'group_id' => 'g-star', 'unread_count' => 0]), + ])), + str_contains($url, '/group') => Http::response(pageJson([ + notificationJson('starred', ['id' => 'n1', 'group_id' => 'g-star']), + ])), + default => Http::response(pollInboxJson($pulled + ? [notificationJson('starred', ['id' => 'n2', 'group_id' => 'g-star', 'event_count' => 3])] + : [notificationJson('starred', ['id' => 'n1', 'group_id' => 'g-star', 'event_count' => 2])])), + }; + }); + + $screen = finishRefreshes(Native::visit('/notifications'))->tap('group-n1'); + + expect(array_keys($screen->instance()->expanded))->toBe(['n1']); + + $pulled = true; + $screen->call('load'); + finishRefreshes($screen); + + expect(array_keys($screen->instance()->expanded))->toBe(['n2']) + ->and($screen->assertSee('Hide stars')); + + finishRefreshes($screen); + + expect(array_map(fn ($item): string => $item->id, $screen->instance()->expanded['n2']))->toBe(['n2', 'n1']); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/api/v1/notifications/n2/group')); +}); + +it('ignores a poll from a selection left behind, even back on the selection it asked under, once a newer one is under way', function () { + holdRefreshes(); + serveApi(function (Request $request) { + $url = $request->url(); + + return match (true) { + str_contains($url, 'through=') && str_contains($url, 'type=stars') => Http::response(pollInboxJson([ + arrivedJson('n-star-new', 'g-star-new'), + ])), + str_contains($url, 'through=') => Http::response(pollInboxJson([ + arrivedJson('n-all-new', 'g-all-new'), + ])), + str_contains($url, 'type=stars') => Http::response(pollInboxJson([ + notificationJson('starred', ['id' => 'n-star', 'group_id' => 'g-star', 'created_at' => now()->subHours(2)->toIso8601ZuluString()]), + ])), + default => Http::response(pollInboxJson([shownJson()])), + }; + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + + // A poll for everything, still out when the selection narrows to stars. + $screen->firePoll('checkForNewNotifications'); + $screen->call('show', 'stars'); + + // Under stars, a poll of its own goes out and lands. + finishRefreshes($screen); + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + // The stale poll's answer is in by now too: it staged nothing, freed nothing, cleared nothing. + expect(array_map(fn ($group): string => $group->groupId, $screen->instance()->staged))->toBe(['g-star-new']) + ->and(pillLabel($screen))->toBe('1 new notification') + ->and(pollOut($screen))->toBeFalse(); + + // The guard the stale poll tried to clear is the newer one's, which already went: another goes out. + $sent = refreshesSent($screen); + $screen->firePoll('checkForNewNotifications'); + + expect(refreshesSent($screen))->toBeGreaterThan($sent); +}); + +it('drops a poll that was under way when the screen went under another, even once its answer lands', function () { + holdRefreshes(); + serveApi(function (Request $request) { + return Http::response(pollInboxJson(str_contains($request->url(), 'through=') + ? [arrivedJson('n-new-1', 'g-new-1'), shownJson()] + : [shownJson()])); + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + + $screen->firePoll('checkForNewNotifications'); + $screen->call('onResume'); + + finishRefreshes($screen); + + expect(pillLabel($screen))->toBeNull() + ->and(pollOut($screen))->toBeFalse() + ->and(array_map(fn ($group): string => $group->groupId, $screen->instance()->notifications))->toBe(['g-shown']); +}); + +it('pins the arrivals control over the list rather than in it, reachable however far down you have read', function (string $platform) { + holdRefreshes(); + serveApi(function (Request $request) { + return Http::response(pollInboxJson(str_contains($request->url(), 'through=') + ? [arrivedJson('n-new-1', 'g-new-1'), shownJson()] + : [shownJson()])); + }); + + $screen = finishRefreshes(Native::visit('/notifications', platform: $platform)); + $list = showingNotifications($screen)['id']; + + expect(findNode(nodeByRef($screen, 'notifications'), fn (array $node): bool => ($node['ref'] ?? null) === 'new-notifications'))->toBeNull(); + + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + // Over the list, under the filters — never a row of it — with the list's identity held still meanwhile. + expect(nodeByRef($screen, 'new-notifications'))->not->toBeNull() + ->and(nodeByRef($screen, 'new-notifications')['props']['a11y_label'] ?? null)->toBe('Show 1 new notification') + ->and(findNode(nodeByRef($screen, 'notifications'), fn (array $node): bool => ($node['ref'] ?? null) === 'new-notifications'))->toBeNull() + ->and(findNode(nodeByRef($screen, 'notifications'), fn (array $node): bool => ($node['ref'] ?? null) === 'new-notifications-slot'))->toBeNull() + ->and(showingNotifications($screen)['id'])->toBe($list); + + // A tap puts what it holds on top, at the top of a fresh list. + $screen->tap('new-notifications'); + + expect(showingNotifications($screen)['id'])->not->toBe($list); +})->with(['ios', 'android']); + +it('keeps a follow back made while a newer copy of the same person waited in the pill, and its rollback', function () { + holdRefreshes(); + $copies = 0; + $followed = false; + + serveApi(function (Request $request) use (&$copies, &$followed) { + $url = $request->url(); + + return match (true) { + str_contains($url, '/users/ada/follow') && $request->method() === 'POST' => tap(Http::response(null, 204), function () use (&$followed): void { + $followed = true; + }), + str_contains($url, '/users/ada/follow') => Http::response(['message' => 'Too Many Attempts.', 'code' => 'rate_limited'], 429), + // The poll's copy of the group: ada again, as the server last knew it. + str_contains($url, 'through=') => Http::response(pollInboxJson([ + notificationJson('followed', ['id' => 'n-shown-'.++$copies, 'group_id' => 'g-shown', 'event_count' => 2, 'actors_count' => 2, + 'actor' => userJson(['id' => 'u-ada', 'username' => 'ada', 'name' => 'Ada', 'avatar_url' => 'https://avatars.test/ada', 'is_followed' => $followed]), + 'actors' => [userJson(['username' => 'ada', 'is_followed' => $followed])]]), + ])), + default => Http::response(pollInboxJson([notificationJson('followed', ['id' => 'n-shown', 'group_id' => 'g-shown', + 'actor' => userJson(['id' => 'u-ada', 'username' => 'ada', 'name' => 'Ada', 'avatar_url' => 'https://avatars.test/ada', 'is_followed' => false])])])), + }; + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + + // A poll stages the group's newer copy first, as the server then knew it: not followed. + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + expect($screen->instance()->staged[0]->actorIsFollowed)->toBeFalse(); + + // You follow ada back: the row says so, devnull agrees, and the copy waiting in the pill goes with it. + $screen->tap('follow-back-ada'); + + expect($screen->instance()->notifications[0]->actorIsFollowed)->toBeTrue() + ->and($screen->instance()->staged[0]->actorIsFollowed)->toBeTrue(); + + // A later poll replaces what waits with its own answer, and its rollback: + // tapping to unfollow, devnull says no, and the copy waiting rolls back with the row. + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen); + + expect($screen->instance()->staged[0]->actorIsFollowed)->toBeTrue(); + + $screen->tap('follow-back-ada'); + + expect($screen->instance()->notifications[0]->actorIsFollowed)->toBeTrue() + ->and($screen->instance()->staged[0]->actorIsFollowed)->toBeTrue(); + + // Applied, the group's newer copy still says you follow ada. + $screen->tap('new-notifications'); + + expect($screen->instance()->notifications[0]->actorIsFollowed)->toBeTrue(); +}); + +it('carries a follow back onto a newer copy only of the same person, not whoever joined their group', function () { + holdRefreshes(); + serveApi(function (Request $request) { + return str_contains($request->url(), 'through=') + // The group's newer copy is led by grace, who sent no is_followed at all. + ? Http::response(pollInboxJson([ + notificationJson('followed', ['id' => 'n-shown-2', 'group_id' => 'g-shown', 'event_count' => 2, 'actors_count' => 2, + 'actor' => userJson(['id' => 'u-grace', 'username' => 'grace', 'name' => 'Grace', 'avatar_url' => 'https://avatars.test/grace']), + 'actors' => [userJson(['username' => 'grace']), userJson(['username' => 'linus', 'is_followed' => true])]]), + ])) + : Http::response(pollInboxJson([notificationJson('followed', ['id' => 'n-shown', 'group_id' => 'g-shown', + 'actor' => userJson(['id' => 'u-linus', 'username' => 'linus', 'name' => 'Linus', 'avatar_url' => 'https://avatars.test/linus', 'is_followed' => true])])])); + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + + expect($screen->instance()->notifications[0]->actor->username)->toBe('linus') + ->and($screen->instance()->notifications[0]->actorIsFollowed)->toBeTrue(); + + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen)->tap('new-notifications'); + + // Grace's copy says nothing of you following anyone: linus's word is not hers to inherit. + expect($screen->instance()->notifications[0]->actor->username)->toBe('grace') + ->and($screen->instance()->notifications[0]->actorIsFollowed)->toBeNull(); +}); + +it('keeps the older members a group had loaded when its newest event moves and it is asked for again', function (bool $viaPill) { + holdRefreshes(); + $pulled = false; + + // A group of 26 events: the first page holds 20 of them, the rest come by cursor, and its newest moves on. + $member = fn (int $n): array => notificationJson('starred', [ + 'id' => "m{$n}", 'group_id' => 'g-star', 'created_at' => now()->subHours(3)->subMinutes($n)->toIso8601ZuluString(), + 'actor' => userJson(['id' => "u-{$n}", 'username' => 'dev'.$n, 'name' => "Dev {$n}", 'avatar_url' => "https://avatars.test/{$n}"]), + ]); + + serveApi(function (Request $request) use (&$pulled, $member) { + $url = $request->url(); + + return match (true) { + // The first page under the group's new newest: it, and the members after it, with a cursor of its own. + str_contains($url, '/n0/group') => Http::response(pageJson( + [notificationJson('starred', ['id' => 'n0', 'group_id' => 'g-star', 'event_count' => 26]), ...array_map($member, range(1, 19))], + 'older-fresh' + )), + // The older members, under the cursor the first page left. + str_contains($url, 'cursor=older2') => Http::response(pageJson(array_map($member, range(20, 24)), 'oldest')), + // The first page under the group's newest as it opened. + str_contains($url, '/group') => Http::response(pageJson( + [notificationJson('starred', ['id' => 'n1', 'group_id' => 'g-star', 'event_count' => 25]), ...array_map($member, range(1, 19))], + 'older2' + )), + str_contains($url, 'through=') => Http::response(pollInboxJson([ + notificationJson('starred', ['id' => 'n0', 'group_id' => 'g-star', 'event_count' => 26]), + ])), + default => Http::response(pollInboxJson($pulled + ? [notificationJson('starred', ['id' => 'n0', 'group_id' => 'g-star', 'event_count' => 26])] + : [notificationJson('starred', ['id' => 'n1', 'group_id' => 'g-star', 'event_count' => 25])])), + }; + }); + + $screen = finishRefreshes(Native::visit('/notifications')); + + $screen->tap('group-n1'); + $screen->call('loadGroup', 'n1', true); + + // 20 on the first page, five more under its cursor, which says there are older still. + expect(count($screen->instance()->expanded['n1']))->toBe(25) + ->and($screen->instance()->expandedCursors)->toBe(['n1' => 'oldest']); + + if ($viaPill) { + $screen->firePoll('checkForNewNotifications'); + finishRefreshes($screen)->tap('new-notifications'); + } else { + $pulled = true; + $screen->call('load'); + } + + finishRefreshes($screen); + + $items = $screen->instance()->expanded['n0'] ?? []; + + // The fresh first page, and the older members it doesn't cover still under it — none twice. + expect(array_keys($screen->instance()->expanded))->toBe(['n0']) + ->and(count($items))->toBe(25) + ->and($items[0]->id)->toBe('n0') + ->and(end($items)->id)->toBe('m24') + ->and(array_count_values(array_map(fn ($item): string => $item->id, $items)))->toBe(array_fill_keys(array_map(fn ($item): string => $item->id, $items), 1)) + // The cursor the older members were paged on from stays; the fresh page's own is not theirs. + ->and($screen->instance()->expandedCursors)->toBe(['n0' => 'oldest']); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/api/v1/notifications/n0/group')); + + // An older member is still on the screen, and still opens who gave the star. + $screen->assertSee('Dev 24')->tap('group-item-m24')->assertNavigatedTo('/notifications/users/dev24'); +})->with([ + 'through the pill' => [true], + 'through a pull to refresh' => [false], +]); diff --git a/tests/Feature/Screens/NotificationsTest.php b/tests/Feature/Screens/NotificationsTest.php index 5848c3b..5fdd94d 100644 --- a/tests/Feature/Screens/NotificationsTest.php +++ b/tests/Feature/Screens/NotificationsTest.php @@ -2,6 +2,8 @@ use App\Devnull\Client; use App\Devnull\Display; +use App\Devnull\ImageCache; +use App\Devnull\NotificationFilters; use App\NativeComponents\Notifications; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; @@ -59,6 +61,14 @@ function ringColour(TestableComponent $screen, string $id): ?string return $node['style']['border_color'] ?? $node['style']['borderColor'] ?? null; } +/** + * The text a single node shows, as one string. + */ +function nodeText(array $node): string +{ + return (string) ($node['props']['text'] ?? ''); +} + it('opens notification actors independently of the commit the row opens', function (string $ref, string $username) { fakeApi(['GET /api/v1/notifications' => [inboxJson([notificationJson('starred', [ 'actor' => personJson('grace', 'Grace'), @@ -75,6 +85,19 @@ function ringColour(TestableComponent $screen, string $id): ?string 'other stacked actor' => ['actor-avatar-n-starred-ada', 'ada'], ]); +it('opens a reply through each of its targets: the person from their avatar and name, the commit from what they did', function () { + fakeApi(['GET /api/v1/notifications' => [inboxJson([notificationJson('replied', [ + 'actor' => personJson('grace', 'Grace', 5), + 'actors' => [personJson('grace', 'Grace', 5)], + ])])]]); + + Native::visit('/notifications')->tap('node-avatar-n-replied')->assertNavigatedTo('/notifications/users/grace'); + + Native::visit('/notifications')->tap('actor-name-n-replied')->assertNavigatedTo('/notifications/users/grace'); + + Native::visit('/notifications')->tap('action-n-replied')->assertNavigatedTo('/notifications/commits/c-replied'); +}); + it('opens an expanded notification actor from their own profile link', function () { fakeApi([ 'GET /api/v1/notifications' => [inboxJson([notificationJson('starred', ['event_count' => 2])])], @@ -117,7 +140,7 @@ function ringColour(TestableComponent $screen, string $id): ?string ->and($follow->isGrouped())->toBeFalse(); }); -it('words each kind like the web: the name, then the handle when they act alone, and what they did', function () { +it('lays a reply, a quote and a mention out as the web does: the name and handle heading the row, what they did under it, then their words', function () { fakeApi(['GET /api/v1/notifications' => [inboxJson([ notificationJson('replied', ['commit' => notificationCommitJson(['id' => 'r1', 'parent_id' => 'c-yours', 'body' => 'Nice work.'])]), notificationJson('quoted', ['id' => 'n-quoted', 'commit' => notificationCommitJson(['id' => 'q1', 'body' => 'Quoting this one.'])]), @@ -125,7 +148,7 @@ function ringColour(TestableComponent $screen, string $id): ?string notificationJson('followed', ['created_at' => now()->subDays(2)->toIso8601ZuluString(), 'actor' => personJson('linus', 'Linus'), 'actors' => [personJson('linus', 'Linus')]]), ])]]); - $screen = Native::visit('/notifications') + $screen = Native::visit('/notifications', platform: 'ios') ->assertNavTitle('Notifications') ->assertTabActive('Notifications') ->assertSee('Nice work.') @@ -133,10 +156,17 @@ function ringColour(TestableComponent $screen, string $id): ?string ->assertSee('Ask @local_dev about it') ->assertSee('2d'); - expect(paragraphRuns($screen, 'replied to your commit'))->toBe([['Ada', 'link'], [' '], ['@ada', 'mention'], [' replied to your commit']]) - ->and(paragraphRuns($screen, 'quoted your commit'))->toBe([['Ada', 'link'], [' '], ['@ada', 'mention'], [' quoted your commit']]) - ->and(paragraphRuns($screen, 'mentioned you'))->toBe([['Grace', 'link'], [' '], ['@grace', 'mention'], [' mentioned you']]) - ->and(paragraphRuns($screen, 'followed you'))->toBe([['Linus', 'link'], [' '], ['@linus', 'mention'], [' followed you']]) + // Their name heads the row with their handle, and what they did runs under it in the web's words, + // with the type's icon beside the time, in their lane, as the web draws it. + $iconName = ['replied' => 'message-square-reply', 'quoted' => 'quote', 'mentioned' => 'at-sign']; + + foreach (['replied' => 'Replied to your commit', 'quoted' => 'Quoted your commit', 'mentioned' => 'Mentioned you'] as $type => $action) { + expect(nodeText(nodeByRef($screen, 'actor-name-n-'.$type)))->toBe($type === 'mentioned' ? 'Grace' : 'Ada') + ->and(nodeText(nodeByRef($screen, 'action-n-'.$type)))->toBe($action) + ->and(findNode(nodeByRef($screen, 'notification-n-'.$type), fn (array $node): bool => ($node['type'] ?? null) === 'icon' && ($node['props']['name'] ?? null) === 'custom:'.$iconName[$type]))->not->toBeNull(); + } + + expect(paragraphRuns($screen, 'followed you'))->toBe([['Linus', 'link'], [' '], ['@linus', 'mention'], [' followed you']]) ->and(stackedAvatars($screen, 'n-followed'))->toBe(['https://avatars.test/linus']); }); @@ -163,6 +193,92 @@ function ringColour(TestableComponent $screen, string $id): ?string $screen->tap('notification-n-starred')->assertNavigatedTo('/notifications/commits/c-starred'); }); +it('words a star of an image-only commit as yours, and shows its image beside it, and nothing of the commit', function () { + $commit = [...notificationCommitJson(['id' => 'c-pictures', 'body' => ''], withParent: false), 'images' => [ + ['id' => 'img-1', 'url' => 'https://images.test/cat.jpg', 'width' => 640, 'height' => 480], + ['id' => 'img-2', 'url' => 'https://images.test/dog.jpg', 'width' => 640, 'height' => 640], + ]]; + + fakeApi(['GET /api/v1/notifications' => [inboxJson([notificationJson('starred', ['commit' => $commit])])]]); + + $screen = Native::visit('/notifications'); + + expect(nodeText(nodeByRef($screen, 'actor-name-n-starred')))->toBe('Ada') + ->and(paragraphRuns($screen, 'starred your images'))->toBe([['Ada', 'link'], [' '], ['@ada', 'mention'], [' starred your images']]) + // devnull's signed API loads no images today, so the thumbnail lights up only when it does. + ->and(nodeByRef($screen, 'starred-image-n-starred')['props']['src'])->toBe(ImageCache::src('https://images.test/cat.jpg')) + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'your-commit-n-starred'))->toBeNull(); +}); + +it('boxes the commit of yours a quote quotes when devnull sends it, a tombstone once deleted, and nothing when it does not', function (array $commit, string $ref) { + fakeApi(['GET /api/v1/notifications' => [inboxJson([notificationJson('quoted', [ + 'commit' => notificationCommitJson(['id' => 'q1', 'body' => 'Quoting this one.', 'quoted_commit' => $commit]), + ])])]]); + + $screen = Native::visit('/notifications')->assertSee('Quoting this one.'); + + expect(nodeByRef($screen, $ref))->not->toBeNull(); +})->with([ + 'live' => [commitJson(['id' => 'c-mine', 'body' => 'The commit of mine they quoted.']), 'quoted-q1'], + 'deleted' => [tombstoneJson(), 'quoted-q1'], +]); + +it('opens the quoted commit of yours from its card, not the one quoting it, and names who wrote it and when', function () { + fakeApi(['GET /api/v1/notifications' => [inboxJson([notificationJson('quoted', [ + 'commit' => notificationCommitJson(['id' => 'q1', 'body' => 'Quoting this one.', 'quoted_commit' => commitJson(['id' => 'c-mine', 'body' => 'The commit of mine they quoted.'])]), + ])])]]); + + $screen = Native::visit('/notifications')->assertSee('The commit of mine they quoted.'); + + $card = nodeByRef($screen, 'quoted-q1'); + + expect(shownText($screen))->toContain('Local Dev') + ->and(nodeByRef($screen, 'quoted-q1'))->not->toBeNull(); + + $screen->tap('quoted-q1')->assertNavigatedTo('/notifications/commits/c-mine'); +}); + +it('shows no box under a quote whose commit devnull did not load', function () { + // The signed API loads no quoted commits today: the key itself is absent, so nothing is boxed. + fakeApi(['GET /api/v1/notifications' => [inboxJson([notificationJson('quoted', [ + 'commit' => array_diff_key(notificationCommitJson(['id' => 'q1', 'body' => 'Quoting this one.']), ['quoted_commit' => '']), + ])])]]); + + $screen = Native::visit('/notifications')->assertSee('Quoting this one.'); + + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'quoted-q1'))->toBeNull(); +}); + +it('words a level-up as safely as the API allows: no level it did not send, no reply action, and your profile to open', function (array $overrides) { + fakeApi(['GET /api/v1/notifications' => [inboxJson([notificationJson('leveled_up', $overrides)])]]); + + $screen = Native::visit('/notifications')->assertSee('You reached a new level.'); + + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'level-chip-n-leveled_up'))->toBeNull() + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'level-description-n-leveled_up'))->toBeNull() + // Nothing but a follow or a level-up opens a profile, and no commit is named. + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'action-n-leveled_up'))->toBeNull(); + + $screen->tap('notification-n-leveled_up')->assertNavigatedTo('/notifications/users/ada'); +})->with([ + 'the API sends no level' => [[]], + 'an empty rank' => [['level' => 4, 'rank' => ' ']], +]); + +it('chips a level-up with its level and rank when devnull actually sends them', function () { + fakeApi(['GET /api/v1/notifications' => [inboxJson([notificationJson('leveled_up', [ + 'level' => 4, + 'rank' => 'Wizard', + 'rank_description' => 'Shipped five days in a row.', + ])])]]); + + $screen = Native::visit('/notifications', platform: 'ios')->assertSee('Lv. 4 · Wizard')->assertSee('Shipped five days in a row.'); + + expect(nodeByRef($screen, 'level-chip-n-leveled_up'))->not->toBeNull() + ->and(nodeByRef($screen, 'level-description-n-leveled_up'))->not->toBeNull() + ->and(findNode(nodeByRef($screen, 'node-n-leveled_up'), fn (array $node): bool => ($node['props']['name'] ?? null) === 'custom:chevrons-up'))->not->toBeNull(); +}); + it('words moderation updates like the web, with a verdict chip and the reason for a rejection', function () { fakeApi(['GET /api/v1/notifications' => [inboxJson([ notificationJson('approved'), @@ -219,6 +335,18 @@ function ringColour(TestableComponent $screen, string $id): ?string ))); expect($refs)->toBe(['day-Today', 'notification-n-mentioned', 'last-visit', 'day-'.Display::dayLabel(now()->subDays(3)), 'notification-n-followed']); + + // The new one says so to a screen reader, on the icon and the avatar in its node — the only place that + // reaches one, a pressable's own label staying unannounced — and the one read before says nothing. + $saysNew = fn (array $node): ?string => collect(findNodes($node, fn (array $child): bool => in_array($child['type'] ?? null, ['icon', 'image'], true) + && (string) ($child['props']['a11y_label'] ?? $child['props']['alt'] ?? '') !== ''))->map( + fn (array $child): string => (string) ($child['props']['a11y_label'] ?? $child['props']['alt']), + )->first(); + + expect($saysNew(nodeByRef($screen, 'notification-n-mentioned')))->toBe('New') + ->and($saysNew(nodeByRef($screen, 'node-n-mentioned')))->toBe('New') + ->and($saysNew(nodeByRef($screen, 'notification-n-followed')))->toBeNull() + ->and($saysNew(nodeByRef($screen, 'node-n-followed')))->toBeNull(); }); it('opens what the web opens for each kind', function (string $type, string $destination) { @@ -227,55 +355,57 @@ function ringColour(TestableComponent $screen, string $id): ?string Native::visit('/notifications')->tap('notification-n-'.$type)->assertNavigatedTo($destination); })->with([ ['replied', '/notifications/commits/c-replied'], + ['quoted', '/notifications/commits/c-quoted'], ['starred', '/notifications/commits/c-starred'], ['mentioned', '/notifications/commits/c-mentioned'], ['approved', '/notifications/commits/c-approved'], ['rejected', '/notifications/commits/c-rejected'], ['followed', '/notifications/users/ada'], + ['leveled_up', '/notifications/users/ada'], ]); -it("has the web's filters, each asked for by its type, with All asked for plainly", function () { +it("has the web's filters, several on at once, each asked for by its type with all asked for plainly", function () { serveApi(function (Request $request) { parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); $type = $query['type'] ?? 'all'; return str_starts_with((string) parse_url($request->url(), PHP_URL_PATH), '/api/v1/notifications') - ? Http::response(inboxJson($type === 'stars' ? [notificationJson('starred')] : [notificationJson('followed')], $type)) + ? Http::response(inboxJson($type === 'none' ? [] : (str_contains($type, 'follows') ? [notificationJson('followed')] : [notificationJson('starred')]), $type)) : Http::response(['data' => userWithCountsJson(), 'unread_notifications_count' => 0]); }); - $screen = Native::visit('/notifications')->assertSee('followed you'); - - // The pager draws the filters along the top, in a row that scrolls, on All. - expect(nodeByRef($screen, 'notifications-pager')['props'])->toMatchArray([ - 'tabs' => ['All', 'Replies', 'Quotes', 'Stars', 'Mentions', 'Follows', 'Moderation'], - 'tabs_scrollable' => true, - 'page' => 0, - ]); + $screen = Native::visit('/notifications')->assertSee('starred your commit'); - tapFilter($screen, 'stars') - ->assertSet('type', 'stars') - ->assertSee('starred your commit') - ->assertDontSee('followed you'); + // The three chips the web's phone layout shows, and its More button for the rest. + foreach ([...Notifications::QuickFilters, 'more'] as $ref) { + expect(nodeByRef($screen, 'filter-'.$ref))->not->toBeNull(); + } - $urls = Http::recorded(fn (Request $request): bool => str_contains($request->url(), '/notifications'))->map(fn (array $pair): string => $pair[0]->url())->values()->all(); + // Several on at once, tapped in any order, sent as one canonical comma-separated type. + tapFilter($screen, 'replies'); + tapFilter($screen, 'quotes'); + $screen->assertSet('type', 'stars,mentions,follows,moderation')->assertSee('followed you')->assertDontSee('starred your commit'); - expect($urls)->toContain('http://devnull.test/api/v1/notifications', 'http://devnull.test/api/v1/notifications?type=stars'); -}); + // The More sheet carries the rest, and stays open across a tap, as the web's menu does. + $screen->tap('filter-more'); + expect(nodeByRef($screen, 'filters-sheet')['props'])->toMatchArray(['visible' => true]); + tapFilter($screen, 'follows'); + $screen->assertSet('type', 'stars,mentions,moderation')->assertDontSee('followed you'); -it("says what each filter shows when it's empty, in the web's words, with a way back to all of them", function (string $type) { - fakeApi(['GET /api/v1/notifications' => [inboxJson([])]]); + // Every one off asks for none of them. + tapFilter($screen, 'stars')->tap('filter-mentions')->tap('filter-moderation')->assertSet('type', 'none'); - $screen = Native::test(Notifications::class)->call('show', $type) - ->assertSee($type === 'all' ? '$ cat /dev/notifications' : '$ cat /dev/notifications | grep '.$type) - ->assertSee(Notifications::Types[$type][1]); + $urls = Http::recorded(fn (Request $request): bool => str_contains($request->url(), '/notifications'))->map(fn (array $pair): string => $pair[0]->url())->values()->all(); - if ($type !== 'all') { - $screen->assertSee('Show all notifications')->tap('show-all')->assertSet('type', 'all'); - } -})->with(array_keys(Notifications::Types)); + expect($urls)->toContain( + 'http://devnull.test/api/v1/notifications', + 'http://devnull.test/api/v1/notifications?type=stars%2Cmentions%2Cfollows%2Cmoderation', + 'http://devnull.test/api/v1/notifications?type=stars%2Cmentions%2Cmoderation', + 'http://devnull.test/api/v1/notifications?type=none', + ); +}); -it('keeps each filter on its own, and opens one kept before at once', function () { +it('keeps the selection on for the account, and opens on it at once', function () { holdRefreshes(); keep('notifications', inboxJson([notificationJson('followed')]), ['type' => null]); keep('notifications', inboxJson([notificationJson('mentioned')], 'mentions'), ['type' => 'mentions']); @@ -284,7 +414,8 @@ function ringColour(TestableComponent $screen, string $id): ?string $screen = Native::visit('/notifications')->assertSee('followed you'); $requests = count(Http::recorded()); - tapFilter($screen, 'mentions') + // Mentions alone, as a link from another screen would ask for it. + $screen->call('show', 'mentions') ->assertSee('mentioned you') ->assertDontSee('followed you'); @@ -292,68 +423,71 @@ function ringColour(TestableComponent $screen, string $id): ?string ->and(count(Http::recorded()))->toBe($requests) ->and(kept('notifications', ['type' => 'mentions']))->not->toBeNull() ->and(kept('notifications', ['type' => 'stars']))->toBeNull(); -}); -it('swipes between the filters both ways under the filters the pager draws, and stops at the ends', function () { + // Opening again is on what was kept, with nothing asked yet. holdRefreshes(); - keep('notifications', inboxJson([notificationJson('followed')]), ['type' => null]); - keep('notifications', inboxJson([notificationJson('replied')], 'replies'), ['type' => 'replies']); - serveApi(fn () => Http::response(inboxJson([]))); - $screen = Native::visit('/notifications'); - $pager = fn (): array => nodeByRef($screen, 'notifications-pager'); + $screen = Native::visit('/notifications')->assertSee('mentioned you')->assertSet('enabled', ['mentions']); - // All is first: it and Replies are shipped, nothing before it. The pager says where it is once a swipe has settled. - expect($pager()['props'])->toMatchArray(['horizontal' => true, 'count' => 7, 'page' => 0, 'window_from' => 0, 'window_to' => 1, 'report_on_settle' => true]) - ->and($pager()['props'])->toHaveKey('on_tab_reselect') - ->and(treeShows(nodeByRef($screen, 'notifications-replies'), 'replied'))->toBeTrue(); - - $screen->changeTab('showPage', 1)->assertSet('type', 'replies'); - - expect($pager()['props'])->toMatchArray(['page' => 1, 'window_from' => 0, 'window_to' => 2]) - ->and(treeShows(showingNotifications($screen), 'replied'))->toBeTrue() - ->and(showsLoading(showingNotifications($screen)))->toBeFalse(); + expect(showsLoading(showingNotifications($screen)))->toBeFalse() + ->and(count(Http::recorded()))->toBe(0); +}); - $screen->changeTab('showPage', 0)->assertSet('type', 'all'); +it("says what each filter shows when it's empty, in the web's words, with a way back to all of them", function (string $type) { + fakeApi(['GET /api/v1/notifications' => [inboxJson([])]]); - expect($pager()['props']['page'])->toBe(0); + $screen = Native::test(Notifications::class)->call('show', $type) + ->assertSee($type === 'all' ? '$ cat /dev/notifications' : '$ cat /dev/notifications | grep -E \''.$type.'\'') + ->assertSee($type === 'all' ? Notifications::AllEmpty : Notifications::Categories[$type][1]); - // Nothing before All, and no wrapping round from Moderation to All. - $screen->changeTab('showPage', -1)->assertSet('type', 'all'); - $screen->changeTab('showPage', 6)->assertSet('type', 'moderation'); + if ($type !== 'all') { + $screen->assertSee('Show all notifications')->tap('show-all')->assertSet('type', 'all'); + } +})->with(['all', ...array_keys(Notifications::Categories)]); - expect($pager()['props'])->toMatchArray(['page' => 6, 'window_from' => 5, 'window_to' => 6]); +it("says what's on when several are and neither is everything, and when nothing is", function () { + fakeApi(['GET /api/v1/notifications' => [inboxJson([])]]); - $screen->changeTab('showPage', 7)->assertSet('type', 'moderation'); + Native::test(Notifications::class) + ->call('show', 'replies,quotes') + ->assertSee('$ cat /dev/notifications | grep -E \'replies|quotes\'') + ->assertSee(Notifications::CombinedEmpty) + ->assertSee('Show all notifications'); + + Native::test(Notifications::class) + ->call('show', 'none') + ->assertSee('$ cat /dev/notifications | grep -E \'^$\'') + ->assertSee(Notifications::NoneEmpty) + ->assertSee('Show all notifications') + ->tap('show-all') + ->assertSee('$ cat /dev/notifications') + ->assertSee(Notifications::AllEmpty) + ->assertDontSee('Show all notifications'); }); -it('gives each filter its own list, starts the one you leave again in a fresh one, and only lets the one you are on page', function () { +it('starts the list over when the selection changes, and holds it while the list refreshes and pages', function () { holdRefreshes(); - keep('notifications', inboxJson([notificationJson('followed')]), ['type' => null]); - keep('notifications', inboxJson(array_map(fn (int $n): array => notificationJson('replied', ['id' => "r{$n}"]), range(1, 10)), 'replies', cursor: 'next'), ['type' => 'replies']); - serveApi(fn () => Http::response(inboxJson([]))); + keep('notifications', inboxJson([notificationJson('followed')], cursor: 'next'), ['type' => null]); + keep('notifications', inboxJson([notificationJson('replied')], 'replies'), ['type' => 'replies']); + serveApi(function (Request $request) { + return str_contains($request->url(), 'cursor=next') + ? Http::response(inboxJson([notificationJson('starred')])) + : Http::response(inboxJson([notificationJson('followed')], cursor: 'next')); + }); $screen = Native::visit('/notifications'); $all = showingNotifications($screen)['id']; - $replies = nodeByRef($screen, 'notifications-replies'); - // Beside All, Replies shows the first few groups kept for it, with nothing under them yet. - expect(count(findNodes($replies, fn (array $node): bool => str_starts_with((string) ($node['ref'] ?? ''), 'notification-'))))->toBe(Notifications::PreviewGroups) - ->and(showsLoading($replies))->toBeFalse() - ->and($replies['props'])->toHaveKey('on_refresh') - ->and($replies['props'])->not->toHaveKey('on_end_reached') - ->and(showingNotifications($screen)['props'])->toHaveKey('on_end_reached'); + // A page under the list keeps its key, so its rows keep their identity. + finishRefreshes($screen); + $screen->call('loadMore'); + expect(showingNotifications($screen)['id'])->toBe($all) + ->and($screen->instance()->notifications)->toHaveCount(2); - $screen->changeTab('showPage', 1); - - // Swiped to, Replies keeps its list and fills it. All, left behind, starts again at the top of a fresh one. - expect(showingNotifications($screen)['id'])->toBe($replies['id']) - ->and(nodeByRef($screen, 'notifications-all')['id'])->not->toBe($all); - - // The filter you're on takes you back to its top. + // A new selection starts a fresh list at the top. tapFilter($screen, 'replies'); - - expect(showingNotifications($screen)['id'])->not->toBe($replies['id']); + expect(showingNotifications($screen)['id'])->not->toBe($all) + ->and($screen->instance()->listKey)->toBeGreaterThan(0); }); it('opens a group in place, as the web expands it, and folds it away again', function () { @@ -379,14 +513,15 @@ function ringColour(TestableComponent $screen, string $id): ?string expect(array_map(fn ($item): string => $item->id, $screen->instance()->expanded['n-starred']))->toBe(['n-star-2', 'n-star-1']) ->and(childRefs($screen, 'group-items-n-starred'))->toContain('group-item-n-star-2', 'group-item-n-star-1'); - $screen->tap('group-item-n-star-1')->assertNavigatedTo('/notifications/commits/c-starred'); + // A star in the list opens the person who gave it, as on the web; a reply would open the commit. + $screen->tap('group-item-n-star-1')->assertNavigatedTo('/notifications/users/ada'); Native::visit('/notifications')->tap('group-n-starred')->tap('group-n-starred')->assertSee('2 stars')->assertDontSee('Hide stars'); Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/notifications/n-starred/group'); }); -it('opens a group with the filter it was listed under, and pages its older ones', function () { +it('opens a group with the selection it was listed under, and pages its older ones', function () { serveApi(function (Request $request) { $url = $request->url(); @@ -427,7 +562,7 @@ function ringColour(TestableComponent $screen, string $id): ?string expect($screen->instance()->expanded['n-starred'][0]->id)->toBe('n-star-1'); }); -it('opens a group kept before at once, and keeps groups per filter', function () { +it('opens a group kept before at once, and keeps groups per selection', function () { holdRefreshes(); keep('notifications', inboxJson([notificationJson('starred', ['event_count' => 2])]), ['type' => null]); keep('notifications/n-starred/group', pageJson([notificationJson('starred', ['id' => 'n-star-kept', 'actor' => personJson('grace', 'Grace')])]), ['type' => null]); @@ -534,3 +669,83 @@ function ringColour(TestableComponent $screen, string $id): ?string // A second tap folds it away. $screen->tap('notification-n-followed')->assertDontSee('Grace Brewster Murray Hopper'); }); + +it('draws the filters and rows on Android as on iOS', function () { + fakeApi(['GET /api/v1/notifications' => [inboxJson([ + notificationJson('replied', ['actor' => personJson('grace', 'Grace', 5), 'actors' => [personJson('grace', 'Grace', 5)]]), + notificationJson('starred', ['event_count' => 2, 'actor' => personJson('ada', 'Ada'), 'actors' => [personJson('ada', 'Ada'), personJson('linus', 'Linus')]]), + ])]]); + + $screen = Native::visit('/notifications', platform: 'android'); + + expect(nodeByRef($screen, 'filter-replies'))->not->toBeNull() + ->and(nodeByRef($screen, 'action-n-replied'))->not->toBeNull() + ->and(nodeByRef($screen, 'node-n-replied'))->not->toBeNull() + ->and($screen->assertSee('Replied to your commit')->assertSee('2 stars')); +}); + +it('shows the first image beside any starred commit that has images, with its words still boxed as yours', function () { + $commit = [...notificationCommitJson(['id' => 'c-shipped', 'body' => 'Shipped the pictures.'], withParent: false), 'images' => [ + ['id' => 'img-1', 'url' => 'https://images.test/cat.jpg', 'width' => 640, 'height' => 480], + ]]; + + fakeApi(['GET /api/v1/notifications' => [inboxJson([notificationJson('starred', ['commit' => $commit])])]]); + + $screen = Native::visit('/notifications'); + + expect(paragraphRuns($screen, 'starred your commit'))->toBe([['Ada', 'link'], [' '], ['@ada', 'mention'], [' starred your commit']]) + ->and(nodeByRef($screen, 'starred-image-n-starred')['props']['src'])->toBe(ImageCache::src('https://images.test/cat.jpg')) + ->and(nodeByRef($screen, 'your-commit-n-starred'))->not->toBeNull(); +}); + +it('draws the filters as chips that carry their own selected state, which a screen reader says', function () { + fakeApi(['GET /api/v1/notifications' => [inboxJson([notificationJson('followed')])]]); + + $screen = Native::visit('/notifications', platform: 'ios'); + + expect(nodeByRef($screen, 'filter-replies')['type'])->toBe('chip') + ->and(nodeByRef($screen, 'filter-replies')['props'])->toMatchArray(['label' => 'Replies', 'value' => true, 'a11y_label' => 'Replies notifications']) + // Everything is on at first, so the More chip's filters are on with it. + ->and(nodeByRef($screen, 'filter-more')['props'])->toMatchArray(['a11y_label' => 'More notification filters', 'value' => true]); + + tapFilter($screen, 'replies'); + + expect(nodeByRef($screen, 'filter-replies')['props']['value'])->toBeFalse(); + + // The More sheet's lines are chips of their own, saying which are on. + $screen->tap('filter-more'); + + expect(nodeByRef($screen, 'filter-mentions')['props'])->toMatchArray(['label' => 'Mentions', 'value' => true, 'a11y_label' => 'Mentions notifications']); + + tapFilter($screen, 'mentions'); + + expect(nodeByRef($screen, 'filter-mentions')['props']['value'])->toBeFalse() + ->and(nodeByRef($screen, 'filter-more')['props']['value'])->toBeTrue(); +}); + +it('changes nothing when asked to show something that names no selection', function (string $bogus) { + holdRefreshes(); + keep('notifications', inboxJson([notificationJson('followed')]), ['type' => null]); + serveApi(fn () => Http::response(inboxJson([notificationJson('starred')]))); + + $screen = Native::visit('/notifications'); + $list = showingNotifications($screen)['id']; + $requests = count(Http::recorded()); + + $screen->call('show', $bogus); + + expect($screen->instance()->type)->toBe('all') + ->and($screen->instance()->enabled)->toBe(array_keys(Notifications::Categories)) + ->and(showingNotifications($screen)['id'])->toBe($list) + ->and($screen->instance()->listKey)->toBe(0) + ->and(count(Http::recorded()))->toBe($requests) + // Nothing was kept for the account, and nothing remembered as none of them. + ->and(kept('notifications', ['type' => 'none']))->toBeNull() + ->and(app(NotificationFilters::class)->type('0199e5c4-0000-7000-8000-000000000001'))->toBe('all'); +})->with([ + 'an unknown word' => ['bogus'], + 'nothing' => [''], + 'a list with an unknown word in it' => ['stars,bogus'], + 'a trailing comma' => ['stars,'], + 'all among the categories' => ['all,stars'], +]); diff --git a/tests/Feature/Screens/ShownTabsTest.php b/tests/Feature/Screens/ShownTabsTest.php index 87a164c..564b787 100644 --- a/tests/Feature/Screens/ShownTabsTest.php +++ b/tests/Feature/Screens/ShownTabsTest.php @@ -39,12 +39,20 @@ function shownTabsProfileJson(string $username): array Http::assertNotSent(fn (Request $request): bool => str_starts_with($request->url(), 'http://devnull.test/api/v1/log/following')); }); -it('opens Notifications on the filter you last had open', function () { +it('opens Notifications on the selection you last had on, carrying a single filter an older app kept across', function () { Http::fake(['devnull.test/*' => Http::response(['data' => [], 'next_cursor' => null, 'read_at' => null, 'filters' => ['type' => 'all']])]); - tapFilter(Native::visit('/notifications'), 'mentions')->assertSet('type', 'mentions'); + // An older app kept one filter at a time: All opens on all of them, one category on it alone. + app(ShownTabs::class)->remember('0199e5c4-0000-7000-8000-000000000001', 'notifications', 'mentions'); - Native::visit('/notifications')->assertSet('type', 'mentions'); + $screen = Native::visit('/notifications')->assertSet('type', 'mentions'); + + // The selection since is several at once, remembered for the account. + $screen->call('show', 'replies,stars'); + + tapFilter(Native::visit('/notifications'), 'stars')->assertSet('type', 'replies'); + + Native::visit('/notifications')->assertSet('enabled', ['replies']); }); it("opens your own profile on the tab you last had open, but someone else's on their commits", function () { diff --git a/tests/Pest.php b/tests/Pest.php index 9ea9bd6..02b7401 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -265,12 +265,17 @@ function tapProfileTab(TestableComponent $screen, string $tab): TestableComponen } /** - * A tap on one of the notifications filters along the top, which the pager - * draws, as for the Log's tabs (see tapLogTab). + * A tap on one of the notification filters: a chip along the top, or a line in + * the More sheet for one the web's phone layout keeps in its menu. The filter + * the screen is already showing toggles off and on, as the chips do. */ -function tapFilter(TestableComponent $screen, string $type): TestableComponent +function tapFilter(TestableComponent $screen, string $category): TestableComponent { - return $screen->changeTab($screen->get('type') === $type ? 'backToTop' : 'showPage', (int) array_search($type, array_keys(Notifications::Types), true)); + if (in_array($category, Notifications::QuickFilters, true)) { + return $screen->tap('filter-'.$category); + } + + return $screen->tap('filter-more')->tap('filter-'.$category); } /** diff --git a/tests/Unit/Devnull/NotificationTest.php b/tests/Unit/Devnull/NotificationTest.php new file mode 100644 index 0000000..79de831 --- /dev/null +++ b/tests/Unit/Devnull/NotificationTest.php @@ -0,0 +1,131 @@ +> $images + * @return array + */ +function starCommitJson(array $images, array $overrides = []): array +{ + return [ + 'id' => 'c-starred', + 'body' => '', + 'body_segments' => [], + 'images' => $images, + 'author' => userJson(), + ...$overrides, + ]; +} + +/** + * @return list> + */ +function starImages(int $count): array +{ + return array_map(fn (int $n): array => ['id' => "i{$n}", 'url' => "https://img.test/{$n}", 'width' => 1080, 'height' => 1080], range(1, $count)); +} + +it('says a star on an image-only commit was your image, or images', function (int $images, string $worded) { + $star = Notification::fromArray(notificationJson('starred', ['commit' => starCommitJson(starImages($images))])); + + expect($star->isStar())->toBeTrue() + ->and($star->isImageOnlyStar())->toBeTrue() + ->and($star->starredImages())->toHaveCount($images) + ->and($star->starredImages()[0])->toBeInstanceOf(CommitImage::class) + ->and($star->sentence())->toBe("starred your {$worded}"); +})->with([ + [1, 'image'], + [2, 'images'], +]); + +it('still words a star with anything to read as your commit, with the images beside it', function () { + $withBody = Notification::fromArray(notificationJson('starred', ['commit' => starCommitJson(starImages(1), ['body' => 'Shipped the app.', 'body_segments' => [['type' => 'text', 'text' => 'Shipped the app.']]])])); + + expect($withBody->isImageOnlyStar())->toBeFalse() + ->and($withBody->starredImages())->toHaveCount(1) + ->and($withBody->sentence())->toBe('starred your commit') + // Without the images devnull's signed API never sends, a star is worded as it always was. + ->and(Notification::fromArray(notificationJson('starred'))->isImageOnlyStar())->toBeFalse() + ->and(Notification::fromArray(notificationJson('starred'))->starredImages())->toBe([]) + ->and(Notification::fromArray(notificationJson('starred'))->sentence())->toBe('starred your commit'); +}); + +it('words a star with a readable body as your commit even when the body\'s segments were not sent', function () { + $star = Notification::fromArray(notificationJson('starred', ['commit' => array_diff_key(starCommitJson(starImages(2), ['body' => 'Readable words.']), ['body_segments' => true])])); + + expect($star->commit?->body)->toBe('Readable words.') + ->and($star->body)->toBe('Readable words.') + ->and($star->isImageOnlyStar())->toBeFalse() + ->and($star->sentence())->toBe('starred your commit'); +}); + +it('shows a quote\'s commit of yours when devnull sent it, deleted or no longer visible alike', function (array $quoted, bool $deleted, ?string $body) { + $quote = Notification::fromArray(notificationJson('quoted', ['commit' => notificationCommitJson(['id' => 'q1', 'body' => 'Quoting this one.']) + ['quoted_commit_id' => 'c-yours', 'quoted_commit' => $quoted]])); + + expect($quote->isActorsCommit())->toBeTrue() + ->and($quote->showsQuotedCommit())->toBeTrue() + ->and($quote->quotedOwnCommit()?->isDeleted())->toBe($deleted) + ->and($quote->quotedOwnCommit()?->plainText())->toBe($body); +})->with([ + 'yours' => [['id' => 'c-yours', 'body' => 'Shipped it.', 'body_segments' => [['type' => 'text', 'text' => 'Shipped it.']], 'author' => userJson()], false, 'Shipped it.'], + 'deleted' => [['id' => 'c-yours', 'deleted_at' => '2026-10-19T15:00:00Z', 'replies_count' => 1], true, ''], +]); + +it('shows no quote box at all when the quoted commit was not sent, as the signed API does not send it', function () { + $quote = Notification::fromArray(notificationJson('quoted', ['commit' => notificationCommitJson(['id' => 'q1', 'body' => 'Quoting this one.', 'quoted_commit_id' => 'c-yours'])])); + $gone = Notification::fromArray(notificationJson('quoted', ['commit' => notificationCommitJson(['id' => 'q1', 'body' => 'Quoting this one.', 'quoted_commit_id' => 'c-yours', 'quoted_commit' => null])])); + + expect($quote->showsQuotedCommit())->toBeFalse() + ->and($quote->quotedOwnCommit())->toBeNull() + // Sent, but no longer yours to see: still a box, the web's unavailable one. + ->and($gone->showsQuotedCommit())->toBeTrue() + ->and($gone->quotedOwnCommit())->toBeNull(); +}); + +it('words a level-up plainly and opens your profile, telling of no level the API leaves out', function () { + $level = Notification::fromArray(notificationJson('leveled_up', ['commit' => null])); + + expect($level->isLevelUp())->toBeTrue() + ->and($level->sentence())->toBe('You reached a new level') + ->and($level->commitToOpen())->toBeNull() + ->and($level->opensCommit())->toBeFalse() + ->and($level->level)->toBeNull() + ->and($level->rank)->toBeNull() + ->and($level->rankDescription)->toBeNull() + ->and($level->isActorsCommit())->toBeFalse() + ->and($level->isAboutYourCommit())->toBeFalse(); +}); + +it('keeps the level and rank only when devnull actually sent them, as the web\'s page does', function () { + $level = Notification::fromArray(notificationJson('leveled_up', ['commit' => null, 'level' => 3, 'rank' => 'Committer', 'rank_description' => 'You shipped it.'])); + + expect($level->level)->toBe(3) + ->and($level->rank)->toBe('Committer') + ->and($level->rankDescription)->toBe('You shipped it.') + // Still no rank read into the sentence, since the API doesn't guarantee one. + ->and($level->sentence())->toBe('You reached a new level'); +}); + +it('carries the whole commit along, while a reply keeps reading as it always did', function () { + $reply = Notification::fromArray(notificationJson('replied')); + + expect($reply->commit)->toBeInstanceOf(Commit::class) + ->and($reply->commit?->id)->toBe('c-replied') + ->and($reply->isActorsCommit())->toBeTrue() + ->and($reply->isReply())->toBeTrue() + ->and($reply->body)->toBe('About replied') + ->and($reply->parentBody)->toBe('Shipped the app.') + // A follow, which has no commit, carries none. + ->and(Notification::fromArray(notificationJson('followed'))->commit)->toBeNull(); + + $followed = Notification::fromArray(notificationJson('starred', ['actors_count' => 3])); + + expect($followed->withActorFollowed(true)->actorIsFollowed)->toBeTrue() + ->and($followed->withActorFollowed(true)->commit?->id)->toBe('c-starred') + ->and($followed->withActorFollowed(true)->starredImages())->toBe([]); +}); From 4e7e9c312aeebe3d900e9b68a68659f36e8f2fbe Mon Sep 17 00:00:00 2001 From: Ricardo Valfreixo Date: Fri, 23 Oct 2026 11:33:39 +0200 Subject: [PATCH 2/7] Wire Android Log reselect to native scrolling and refresh --- app/NativeComponents/Layouts/TabsLayout.php | 14 +- app/NativeComponents/Log.php | 11 + .../views/native/partials/log-page.blade.php | 5 + .../Screens/LogBottomNavReselectTest.php | 225 ++++++++++++++++++ 4 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Screens/LogBottomNavReselectTest.php diff --git a/app/NativeComponents/Layouts/TabsLayout.php b/app/NativeComponents/Layouts/TabsLayout.php index 86dbb5e..5abef34 100644 --- a/app/NativeComponents/Layouts/TabsLayout.php +++ b/app/NativeComponents/Layouts/TabsLayout.php @@ -12,6 +12,7 @@ use Native\Mobile\Edge\Layouts\Builders\TabBar; use Native\Mobile\Edge\Layouts\NativeLayout; use Native\Mobile\Edge\NativeComponent; +use Native\Mobile\Platform; /** * The top-level sections: Log, Notifications, Remotes and your Profile, with @@ -70,16 +71,27 @@ public function navBar(NativeComponent $screen): ?NavBar public function tabBar(NativeComponent $screen): ?TabBar { + $log = Tab::link('Log', '/log', ios: Lucide::GitCommitVertical->ios(), android: Lucide::GitCommitVertical->android()); $notifications = Tab::link('Notifications', '/notifications', ios: Lucide::Bell->ios(), android: Lucide::Bell->android()); $remotes = Tab::link('Remotes', '/remotes', ios: Lucide::MessagesSquare->ios(), android: Lucide::MessagesSquare->android()); + // On Android, a re-tap of the Log tab while the Log is the screen showing + // takes the log you're on back to its top and, once it's there, refreshes + // it (see Log::refreshLog). The target names the list of the log that's + // showing, of the three the pager keeps. Only that screen and platform + // carry the handler: iOS and every other screen, stacked under the tab or + // not, keep the tab's plain URL navigation. + if ($screen instanceof Log && Platform::isAndroid()) { + $log->onReselect('refreshLog', 'log-feed-'.$screen->log); + } + self::badge($notifications, app(Account::class)->unreadNotifications()); self::badge($remotes, app(Account::class)->unreadRemotes()); return TabBar::make() ->activeColor(self::accent()) ->labelVisibility('labeled') - ->add(Tab::link('Log', '/log', ios: Lucide::GitCommitVertical->ios(), android: Lucide::GitCommitVertical->android())) + ->add($log) ->add($notifications) ->add($remotes) ->add(Tab::link('Profile', '/profile', ios: Lucide::UserRound->ios(), android: Lucide::UserRound->android())); diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index 04e28ad..1ea3cd9 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -414,6 +414,17 @@ public function load(): void $this->refreshUnreadNotifications(); } + /** + * A re-tap of the Log tab at the bottom, which the tab bar only sends once + * the log you're on is back at its top (it scrolls the list there itself + * when it isn't): pull the log to refresh, as the web's tab does. Keeps + * the log you're on and its list where they are. + */ + public function refreshLog(): void + { + $this->load(); + } + /** * The pill: the new commits go on top, and they're the newest you've seen now. */ diff --git a/resources/views/native/partials/log-page.blade.php b/resources/views/native/partials/log-page.blade.php index fa97fbb..50d4536 100644 --- a/resources/views/native/partials/log-page.blade.php +++ b/resources/views/native/partials/log-page.blade.php @@ -6,6 +6,10 @@ row changed under it kept its scroll offset and row heights, and iOS drew it blank with the first rows pushed to the bottom, so a page only gets new rows under it when it can't have been scrolled. + Each also registers itself under its own scroll target key (log-feed-), which Android's tab bar looks the + current log's list up by to scroll it back to its top on a re-tap of the Log tab, and to say it's at its top so + that tap refreshes instead (see Log::refreshLog). iOS ignores the key. + Only the log you're on asks for its next page: a page laid out off screen still shows its rows, and its last ones would ask for more of the log you're on. It also says which rows are on screen as you scroll, so only the rows near them are drawn (see App\Devnull\RowWindow). Every page keeps pull to refresh, which can only be pulled on the log @@ -15,6 +19,7 @@ 'http://devnull.test']); + signIn(); +}); + +/** + * The bottom navigation bar's tab for a section, as TabsLayout publishes it in + * the active screen's chrome: the bottom_nav_item Android's tab bar renders. + * + * @return array + */ +function retapTab(TestableComponent $screen, string $label): array +{ + $tab = findNode($screen->tree(), function (array $node) use ($label): bool { + return ($node['type'] ?? null) === 'bottom_nav_item' + && ($node['props']['label'] ?? null) === $label; + }); + + expect($tab)->not->toBeNull("The bottom navigation must publish a [{$label}] tab."); + + return $tab; +} + +/** + * The scroll targets the Log's pager lists register under, one per log's own + * list, in pager order. Android's reselect seam looks the current log's list + * up by exactly these keys. + * + * @return list + */ +function retapListTargets(TestableComponent $screen): array +{ + $targets = array_map( + fn (array $list): string => (string) ($list['props']['scroll_target'] ?? ''), + findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'list'), + ); + + return array_values(array_filter($targets, fn (string $target): bool => $target !== '')); +} + +/** + * The callback id the screen's registry resolves a method name to, as the + * runloop does when a press lands from the device (the harness's registry + * accessor is protected; the suite binds into component internals the same + * way, see LogsTest's shownLogIds). + */ +function retapCallbackId(TestableComponent $screen, string $method): ?int +{ + return Closure::bind(fn (): ?int => $this->callbacks()->lookup($method), $screen, TestableComponent::class)(); +} + +/** + * How many GETs have gone to one of the logs, not counting the next pages a + * list asks for with a cursor. + */ +function retapRequestsTo(string $log): int +{ + return Http::recorded(fn (Request $request): bool => str_contains($request->url(), "/api/v1/log/{$log}") + && ! str_contains($request->url(), 'cursor='))->count(); +} + +/** + * The three logs as devnull sends them, plus the account the badges read. + * + * @param list>|null $trending + */ +function serveRetapFeeds(?array $trending = null): void +{ + serveApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'f1', 'body' => 'From following'])], path: 'http://devnull.test/api/v1/log/following')], + 'GET /api/v1/log/latest' => [pageJson([commitJson(['id' => 'l1', 'body' => 'From latest'])], path: 'http://devnull.test/api/v1/log/latest')], + 'GET /api/v1/log/trending' => [$trending ?? [ + ...pageJson([commitJson(['id' => 't1', 'body' => 'From trending'])], path: 'http://devnull.test/api/v1/log/trending'), + 'log' => 'trending', + 'is_ranked' => true, + 'newest_commit_id' => 't1', + 'suggestions' => [], + ]], + 'GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0]], + ]); +} + +it('wires the Log tab reselect on Android to a scroll target per log, following the log that is showing', function (string $log) { + holdRefreshes(); + serveRetapFeeds(); + + $screen = finishRefreshes(Native::visit('/log', platform: 'android')); + $screen = finishRefreshes(tapLogTab($screen, $log)); + + // The Log tab carries the reselect handler, registered to the screen's + // refresh method, and names the current log's list as its scroll target: + // the published on_reselect id is the id the registry resolves the + // method to, which is what Android's tab bar sends a press at. + $tab = retapTab($screen, 'Log'); + + expect($tab['props']['on_reselect'] ?? null)->toBeInt() + ->and(retapCallbackId($screen, 'refreshLog'))->toBe($tab['props']['on_reselect']) + ->and($tab['props']['reselect_scroll_target'] ?? null)->toBe("log-feed-{$log}"); + + // Each of the pager's three lists registers under its own key, so the + // neighbors composed beside the log you're on never fight over one entry. + expect(retapListTargets($screen))->toBe(['log-feed-following', 'log-feed-latest', 'log-feed-trending']); + + // Only the Log tab carries the reselect: the other sections' tabs keep + // their plain URL navigation. + expect(retapTab($screen, 'Notifications')['props']['on_reselect'] ?? null)->toBeNull() + ->and(retapTab($screen, 'Remotes')['props']['on_reselect'] ?? null)->toBeNull() + ->and(retapTab($screen, 'Profile')['props']['on_reselect'] ?? null)->toBeNull(); +})->with(Log::Logs); + +it('refreshes the log you are on from the reselect callback, without remounting it or navigating', function () { + holdRefreshes(); + keep('log/following', pageJson([commitJson(['id' => 'c1', 'body' => 'Kept row'])], path: 'http://devnull.test/api/v1/log/following')); + serveApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c2', 'body' => 'Retapped in']), commitJson(['id' => 'c1', 'body' => 'Kept row'])], path: 'http://devnull.test/api/v1/log/following')], + 'GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0]], + ]); + + $screen = finishRefreshes(Native::visit('/log', platform: 'android'))->assertSee('Kept row'); + + $listKey = $screen->get('listKey'); + $list = showingLog($screen)['id']; + $asks = retapRequestsTo('following'); + $onReselect = retapTab($screen, 'Log')['props']['on_reselect'] ?? null; + + // The confirmed-top press the tab bar sends: a press at the published + // on_reselect id, which the registry resolves to the screen's method. + expect($onReselect)->toBeInt() + ->and(retapCallbackId($screen, 'refreshLog'))->toBe($onReselect); + + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + + // Forced: another reselect while the first answer is still on its way + // still asks, as pull to refresh does. + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + + finishRefreshes($screen); + + // Two fresh GETs of the log that's showing — the mount's ask and nothing + // else besides the two reselects — and the fresh rows are laid over the + // list in place: same list key, same list node, no navigation, still the + // log you were on. + expect(retapRequestsTo('following'))->toBe($asks + 2) + ->and($screen->get('log'))->toBe('following') + ->and($screen->get('listKey'))->toBe($listKey) + ->and(showingLog($screen)['id'])->toBe($list) + ->and($screen->navigationIntent())->toBeNull(); + + $screen->assertSee('Retapped in')->assertSee('Kept row'); +}); + +it('still crosses sections on the tab URL, which the Log reselect leaves alone', function () { + fakeApi([ + 'GET /api/v1/notifications' => [[ + ...pageJson([], path: 'http://devnull.test/api/v1/notifications'), + 'filters' => ['type' => 'all'], + 'read_at' => now()->toIso8601ZuluString(), + ]], + 'GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0]], + ]); + + $screen = Native::visit('/notifications', platform: 'android'); + + // Off the Log screen the tab carries no reselect handler: from here a + // tap on it is the tab's plain URL navigation. + expect(retapTab($screen, 'Log')['props']['on_reselect'] ?? null)->toBeNull(); + + $screen->tap('Log')->assertReplacedWith('/log'); +}); + +it('publishes no Log reselect handler on a screen stacked under the Log tab', function () { + fakeApi([ + 'GET /api/v1/commits/c1' => [commitPageJson(commitJson(['id' => 'c1', 'body' => 'A commit']), [commitJson(['id' => 'c0', 'body' => 'Its parent'])])], + 'GET /api/v1/commits/c1/replies' => [pageJson([replyJson(['id' => 'r1', 'parent_id' => 'c1', 'body' => 'A reply', 'author' => userJson(['id' => 'u3', 'username' => 'grace', 'name' => 'Grace'])])])], + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'f1'])])], + 'GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0]], + ]); + + // The Log tab is still the active one, but the screen showing is a + // commit under it: no Log reselect handler is published. + Native::visit('/log/commits/c1', platform: 'android') + ->assertTabActive('Log'); + + expect(retapTab(Native::visit('/log/commits/c1', platform: 'android'), 'Log')['props']['on_reselect'] ?? null)->toBeNull(); +}); + +it('leaves the Log tab unwired on iOS', function () { + serveRetapFeeds(); + + $screen = Native::visit('/log', platform: 'ios'); + + expect(retapTab($screen, 'Log')['props']['on_reselect'] ?? null)->toBeNull() + ->and(retapTab($screen, 'Log')['props']['reselect_scroll_target'] ?? null)->toBeNull(); +}); + +it('asks once for whichever log is showing when its tab is retapped, and for nothing else', function (string $log) { + holdRefreshes(); + serveRetapFeeds(); + + $screen = finishRefreshes(Native::visit('/log', platform: 'android')); + $screen = finishRefreshes(tapLogTab($screen, $log)); + + $before = array_combine(Log::Logs, array_map(retapRequestsTo(...), Log::Logs)); + + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + + finishRefreshes($screen); + + // The reselect asks for the log that's showing once more, whichever of + // the three it is, and none of the others, and it stays where it was. + foreach (Log::Logs as $other) { + expect(retapRequestsTo($other))->toBe($before[$other] + ($other === $log ? 1 : 0)); + } + + expect($screen->get('log'))->toBe($log) + ->and($screen->navigationIntent())->toBeNull(); +})->with(Log::Logs); From fe428936f10be3f3edb4a54583ebe54c3e6bdee8 Mon Sep 17 00:00:00 2001 From: Ricardo Valfreixo Date: Fri, 23 Oct 2026 13:50:53 +0200 Subject: [PATCH 3/7] Restore Log tab return and show refresh progress --- app/NativeComponents/Concerns/OpensInTab.php | 8 + .../Concerns/RefreshesInBackground.php | 100 ++- app/NativeComponents/Layouts/TabsLayout.php | 19 +- app/NativeComponents/Log.php | 28 +- resources/views/native/log.blade.php | 1 + .../native/partials/refreshing.blade.php | 23 + .../Screens/LogBottomNavReselectTest.php | 20 +- .../Screens/LogRefreshFeedbackTest.php | 714 ++++++++++++++++++ tests/Feature/Screens/LogTabReturnTest.php | 145 ++++ 9 files changed, 1021 insertions(+), 37 deletions(-) create mode 100644 resources/views/native/partials/refreshing.blade.php create mode 100644 tests/Feature/Screens/LogRefreshFeedbackTest.php create mode 100644 tests/Feature/Screens/LogTabReturnTest.php diff --git a/app/NativeComponents/Concerns/OpensInTab.php b/app/NativeComponents/Concerns/OpensInTab.php index 381c499..6c33a20 100644 --- a/app/NativeComponents/Concerns/OpensInTab.php +++ b/app/NativeComponents/Concerns/OpensInTab.php @@ -21,6 +21,14 @@ */ trait OpensInTab { + /** + * Resume the existing feed instead of mounting another one over it. + */ + public function returnToLog(): void + { + $this->popTo(TabsLayout::Tabs[0]); + } + /** * @param array $data */ diff --git a/app/NativeComponents/Concerns/RefreshesInBackground.php b/app/NativeComponents/Concerns/RefreshesInBackground.php index 807a5df..f75690a 100644 --- a/app/NativeComponents/Concerns/RefreshesInBackground.php +++ b/app/NativeComponents/Concerns/RefreshesInBackground.php @@ -46,6 +46,30 @@ trait RefreshesInBackground */ protected array $refreshing = []; + /** + * The refreshes that owe the screen feedback, still out, by the path they + * were asked for — a feed's plain ask and its ?since ask are one feed's + * ask. Counted, not flagged: a refresh forced through beside one already + * out owes feedback until both have landed, and only the ones asked for + * it are counted at all — the quiet ones a poll or a resume makes never + * show. Cleared when the screen goes under another, whose answers were + * dropped, so a dropped answer can't leave feedback up for good (see + * forgetRefreshes). + * + * @var array + */ + protected array $feedbackRefreshes = []; + + /** + * Bumped whenever refreshes are forgotten (a screen going under another, + * whose answers were dropped). A completion carrying an older one owns + * nothing anymore — not the dedupe markers, not the feedback — though + * what it brought still applies, as ever. As Log guards its held pages by + * the list they were cut from (Log::$listGeneration), this guards the + * refreshes by the screen that asked for them. + */ + protected int $refreshGeneration = 0; + /** * Asks again for everything the screen shows. */ @@ -84,14 +108,19 @@ protected function cached(string $path, array $query = []): ?array * screen. It only tells $failed, for a caller that has something else to * do then. One $withImages brings the answer's images down before it * lands, rather than after in a task of their own (see FetchInBackground). + * A $feedback one is counted among the refreshes the screen says it's + * waiting on (see $feedbackRefreshes), landing or failing or failing its + * way into a retry. * * @param array $query * @param Closure(array): void $apply * @param (Closure(): void)|null $failed */ - protected function refresh(string $path, array $query, Closure $apply, bool $again = false, bool $quiet = false, ?Closure $failed = null, bool $withImages = false, bool $retried = false): void + protected function refresh(string $path, array $query, Closure $apply, bool $again = false, bool $quiet = false, ?Closure $failed = null, bool $withImages = false, bool $retried = false, bool $feedback = false, ?int $generation = null): void { $key = ResponseCache::key('', $path, $query); + $generation ??= $this->refreshGeneration; + $current = $generation === $this->refreshGeneration; if (! $again && isset($this->refreshing[$key]) && microtime(true) - $this->refreshing[$key] < 30) { return; @@ -109,25 +138,43 @@ protected function refresh(string $path, array $query, Closure $apply, bool $aga return; } - $this->refreshing[$key] = microtime(true); + if ($current) { + $this->refreshing[$key] = microtime(true); + + if ($feedback) { + $this->feedbackRefreshes[$path] = ($this->feedbackRefreshes[$path] ?? 0) + 1; + } + } FetchInBackground::dispatch($request->url, $request->headers, 'GET', '', $withImages, $withImages ? ImageCache::takePrune() : null) ->timeout(30) - ->finished(function (mixed $result) use ($key, $path, $query, $apply, $quiet, $failed, $withImages, $retried): void { - unset($this->refreshing[$key]); - + ->finished(function (mixed $result) use ($key, $path, $query, $apply, $quiet, $failed, $withImages, $retried, $feedback, $generation): void { $result = is_array($result) ? $result : []; + $serverError = ! $retried && (int) ($result['status'] ?? 0) >= 500; + + // A completion from before the screen went under another owns + // nothing: it doesn't touch the markers of the refreshes asked + // since. What it brought still applies, below, as ever — and + // its retry is still worth asking for, under its own old + // generation, owning nothing too. + if ($generation === $this->refreshGeneration) { + unset($this->refreshing[$key]); + $this->endedFeedbackRefresh($path, $feedback); + } - if (! $retried && (int) ($result['status'] ?? 0) >= 500) { - $this->refresh($path, $query, $apply, again: true, quiet: $quiet, failed: $failed, withImages: $withImages, retried: true); + if ($serverError) { + $this->refresh($path, $query, $apply, again: true, quiet: $quiet, failed: $failed, withImages: $withImages, retried: true, feedback: $feedback, generation: $generation); return; } $this->settleRefresh($result, $path, $query, $apply, $quiet, $failed, $withImages); }) - ->failed(function (Throwable $exception) use ($key, $quiet, $failed): void { - unset($this->refreshing[$key]); + ->failed(function (Throwable $exception) use ($key, $path, $quiet, $failed, $feedback, $generation): void { + if ($generation === $this->refreshGeneration) { + unset($this->refreshing[$key]); + $this->endedFeedbackRefresh($path, $feedback); + } if ($quiet) { $failed?->__invoke(); @@ -168,13 +215,46 @@ protected function refused(ApiException $exception): bool return false; } + /** + * Whether a refresh the screen owes feedback for is still out — any of + * them, or the ones asking for $path, a feed's plain ask and its ?since + * ask alike. No clock in it: an answer that never comes keeps its + * feedback, and only landing or failing takes it down. + */ + public function refreshFeedbackOut(?string $path = null): bool + { + return $path === null ? $this->feedbackRefreshes !== [] : isset($this->feedbackRefreshes[$path]); + } + + /** + * One feedback refresh done, landed or failed. Others asking for the + * same path may still be out, forced through beside it. + */ + protected function endedFeedbackRefresh(string $path, bool $feedback): void + { + if (! $feedback) { + return; + } + + if (($this->feedbackRefreshes[$path] ?? 0) <= 1) { + unset($this->feedbackRefreshes[$path]); + + return; + } + + $this->feedbackRefreshes[$path]--; + } + /** * Forgets refreshes that were still out, whose answers NativePHP dropped - * when this screen went under another. + * when this screen went under another. Their feedback goes with them, + * and whatever lands from them afterwards owns nothing. */ protected function forgetRefreshes(): void { $this->refreshing = []; + $this->feedbackRefreshes = []; + $this->refreshGeneration++; } /** diff --git a/app/NativeComponents/Layouts/TabsLayout.php b/app/NativeComponents/Layouts/TabsLayout.php index 5abef34..e9ebf00 100644 --- a/app/NativeComponents/Layouts/TabsLayout.php +++ b/app/NativeComponents/Layouts/TabsLayout.php @@ -75,14 +75,17 @@ public function tabBar(NativeComponent $screen): ?TabBar $notifications = Tab::link('Notifications', '/notifications', ios: Lucide::Bell->ios(), android: Lucide::Bell->android()); $remotes = Tab::link('Remotes', '/remotes', ios: Lucide::MessagesSquare->ios(), android: Lucide::MessagesSquare->android()); - // On Android, a re-tap of the Log tab while the Log is the screen showing - // takes the log you're on back to its top and, once it's there, refreshes - // it (see Log::refreshLog). The target names the list of the log that's - // showing, of the three the pager keeps. Only that screen and platform - // carry the handler: iOS and every other screen, stacked under the tab or - // not, keep the tab's plain URL navigation. - if ($screen instanceof Log && Platform::isAndroid()) { - $log->onReselect('refreshLog', 'log-feed-'.$screen->log); + /** + * Android reselect refreshes a root feed after scrolling to its top, + * but returns from child screens without replacing the existing feed. + * Inactive tabs still use their normal URL press. + */ + if (Platform::isAndroid()) { + if ($screen instanceof Log) { + $log->onReselect('refreshLog', 'log-feed-'.$screen->log); + } elseif (method_exists($screen, 'returnToLog')) { + $log->onReselect('returnToLog'); + } } self::badge($notifications, app(Account::class)->unreadNotifications()); diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index 1ea3cd9..a5ae69a 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -49,7 +49,10 @@ * * It opens on the log as it was last time, then asks devnull again in the * background, and every 30 seconds while it's showing, as the web polls. - * Commits newer than anything showing wait at the top of the list in the new + * A pull to refresh, and the re-tap of the Log tab that does the same, says + * it's waiting with the chip under the pager's tabs (see + * partials/refreshing), which those quiet asks never do. Commits newer than + * anything showing wait at the top of the list in the new * commits pill (see HoldsNewCommits), which says how many devnull counted * since the newest one showing (?since=). Scrolled down, it pins under the tabs. * @@ -363,7 +366,7 @@ private function openAfresh(): void $this->replacedList(); $this->lastVisitAt = $this->lastVisitSeenAt(); $this->showKept(); - $this->open(); + $this->open(feedback: true); } /** @@ -398,18 +401,20 @@ public function isRanked(): bool } /** - * Pull to refresh. You're at the top, so anything new goes straight in. + * Pull to refresh, and the re-tap of the Log tab that does the same. You're + * at the top, so anything new goes straight in. It says it's waiting (the + * refresh carries feedback), as a pull says it with the list in your hand. */ public function load(): void { if ($this->isRanked()) { - $this->rank(); + $this->rank(feedback: true); return; } $log = $this->log; - $this->refresh('log/'.$log, [], fn (array $json) => $this->takeFresh($log, $json, showNewer: true), again: true); + $this->refresh('log/'.$log, [], fn (array $json) => $this->takeFresh($log, $json, showNewer: true), again: true, feedback: true); $this->refreshThreads(); $this->refreshUnreadNotifications(); } @@ -449,14 +454,14 @@ public function showNewCommits(): void * Asks devnull what changed since the newest commit showing. Trending * stays as it was ranked, and only learns what's newer than the ranking. */ - protected function revalidate(): void + protected function revalidate(bool $feedback = false): void { $log = $this->log; $since = $this->isRanked() ? $this->rankedSince : ($this->commits[0]->id ?? null); $this->refresh('log/'.$log, ['since' => $since], fn (array $json) => $this->isRanked() ? $this->takeRankedUpdate($log, $json) - : $this->takeFresh($log, $json)); + : $this->takeFresh($log, $json), feedback: $feedback); $this->refreshThreads(); $this->refreshUnreadNotifications(); } @@ -464,20 +469,20 @@ protected function revalidate(): void /** * Opening a log: Trending is ranked afresh, as a page load does on the web. The others refresh behind what's kept. */ - private function open(): void + private function open(bool $feedback = false): void { - $this->isRanked() ? $this->rank() : $this->revalidate(); + $this->isRanked() ? $this->rank($feedback) : $this->revalidate($feedback); } /** * Ranks Trending afresh: the fresh ranking replaces what's showing, and * what was put above it goes, as reloading the page does on the web. */ - private function rank(): void + private function rank(bool $feedback = false): void { $log = $this->log; - $this->refresh('log/'.$log, [], fn (array $json) => $this->takeRanking($log, $json), again: true); + $this->refresh('log/'.$log, [], fn (array $json) => $this->takeRanking($log, $json), again: true, feedback: $feedback); $this->refreshThreads(); $this->refreshUnreadNotifications(); } @@ -1034,6 +1039,7 @@ private function frame(): View 'pasteis' => app(Account::class)->pasteis(), 'stashed' => app(Stash::class)->names($viewer?->id), 'newCommitsLabel' => $this->newCommitsLabel(), + 'refreshing' => $this->refreshFeedbackOut('log/'.$this->log), 'pages' => $pages, 'pageIndex' => array_search($this->log, self::Logs, true), 'tabs' => array_map(fn (string $log): string => self::Labels[$log], self::Logs), diff --git a/resources/views/native/log.blade.php b/resources/views/native/log.blade.php index b7e5a31..cb54286 100644 --- a/resources/views/native/log.blade.php +++ b/resources/views/native/log.blade.php @@ -40,6 +40,7 @@ class="w-full flex-1 bg-theme-surface" @include('native.partials.new-commits-pill', ['label' => $newCommitsLabel, 'press' => 'showNewCommits', 'shown' => $newCommits !== []]) + @include('native.partials.refreshing', ['shown' => $refreshing]) diff --git a/resources/views/native/partials/refreshing.blade.php b/resources/views/native/partials/refreshing.blade.php new file mode 100644 index 0000000..7f91b4b --- /dev/null +++ b/resources/views/native/partials/refreshing.blade.php @@ -0,0 +1,23 @@ +{{-- + The refresh a re-tap of the Log tab or a pull started, still out: a quiet + chip in the pager's overlay that says the log is being fetched again, to + the eye and to a screen reader, until the answer lands or fails (the + refreshes that owe this are counted in RefreshesInBackground; the quiet + ones the poll and a resume make never show here). + + Its slot is always in the overlay, hidden until there's something to + show, so the pager and its lists keep their shape when it comes and goes + (as the new commits pill's slot does; a node added beside a scrolled list + sometimes left the list blank on iOS). It sits after the pill, which + pins under the tabs: the pill stays where it is while the chip comes and + goes beneath it. +--}} +@php($shown ??= false) + + @if ($shown) + + + Refreshing… + + @endif + diff --git a/tests/Feature/Screens/LogBottomNavReselectTest.php b/tests/Feature/Screens/LogBottomNavReselectTest.php index 1b4c62d..2a6efdf 100644 --- a/tests/Feature/Screens/LogBottomNavReselectTest.php +++ b/tests/Feature/Screens/LogBottomNavReselectTest.php @@ -169,14 +169,13 @@ function serveRetapFeeds(?array $trending = null): void $screen = Native::visit('/notifications', platform: 'android'); - // Off the Log screen the tab carries no reselect handler: from here a - // tap on it is the tab's plain URL navigation. - expect(retapTab($screen, 'Log')['props']['on_reselect'] ?? null)->toBeNull(); + expect(retapTab($screen, 'Log')['props']['on_reselect'] ?? null)->toBeInt() + ->and(retapTab($screen, 'Log')['props']['reselect_scroll_target'] ?? null)->toBeNull(); $screen->tap('Log')->assertReplacedWith('/log'); }); -it('publishes no Log reselect handler on a screen stacked under the Log tab', function () { +it('wires a child screen Log reselect to pop back without scrolling or refreshing the feed', function () { fakeApi([ 'GET /api/v1/commits/c1' => [commitPageJson(commitJson(['id' => 'c1', 'body' => 'A commit']), [commitJson(['id' => 'c0', 'body' => 'Its parent'])])], 'GET /api/v1/commits/c1/replies' => [pageJson([replyJson(['id' => 'r1', 'parent_id' => 'c1', 'body' => 'A reply', 'author' => userJson(['id' => 'u3', 'username' => 'grace', 'name' => 'Grace'])])])], @@ -184,12 +183,17 @@ function serveRetapFeeds(?array $trending = null): void 'GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0]], ]); - // The Log tab is still the active one, but the screen showing is a - // commit under it: no Log reselect handler is published. - Native::visit('/log/commits/c1', platform: 'android') + $screen = Native::visit('/log/commits/c1', platform: 'android') ->assertTabActive('Log'); + $tab = retapTab($screen, 'Log'); + $callback = 'returnToLog'; + + expect($tab['props']['on_reselect'] ?? null)->toBeInt() + ->and(retapCallbackId($screen, $callback))->toBe($tab['props']['on_reselect']) + ->and($tab['props']['reselect_scroll_target'] ?? null)->toBeNull(); - expect(retapTab(Native::visit('/log/commits/c1', platform: 'android'), 'Log')['props']['on_reselect'] ?? null)->toBeNull(); + $screen->fireEvent($callback, TestableComponent::EVENT_PRESS) + ->assertPoppedTo('/log'); }); it('leaves the Log tab unwired on iOS', function () { diff --git a/tests/Feature/Screens/LogRefreshFeedbackTest.php b/tests/Feature/Screens/LogRefreshFeedbackTest.php new file mode 100644 index 0000000..7cb5f3f --- /dev/null +++ b/tests/Feature/Screens/LogRefreshFeedbackTest.php @@ -0,0 +1,714 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * The bottom navigation bar's tab for a section, as TabsLayout publishes it + * in the active screen's chrome, as the reselect suite reads it. + * + * @return array + */ +function feedbackTab(TestableComponent $screen, string $label): array +{ + $tab = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'bottom_nav_item' + && ($node['props']['label'] ?? null) === $label); + + expect($tab)->not->toBeNull("The bottom navigation must publish a [{$label}] tab."); + + return $tab; +} + +/** + * The callback id the screen's registry resolves a method name to, as the + * runloop does when a press lands from the device (the harness's registry + * accessor is protected; the reselect suite binds into component internals + * the same way). + */ +function feedbackCallbackId(TestableComponent $screen, string $method): ?int +{ + return Closure::bind(fn (): ?int => $this->callbacks()->lookup($method), $screen, TestableComponent::class)(); +} + +/** + * The feedback a re-tap of the Log tab owes while devnull answers: something + * on screen that says, to the eye and to a screen reader, that the log is + * being fetched again — an activity indicator whose subtree carries an + * accessibility label, or any node whose label says it is refreshing. The + * skeleton rows a log with nothing kept to show draws, and the quiet + * "couldn't refresh" banner after a failure, are not it: those answer + * different questions. + * + * @return array|null + */ +function refreshFeedback(TestableComponent $screen): ?array +{ + $labelled = fn (array $node): bool => findNode($node, fn (array $in): bool => (string) ($in['props']['a11y_label'] ?? '') !== '') !== null; + + return findNode($screen->tree(), function (array $node) use ($labelled): bool { + return (in_array($node['type'] ?? null, ['activity_indicator'], true) + || str_contains(mb_strtolower((string) ($node['props']['a11y_label'] ?? '')), 'refreshing')) + && $labelled($node); + }); +} + +/** + * Serves the logs from a queue of answers, as devnull moving on between two + * refreshes: each GET of a log's first page takes the next answer queued for + * it, in the order asked, and `me` answers plainly. Next pages 404, so a + * stray one shows up in the test. + * + * @param array>> $answers + */ +function serveFeedbackFeeds(array $answers): void +{ + serveApi(function (Request $request) use (&$answers) { + $path = substr((string) parse_url($request->url(), PHP_URL_PATH), strlen('/api/v1/')); + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + if ($path === 'me') { + return Http::response(['data' => userWithCountsJson(), 'unread_notifications_count' => 0]); + } + + if (isset($query['cursor']) || ($answers[$path] ?? []) === []) { + return Http::response(['message' => "No fake for {$request->url()}."], 404); + } + + return Http::response(array_shift($answers[$path])); + }); +} + +/** + * A log's first page as devnull sends it, with Trending's ranking shape. + * + * @param list> $commits + * @return array + */ +function feedbackPage(string $log, array $commits): array +{ + $page = pageJson($commits, path: "http://devnull.test/api/v1/log/{$log}"); + + return $log === Log::Ranked + ? [...$page, 'log' => 'trending', 'is_ranked' => true, 'newest_commit_id' => $commits[0]['id'] ?? null, 'suggestions' => []] + : $page; +} + +/** + * How many GETs have gone to one of the logs' first pages, not counting the + * next pages a list asks for with a cursor. + */ +function feedbackAsks(string $log): int +{ + return Http::recorded(fn (Request $request): bool => str_contains($request->url(), "/api/v1/log/{$log}") + && ! str_contains($request->url(), 'cursor='))->count(); +} + +/** + * The payloads of the background refreshes still held, oldest first, by + * their task id — read where they wait, without landing any of them. + * + * @return array + */ +function heldRefreshPayloads(TestableComponent $screen): array +{ + $held = []; + + foreach ($screen->bridge()->callsTo('AsyncTask.Dispatch') as $call) { + $id = $call['params']['id']; + $path = AsyncTaskTransport::directory('payload').DIRECTORY_SEPARATOR.$id.'.task'; + + if (is_file($path)) { + $held[$id] = (string) file_get_contents($path); + } + } + + return $held; +} + +/** + * Lands a held background refresh, exactly as the background lane lands one: + * runs the task its payload names and delivers its answer to the screen the + * way the lane does. finishRefreshes() runs them all; the lane answers one + * at a time and not in order, so $url lands the oldest held task asking for + * it (or the newest, with $last) — a stale answer landing alone is what some + * of these tests need. + */ +function landOneHeldRefresh(TestableComponent $screen, ?string $url = null, bool $last = false): void +{ + $matching = array_keys(array_filter(heldRefreshPayloads($screen), fn (string $payload): bool => $url === null || str_contains($payload, $url))); + $id = $matching === [] ? null : ($last ? $matching[count($matching) - 1] : $matching[0]); + $payload = $id === null ? null : AsyncTaskTransport::readPayload($id); + $what = $url ?? 'anything'; + + expect($payload)->not->toBeNull("No held background refresh for [{$what}] was left to land."); + + try { + $result = AsyncTaskRunner::normalizeResult(AsyncTaskRunner::invoke(unserialize($payload))); + $screen->emitNative(AsyncTaskFinished::class, ['id' => $id, 'result' => $result]); + } catch (Throwable $exception) { + $screen->emitNative(AsyncTaskFailed::class, ['id' => $id, 'exceptionClass' => $exception::class, 'message' => $exception->getMessage()]); + } +} + +/** + * Forgets every held background refresh but the oldest, as a screen going + * under another has all but its first ask answered and dropped — and keeps + * that one, to land it late. + */ +function keepOldestHeldRefresh(TestableComponent $screen): void +{ + $held = array_keys(heldRefreshPayloads($screen)); + + expect($held)->not->toBeEmpty('No held background refresh was left to keep.'); + + array_shift($held); + + foreach ($held as $id) { + AsyncTaskTransport::forgetPayload($id); + } +} + +it('says the log is being refreshed, to the eye and to a screen reader, while a re-tap of the Log tab is answered', function (string $log) { + holdRefreshes(); + serveFeedbackFeeds([ + 'log/following' => [ + feedbackPage('following', [commitJson(['id' => 'f1', 'body' => 'Kept row'])]), + feedbackPage('following', [commitJson(['id' => 'f2', 'body' => 'Retapped in']), commitJson(['id' => 'f1', 'body' => 'Kept row'])]), + ], + 'log/latest' => [ + feedbackPage('latest', [commitJson(['id' => 'l1', 'body' => 'Kept row'])]), + feedbackPage('latest', [commitJson(['id' => 'l2', 'body' => 'Retapped in']), commitJson(['id' => 'l1', 'body' => 'Kept row'])]), + ], + 'log/trending' => [ + feedbackPage('trending', [commitJson(['id' => 't1', 'body' => 'Kept row'])]), + feedbackPage('trending', [commitJson(['id' => 't2', 'body' => 'Retapped in']), commitJson(['id' => 't1', 'body' => 'Kept row'])]), + ], + ]); + + $screen = finishRefreshes(Native::visit('/log', platform: 'android')); + $screen = finishRefreshes(tapLogTab($screen, $log))->assertSee('Kept row'); + + // The confirmed-top press the tab bar sends, at the published on_reselect id. + $onReselect = feedbackTab($screen, 'Log')['props']['on_reselect'] ?? null; + + expect($onReselect)->toBeInt() + ->and(feedbackCallbackId($screen, 'refreshLog'))->toBe($onReselect); + + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + + // Immediately, before anything lands. + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + $screen->assertSee('Kept row'); + + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull('The feedback outlived the refresh it was for.'); + + $screen->assertSee('Retapped in')->assertSee('Kept row'); +})->with(Log::Logs); + +it('keeps the log, its list and its rows where they are while the re-tap refresh is answered, and lays the fresh rows in place', function () { + holdRefreshes(); + serveFeedbackFeeds([ + 'log/following' => [ + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept row'])]), + feedbackPage('following', [commitJson(['id' => 'c2', 'body' => 'Retapped in']), commitJson(['id' => 'c1', 'body' => 'Kept row'])]), + ], + ]); + + $screen = finishRefreshes(Native::visit('/log', platform: 'android'))->assertSee('Kept row'); + + $listKey = $screen->get('listKey'); + $list = showingLog($screen)['id']; + + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + + expect($screen->get('log'))->toBe('following') + ->and($screen->get('listKey'))->toBe($listKey) + ->and(showingLog($screen)['id'])->toBe($list) + ->and($screen->navigationIntent())->toBeNull(); + + $screen->assertSee('Kept row'); + + finishRefreshes($screen); + + expect($screen->get('log'))->toBe('following') + ->and($screen->get('listKey'))->toBe($listKey) + ->and(showingLog($screen)['id'])->toBe($list) + ->and($screen->navigationIntent())->toBeNull(); + + $screen->assertSee('Retapped in')->assertSee('Kept row'); +}); + +it('clears the feedback when the refresh cannot be done, saying so the quiet way instead', function (string $log) { + holdRefreshes(); + + $offline = false; + $page = fn (): array => feedbackPage($log, [commitJson(['id' => 'k1', 'body' => 'Kept row'])]); + serveApi(function (Request $request) use (&$offline, $log, $page) { + $path = substr((string) parse_url($request->url(), PHP_URL_PATH), strlen('/api/v1/')); + + if ($offline && in_array($path, ['me', "log/{$log}"], true)) { + throw new ConnectionException('cURL error 6: Could not resolve host: devnull.test'); + } + + return match ($path) { + 'me' => Http::response(['data' => userWithCountsJson(), 'unread_notifications_count' => 0]), + "log/{$log}" => Http::response($page()), + default => Http::response(['message' => "No fake for {$path}."], 404), + }; + }); + + $screen = finishRefreshes(Native::visit('/log', platform: 'android')); + $screen = finishRefreshes(tapLogTab($screen, $log))->assertSee('Kept row'); + + $offline = true; + + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + finishRefreshes($screen); + + // Waiting is over, one way or another: the feedback goes and the quiet + // banner takes its turn, with what was showing kept where it was. + expect(refreshFeedback($screen))->toBeNull('The feedback outlived a refresh that failed.') + ->and($screen->get('refreshFailed'))->toBeTrue() + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'refresh-failed'))->not->toBeNull(); + + $screen->assertSee('Kept row'); +})->with(['following', 'trending']); + +it('keeps the feedback up while a newer refresh of the same log is still out, when an older answer lands', function () { + holdRefreshes(); + serveFeedbackFeeds([ + 'log/following' => [ + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept row'])]), + feedbackPage('following', [commitJson(['id' => 'c2', 'body' => 'First answer'])]), + feedbackPage('following', [commitJson(['id' => 'c3', 'body' => 'Second answer'])]), + ], + ]); + + $screen = finishRefreshes(Native::visit('/log', platform: 'android'))->assertSee('Kept row'); + + // Two refreshes of the same log out at once, as the reselect suite sends. + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + // The older one's answer lands first, as the lane answers one at a time. + landOneHeldRefresh($screen); + + expect(refreshFeedback($screen))->not->toBeNull('An older answer cleared the feedback while a newer refresh is still out.'); + + $screen->assertSee('First answer'); + + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull(); + + $screen->assertSee('Second answer'); +}); + +it('keeps the feedback for the log you switched to while the log you left is still being answered', function () { + holdRefreshes(); + keep('log/latest', feedbackPage('latest', [commitJson(['id' => 'l1', 'body' => 'Kept on Latest'])])); + serveFeedbackFeeds([ + 'log/following' => [ + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept on Following'])]), + feedbackPage('following', [commitJson(['id' => 'c2', 'body' => 'Late from following'])]), + ], + 'log/latest' => [ + feedbackPage('latest', [commitJson(['id' => 'l1', 'body' => 'Latest landed'])]), + ], + ]); + + $screen = finishRefreshes(Native::visit('/log', platform: 'android'))->assertSee('Kept on Following'); + + // A re-tap refresh of Following, then away to Latest before it answers. + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + tapLogTab($screen, 'latest')->assertSee('Kept on Latest'); + + // Following's answer lands first, stale now that you've left: an answer + // for a log nobody is on must not settle the refresh that's showing. + landOneHeldRefresh($screen); + + expect(refreshFeedback($screen))->not->toBeNull('A stale answer for the log you left cleared the feedback for the log you\'re on.'); + + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull() + ->and($screen->get('log'))->toBe('latest'); + + $screen->assertSee('Latest landed'); +}); + +it('still asks, and still says so, once the log has sat idle past its refresh windows', function () { + holdRefreshes(); + serveFeedbackFeeds([ + 'log/following' => [ + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept row'])]), + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Polled in'])]), + feedbackPage('following', [commitJson(['id' => 'c2', 'body' => 'Retapped in']), commitJson(['id' => 'c1', 'body' => 'Polled in'])]), + feedbackPage('following', [commitJson(['id' => 'c3', 'body' => 'Retapped again']), commitJson(['id' => 'c2', 'body' => 'Retapped in'])]), + ], + ]); + + $screen = finishRefreshes(Native::visit('/log', platform: 'android'))->assertSee('Kept row'); + + // The screen sits idle: its poll asks and is answered, and time moves on + // past every thirty-second window a refresh is remembered by. This is as + // close as PHP comes to the user coming back to an idle phone; whether + // the tab bar still sends the reselect then is the device's half. + finishRefreshes($screen->firePolls())->assertSee('Polled in'); + + $this->travel(31)->seconds(); + + $asks = feedbackAsks('following'); + $sent = refreshesSent($screen); + + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + + // Still asked — the press still puts its ask in the background — and + // still said: nothing about having been idle may hold either back. + expect(refreshesSent($screen))->toBeGreaterThan($sent) + ->and(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + // The answer is late, later than every window, and the feedback waits + // for it all the same: no clock takes it down, only the answer. + $this->travel(35)->seconds(); + + expect(refreshFeedback($screen))->not->toBeNull('The feedback gave up on a refresh still out.'); + + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull() + ->and(feedbackAsks('following'))->toBe($asks + 1); + + $screen->assertSee('Retapped in'); + + // And once more after that: whatever the feedback ran on has let go. + $sent = refreshesSent($screen); + + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + + expect(refreshesSent($screen))->toBeGreaterThan($sent) + ->and(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull(); + + $screen->assertSee('Retapped again'); +}); + +it('lets the feedback go when the screen goes under another, whose answers were dropped', function () { + holdRefreshes(); + keep('commits/c1', commitPageJson(commitJson(['id' => 'c1', 'body' => 'A commit']))); + keep('commits/c1/replies', pageJson([], path: 'http://devnull.test/api/v1/commits/c1/replies')); + serveFeedbackFeeds([ + 'log/following' => [ + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept row'])]), + feedbackPage('following', [commitJson(['id' => 'c2', 'body' => 'Landed while away'])]), + ], + ]); + + $screen = finishRefreshes(Native::visit('/log'))->assertSee('Kept row'); + + // The reselect refresh, sent as the tab bar sends it (see the rest of + // this suite for the wiring). + $screen->call('refreshLog'); + + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + // Under a commit, with the refresh still out: its answer is dropped, as + // NativePHP drops the answers a buried screen asked for. + $child = $screen->tap('commit-c1')->assertNavigatedTo('/log/commits/c1')->follow(); + + foreach ($screen->bridge()->callsTo('AsyncTask.Dispatch') as $call) { + AsyncTaskTransport::forgetPayload($call['params']['id']); + } + + // Back: the refresh it never heard the end of is forgotten with its + // dropped answer, and the one the resume makes is quiet. + $screen = $child->goBack(); + + expect(refreshFeedback($screen))->toBeNull('The feedback outlived the screen going under another.'); + + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull(); + + $screen->assertSee('Kept row'); +}); + +it('says only the log that is showing is being refreshed, so a slower other log cannot keep its chip up', function () { + holdRefreshes(); + keep('log/latest', feedbackPage('latest', [commitJson(['id' => 'l1', 'body' => 'Kept on Latest'])])); + serveFeedbackFeeds([ + 'log/following' => [ + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept on Following'])]), + feedbackPage('following', [commitJson(['id' => 'c2', 'body' => 'Late from following'])]), + ], + 'log/latest' => [ + feedbackPage('latest', [commitJson(['id' => 'l1', 'body' => 'Latest landed'])]), + ], + ]); + + $screen = finishRefreshes(Native::visit('/log', platform: 'android'))->assertSee('Kept on Following'); + + // A re-tap refresh of Following, then away to Latest before it answers. + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + tapLogTab($screen, 'latest')->assertSee('Kept on Latest'); + + // Latest's own answer lands first, as the lane can answer out of order: + // the log you're on is no longer being asked for, so its chip goes, though + // Following's ask is still out beneath it. + landOneHeldRefresh($screen, 'log/latest'); + + expect(refreshFeedback($screen))->toBeNull('An ask for a log you are not on kept the chip up after the log you are on was answered.'); + + $screen->assertSee('Latest landed'); + + // Following's answer landing late changes nothing on screen. + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull() + ->and($screen->get('log'))->toBe('latest'); +}); + +it('keeps the chip for a new re-tap refresh when an answer from before the screen went under lands late', function () { + holdRefreshes(); + keep('commits/c1', commitPageJson(commitJson(['id' => 'c1', 'body' => 'A commit']))); + keep('commits/c1/replies', pageJson([], path: 'http://devnull.test/api/v1/commits/c1/replies')); + serveFeedbackFeeds([ + 'log/following' => [ + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept row'])]), + feedbackPage('following', [commitJson(['id' => 'c2', 'body' => 'Answer from before'])]), + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept row'])]), + feedbackPage('following', [commitJson(['id' => 'c3', 'body' => 'New answer'])]), + ], + ]); + + $screen = finishRefreshes(Native::visit('/log'))->assertSee('Kept row'); + + $screen->call('refreshLog'); + + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + // Under a commit and back: the refresh it asked for is forgotten, its + // answer dropped — its payload is kept, to land it late below. + $child = $screen->tap('commit-c1')->assertNavigatedTo('/log/commits/c1')->follow(); + $screen = $child->goBack(); + + expect(refreshFeedback($screen))->toBeNull('The feedback outlived the screen going under another.'); + + // A new re-tap forces a fresh ask of its own. + $screen->call('refreshLog'); + + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + // The old answer lands now, out of its time. What it brought still shows + // — stale-while-revalidate, as ever — but it owns nothing: the chip stays + // up for the ask that is still out, and that ask's marker with it. + landOneHeldRefresh($screen, 'log/following'); + + expect(refreshFeedback($screen))->not->toBeNull('An answer from before the screen went under cleared the chip for the refresh that is still out.'); + + $screen->assertSee('Answer from before'); + + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull(); + + $screen->assertSee('New answer'); +}); + +it('keeps the chip for a new re-tap refresh when a failure from before the screen went under lands late', function () { + holdRefreshes(); + keep('commits/c1', commitPageJson(commitJson(['id' => 'c1', 'body' => 'A commit']))); + keep('commits/c1/replies', pageJson([], path: 'http://devnull.test/api/v1/commits/c1/replies')); + + $offline = false; + $answers = [ + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept row'])]), + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept row'])]), + feedbackPage('following', [commitJson(['id' => 'c3', 'body' => 'New answer'])]), + ]; + serveApi(function (Request $request) use (&$offline, &$answers) { + $path = substr((string) parse_url($request->url(), PHP_URL_PATH), strlen('/api/v1/')); + + if ($path === 'me') { + return Http::response(['data' => userWithCountsJson(), 'unread_notifications_count' => 0]); + } + + if ($offline && $path === 'log/following') { + throw new ConnectionException('cURL error 6: Could not resolve host: devnull.test'); + } + + return $path === 'log/following' && $answers !== [] + ? Http::response(array_shift($answers)) + : Http::response(['message' => "No fake for {$path}."], 404); + }); + + $screen = finishRefreshes(Native::visit('/log'))->assertSee('Kept row'); + + $screen->call('refreshLog'); + + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + $child = $screen->tap('commit-c1')->assertNavigatedTo('/log/commits/c1')->follow(); + $screen = $child->goBack(); + + $screen->call('refreshLog'); + + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + // The old ask fails now, out of its time, offline. It says so the quiet + // way, as any failed refresh does — but it owns nothing: the chip stays + // up for the ask that is still out. + $offline = true; + + landOneHeldRefresh($screen, 'log/following'); + + $offline = false; + + expect(refreshFeedback($screen))->not->toBeNull('A failure from before the screen went under cleared the chip for the refresh that is still out.'); + + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull(); + + $screen->assertSee('New answer'); +}); + +it('keeps the chip across a server error retry, whose late twin from before the screen went under owns nothing', function () { + holdRefreshes(); + keep('commits/c3', commitPageJson(commitJson(['id' => 'c3', 'body' => 'A commit']))); + keep('commits/c3/replies', pageJson([], path: 'http://devnull.test/api/v1/commits/c3/replies')); + + // The mount's ask answers plainly; the next ask of the log meets a server + // error, and every one after answers. + $answers = [ + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept row'])]), + 'error', + feedbackPage('following', [commitJson(['id' => 'c3', 'body' => 'Answered'])]), + 'error', + feedbackPage('following', [commitJson(['id' => 'c3', 'body' => 'Answered'])]), + feedbackPage('following', [commitJson(['id' => 'c3', 'body' => 'Answered'])]), + ]; + serveApi(function (Request $request) use (&$answers) { + $path = substr((string) parse_url($request->url(), PHP_URL_PATH), strlen('/api/v1/')); + + if ($path === 'me') { + return Http::response(['data' => userWithCountsJson(), 'unread_notifications_count' => 0]); + } + + if ($path === 'log/following' && ($answers[0] ?? null) === 'error') { + array_shift($answers); + + return Http::response(['message' => 'Server Error'], 500); + } + + return $path === 'log/following' && $answers !== [] + ? Http::response(array_shift($answers)) + : Http::response(['message' => "No fake for {$path}."], 404); + }); + + $screen = finishRefreshes(Native::visit('/log'))->assertSee('Kept row'); + + // A re-tap whose answer is a server error: the ask is made once more, + // signed afresh, and the chip holds through both, as one refresh. + $screen->call('refreshLog'); + + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + landOneHeldRefresh($screen, 'log/following'); + + expect(refreshFeedback($screen))->not->toBeNull('The chip left while a server error retry was still out.'); + + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull(); + + $screen->assertSee('Answered'); + + // The same error, but from an ask made before the screen went under a + // child: its retry is asked for and answered, and owns nothing. + $screen->call('refreshLog'); + + $child = $screen->tap('commit-c3')->assertNavigatedTo('/log/commits/c3')->follow(); + $screen = $child->goBack(); + + keepOldestHeldRefresh($screen); + + $screen->call('refreshLog'); + + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + + // The old ask's server error, landing late, sends its retry. + landOneHeldRefresh($screen, 'log/following'); + + // The retry lands before the new ask does: it owns nothing, so the chip + // is still up, waiting on the new ask. + landOneHeldRefresh($screen, 'log/following', last: true); + + expect(refreshFeedback($screen))->not->toBeNull('A retry from before the screen went under took the chip from the refresh that is still out.'); + + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull(); + + $screen->assertSee('Answered'); +}); diff --git a/tests/Feature/Screens/LogTabReturnTest.php b/tests/Feature/Screens/LogTabReturnTest.php new file mode 100644 index 0000000..c4d88a6 --- /dev/null +++ b/tests/Feature/Screens/LogTabReturnTest.php @@ -0,0 +1,145 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * Dispatch the active tab's reselect PRESS, not its inactive-tab URL press. + */ +function returnToExistingLog(TestableComponent $screen): TestableComponent +{ + $tab = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'bottom_nav_item' + && ($node['props']['label'] ?? null) === 'Log'); + $callback = 'returnToLog'; + $callbackId = Closure::bind(fn (): ?int => $this->callbacks()->lookup($callback), $screen, TestableComponent::class)(); + + expect($tab['props']['on_reselect'] ?? null)->toBeInt()->toBe($callbackId) + ->and($tab['props']['reselect_scroll_target'] ?? null)->toBeNull(); + + return $screen->fireEvent($callback, TestableComponent::EVENT_PRESS) + ->assertPoppedTo('/log') + ->followNavigation(); +} + +/** + * Each row of the log that's showing, as its ref, or "placeholder" for one + * the phone holds a place for off screen (as the window suite reads them). + * + * @return list + */ +function tabReturnRows(TestableComponent $screen): array +{ + return array_map( + fn (array $row): ?string => $row['type'] === 'list_placeholder' ? 'placeholder' : $row['ref'] ?? null, + showingLog($screen)['children'], + ); +} + +/** + * A log of 300 commits, c0 to c299, one page, as both Following and Latest + * answer it — enough log under your finger that where you were scrolled is + * a fact worth returning to. + * + * @return array}> + */ +function tabReturnFeeds(): array +{ + $page = fn (): array => pageJson(array_map(fn (int $n): array => commitJson([ + 'id' => "c{$n}", + 'body' => "Commit {$n}", + ]), range(0, 299)), 'more'); + + return [ + 'GET /api/v1/log/following' => [$page()], + 'GET /api/v1/log/latest' => [$page()], + ]; +} + +it('returns from a commit under the Log tab to the log it left: the same one, on the feed you chose, where you had scrolled', function () { + holdRefreshes(); + fakeApi([ + ...tabReturnFeeds(), + 'GET /api/v1/commits/c150' => [commitPageJson(commitJson(['id' => 'c150', 'body' => 'Commit 150, opened']))], + 'GET /api/v1/commits/c150/replies' => [pageJson([], path: 'http://devnull.test/api/v1/commits/c150/replies')], + ]); + + $log = finishRefreshes(Native::visit('/log', platform: 'android')); + $log = finishRefreshes(tapLogTab($log, 'latest')); + // A tap on the pager tab you're on takes that log back to its top, so the + // list has a key of its own to come back to. + tapLogTab($log, 'latest'); + // Deep in the log, a few hundred commits down. + $log->call('showRows', 231, 236); + + $instance = spl_object_id($log->instance()); + $listKey = $log->get('listKey'); + $rows = tabReturnRows($log); + + // A commit under the Log tab, as opening or replying to one takes you + // there. The Log tab stays the active one. + $commit = $log->tap('commit-c150')->assertNavigatedTo('/log/commits/c150')->follow(); + + finishRefreshes($commit)->assertTabActive('Log'); + + $returned = returnToExistingLog($commit); + expect(spl_object_id($returned->instance()))->toBe($instance, 'The Log tab landed on a newly mounted log instead of the one that was already under the child.') + ->and($returned->get('log'))->toBe('latest') + ->and($returned->get('listKey'))->toBe($listKey) + ->and(tabReturnRows($returned))->toBe($rows) + ->and($returned->instance()->refreshFeedbackOut())->toBeFalse(); + + $returned->assertSee('Commit 150'); +}); + +it('returns from the reply a post focuses, under its commit, to the same log', function () { + holdRefreshes(); + keep('log/following', pageJson(array_map(fn (int $n): array => commitJson(['id' => "c{$n}", 'body' => "Commit {$n}"]), range(0, 9)), 'more')); + keep('commits/c2', commitPageJson(commitJson(['id' => 'c2', 'body' => 'The focused commit']))); + keep('commits/c2/replies', pageJson([replyJson(['id' => 'r1', 'parent_id' => 'c2', 'body' => 'First reply'])], 'page2')); + fakeApi([ + 'GET /api/v1/log/following' => [pageJson(array_map(fn (int $n): array => commitJson(['id' => "c{$n}", 'body' => "Commit {$n}"]), range(0, 9)), 'more')], + 'POST /api/v1/commits' => [['data' => replyJson(['id' => 'r9', 'parent_id' => 'c2', 'status' => 'pending', 'body' => 'A fresh reply'])], 201], + ]); + + $log = finishRefreshes(Native::visit('/log', platform: 'android')); + + $log->call('showRows', 5, 8); + $instance = spl_object_id($log->instance()); + $listKey = $log->get('listKey'); + $rows = tabReturnRows($log); + + // Open a commit, reply to it: the view focuses the reply, two screens + // down under the Log tab. + $reply = $log->tap('commit-c2')->assertNavigatedTo('/log/commits/c2')->follow(); + $reply = finishRefreshes($reply) + ->input('reply-input', 'A fresh reply') + ->tap('send-reply') + ->assertNavigatedTo('/log/commits/r9') + ->follow() + ->assertSet('id', 'r9') + ->assertSee('A fresh reply'); + + $reply->assertTabActive('Log'); + + $returned = returnToExistingLog($reply); + + expect(spl_object_id($returned->instance()))->toBe($instance, 'The Log tab landed on a newly mounted log instead of the one that was already under the child.') + ->and($returned->get('log'))->toBe('following') + ->and($returned->get('listKey'))->toBe($listKey) + ->and(tabReturnRows($returned))->toBe($rows) + ->and($returned->instance()->refreshFeedbackOut())->toBeFalse(); + + $returned->assertSee('Commit 0'); +}); From 3c2f25e53f496e3b689a329e32c26b56f6ebd6e5 Mon Sep 17 00:00:00 2001 From: Ricardo Valfreixo Date: Fri, 23 Oct 2026 14:16:26 +0200 Subject: [PATCH 4/7] Pin reviewed NativePHP navigation companion refs --- composer.json | 8 ++++---- composer.lock | 38 +++++++++++++++++--------------------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/composer.json b/composer.json index 7ac7e9b..fbb3f3a 100644 --- a/composer.json +++ b/composer.json @@ -13,11 +13,11 @@ "repositories": [ { "type": "vcs", - "url": "https://github.com/NativePHP/mobile-air" + "url": "https://github.com/ricardovfreixo/mobile-air" }, { "type": "vcs", - "url": "https://github.com/NativePHP/mobile-ui" + "url": "https://github.com/ricardovfreixo/mobile-ui" }, { "name": "nativephp-plugins", @@ -31,12 +31,12 @@ "laravel/boost": "^2.0", "laravel/framework": "^13.0", "laravel/tinker": "^3.0", - "nativephp/mobile": "dev-main as 4.6.0", + "nativephp/mobile": "dev-fix/root-tab-reselect as 4.6.0", "nativephp/mobile-browser": "^1.2", "nativephp/mobile-camera": "^1.1", "nativephp/mobile-secure-storage": "^1.1", "nativephp/mobile-share": "^1.1", - "nativephp/mobile-ui": "dev-main as 0.8.0", + "nativephp/mobile-ui": "dev-fix/list-root-tab-scroll-target as 0.8.0", "s2br/nativephp-mobile-splashscreen": "^1.4" }, "require-dev": { diff --git a/composer.lock b/composer.lock index ddc2f31..1cd2cb6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8bcaeeb71989e08fe920116ba3e08bcd", + "content-hash": "abab2d6147983d77671c0ba0837c35f3", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2717,16 +2717,16 @@ }, { "name": "nativephp/mobile", - "version": "dev-main", + "version": "dev-fix/root-tab-reselect", "source": { "type": "git", - "url": "https://github.com/NativePHP/mobile-air.git", - "reference": "2eda16dd1a84fd67b67b413df7e2f751b0a841bb" + "url": "https://github.com/ricardovfreixo/mobile-air.git", + "reference": "7ffc50875aeaeed98d8f0cb4ee1dfc10c9c3a3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/2eda16dd1a84fd67b67b413df7e2f751b0a841bb", - "reference": "2eda16dd1a84fd67b67b413df7e2f751b0a841bb", + "url": "https://api.github.com/repos/ricardovfreixo/mobile-air/zipball/7ffc50875aeaeed98d8f0cb4ee1dfc10c9c3a3c9", + "reference": "7ffc50875aeaeed98d8f0cb4ee1dfc10c9c3a3c9", "shasum": "" }, "require": { @@ -2766,7 +2766,6 @@ "suggest": { "ext-zip": "Required by native:install to validate and extract the downloaded Android PHP binary archives" }, - "default-branch": true, "type": "library", "extra": { "laravel": { @@ -2834,10 +2833,9 @@ "nativephp" ], "support": { - "source": "https://github.com/NativePHP/mobile-air/tree/main", - "issues": "https://github.com/NativePHP/mobile-air/issues" + "source": "https://github.com/ricardovfreixo/mobile-air/tree/fix/root-tab-reselect" }, - "time": "2026-10-22T22:24:28+00:00" + "time": "2026-10-23T12:06:54+00:00" }, { "name": "nativephp/mobile-browser", @@ -3061,16 +3059,16 @@ }, { "name": "nativephp/mobile-ui", - "version": "dev-main", + "version": "dev-fix/list-root-tab-scroll-target", "source": { "type": "git", - "url": "https://github.com/NativePHP/mobile-ui.git", - "reference": "b2a8b2ab5979fb217424210740a5aaca268f295d" + "url": "https://github.com/ricardovfreixo/mobile-ui.git", + "reference": "4d663b55c9e2ade32724524de732c9c515b35c03" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/b2a8b2ab5979fb217424210740a5aaca268f295d", - "reference": "b2a8b2ab5979fb217424210740a5aaca268f295d", + "url": "https://api.github.com/repos/ricardovfreixo/mobile-ui/zipball/4d663b55c9e2ade32724524de732c9c515b35c03", + "reference": "4d663b55c9e2ade32724524de732c9c515b35c03", "shasum": "" }, "require": { @@ -3082,7 +3080,6 @@ "orchestra/testbench": "^10.0", "pestphp/pest": "^3.0" }, - "default-branch": true, "type": "nativephp-plugin", "extra": { "laravel": { @@ -3121,8 +3118,7 @@ ], "description": "Native SwiftUI and Jetpack Composer components for NativePHP", "support": { - "source": "https://github.com/NativePHP/mobile-ui/tree/main", - "issues": "https://github.com/NativePHP/mobile-ui/issues" + "source": "https://github.com/ricardovfreixo/mobile-ui/tree/fix/list-root-tab-scroll-target" }, "funding": [ { @@ -3130,7 +3126,7 @@ "url": "https://nativephp.com/sponsor" } ], - "time": "2026-10-22T19:20:50+00:00" + "time": "2026-10-23T11:50:53+00:00" }, { "name": "nesbot/carbon", @@ -11132,13 +11128,13 @@ "aliases": [ { "package": "nativephp/mobile", - "version": "dev-main", + "version": "dev-fix/root-tab-reselect", "alias": "4.6.0", "alias_normalized": "4.6.0.0" }, { "package": "nativephp/mobile-ui", - "version": "dev-main", + "version": "dev-fix/list-root-tab-scroll-target", "alias": "0.8.0", "alias_normalized": "0.8.0.0" } From c84854a9877299d1dd1fd5fdfb659735fd4083fb Mon Sep 17 00:00:00 2001 From: Ricardo Valfreixo Date: Fri, 23 Oct 2026 14:46:36 +0200 Subject: [PATCH 5/7] Pin tab-row Kotlin compatibility fix --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 1cd2cb6..8e7b4cc 100644 --- a/composer.lock +++ b/composer.lock @@ -3063,12 +3063,12 @@ "source": { "type": "git", "url": "https://github.com/ricardovfreixo/mobile-ui.git", - "reference": "4d663b55c9e2ade32724524de732c9c515b35c03" + "reference": "28753b7ea337349243330c1f531ed61f386a2c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ricardovfreixo/mobile-ui/zipball/4d663b55c9e2ade32724524de732c9c515b35c03", - "reference": "4d663b55c9e2ade32724524de732c9c515b35c03", + "url": "https://api.github.com/repos/ricardovfreixo/mobile-ui/zipball/28753b7ea337349243330c1f531ed61f386a2c6d", + "reference": "28753b7ea337349243330c1f531ed61f386a2c6d", "shasum": "" }, "require": { @@ -3126,7 +3126,7 @@ "url": "https://nativephp.com/sponsor" } ], - "time": "2026-10-23T11:50:53+00:00" + "time": "2026-10-23T12:43:42+00:00" }, { "name": "nesbot/carbon", From b1ceecec66ae59804f0f3b33e29937966b0be3f2 Mon Sep 17 00:00:00 2001 From: Ricardo Valfreixo Date: Fri, 23 Oct 2026 15:23:10 +0200 Subject: [PATCH 6/7] Pin Android startup extraction race fix --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 8e7b4cc..bbcd030 100644 --- a/composer.lock +++ b/composer.lock @@ -2721,12 +2721,12 @@ "source": { "type": "git", "url": "https://github.com/ricardovfreixo/mobile-air.git", - "reference": "7ffc50875aeaeed98d8f0cb4ee1dfc10c9c3a3c9" + "reference": "f06d13ccc80e597b86600e442a2dd2407ac0ab5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ricardovfreixo/mobile-air/zipball/7ffc50875aeaeed98d8f0cb4ee1dfc10c9c3a3c9", - "reference": "7ffc50875aeaeed98d8f0cb4ee1dfc10c9c3a3c9", + "url": "https://api.github.com/repos/ricardovfreixo/mobile-air/zipball/f06d13ccc80e597b86600e442a2dd2407ac0ab5e", + "reference": "f06d13ccc80e597b86600e442a2dd2407ac0ab5e", "shasum": "" }, "require": { @@ -2835,7 +2835,7 @@ "support": { "source": "https://github.com/ricardovfreixo/mobile-air/tree/fix/root-tab-reselect" }, - "time": "2026-10-23T12:06:54+00:00" + "time": "2026-10-23T13:20:45+00:00" }, { "name": "nativephp/mobile-browser", From 3412c44954ea3cd31e9a15741e22e1b812fc1153 Mon Sep 17 00:00:00 2001 From: Ricardo Valfreixo Date: Sun, 25 Oct 2026 10:47:54 +0100 Subject: [PATCH 7/7] Limit Log refresh feedback to tab reselect --- app/NativeComponents/Log.php | 25 ++-- .../native/partials/refreshing.blade.php | 11 +- .../Screens/LogRefreshFeedbackTest.php | 127 +++++++++++++++++- 3 files changed, 143 insertions(+), 20 deletions(-) diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index a5ae69a..d852e52 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -49,9 +49,10 @@ * * It opens on the log as it was last time, then asks devnull again in the * background, and every 30 seconds while it's showing, as the web polls. - * A pull to refresh, and the re-tap of the Log tab that does the same, says - * it's waiting with the chip under the pager's tabs (see - * partials/refreshing), which those quiet asks never do. Commits newer than + * Only a re-tap of the Log tab says it's waiting, with the chip under the + * pager's tabs (see partials/refreshing): a pull to refresh has the list's + * own spinner in your hand, and those quiet asks never say so either. + * Commits newer than * anything showing wait at the top of the list in the new * commits pill (see HoldsNewCommits), which says how many devnull counted * since the newest one showing (?since=). Scrolled down, it pins under the tabs. @@ -366,7 +367,7 @@ private function openAfresh(): void $this->replacedList(); $this->lastVisitAt = $this->lastVisitSeenAt(); $this->showKept(); - $this->open(feedback: true); + $this->open(); } /** @@ -402,19 +403,20 @@ public function isRanked(): bool /** * Pull to refresh, and the re-tap of the Log tab that does the same. You're - * at the top, so anything new goes straight in. It says it's waiting (the - * refresh carries feedback), as a pull says it with the list in your hand. + * at the top, so anything new goes straight in. A pull stays quiet — the + * list's own spinner, in your hand, says it — so only the re-tap asks with + * $feedback, for the chip (see partials/refreshing) the pull never shows. */ - public function load(): void + public function load(bool $feedback = false): void { if ($this->isRanked()) { - $this->rank(feedback: true); + $this->rank(feedback: $feedback); return; } $log = $this->log; - $this->refresh('log/'.$log, [], fn (array $json) => $this->takeFresh($log, $json, showNewer: true), again: true, feedback: true); + $this->refresh('log/'.$log, [], fn (array $json) => $this->takeFresh($log, $json, showNewer: true), again: true, feedback: $feedback); $this->refreshThreads(); $this->refreshUnreadNotifications(); } @@ -423,11 +425,12 @@ public function load(): void * A re-tap of the Log tab at the bottom, which the tab bar only sends once * the log you're on is back at its top (it scrolls the list there itself * when it isn't): pull the log to refresh, as the web's tab does. Keeps - * the log you're on and its list where they are. + * the log you're on and its list where they are. Unlike a pull, it says + * it's waiting (feedback), there being nothing in your hand that does. */ public function refreshLog(): void { - $this->load(); + $this->load(feedback: true); } /** diff --git a/resources/views/native/partials/refreshing.blade.php b/resources/views/native/partials/refreshing.blade.php index 7f91b4b..d5c833c 100644 --- a/resources/views/native/partials/refreshing.blade.php +++ b/resources/views/native/partials/refreshing.blade.php @@ -1,9 +1,10 @@ {{-- - The refresh a re-tap of the Log tab or a pull started, still out: a quiet - chip in the pager's overlay that says the log is being fetched again, to - the eye and to a screen reader, until the answer lands or fails (the - refreshes that owe this are counted in RefreshesInBackground; the quiet - ones the poll and a resume make never show here). + The refresh a re-tap of the Log tab started, still out: a quiet chip in + the pager's overlay that says the log is being fetched again, to the eye + and to a screen reader, until the answer lands or fails (the refreshes + that owe this are counted in RefreshesInBackground; the quiet ones the + poll, a resume, a next page, a pull to refresh and a switch of logs make + never show here — the pull has the list's own spinner). Its slot is always in the overlay, hidden until there's something to show, so the pager and its lists keep their shape when it comes and goes diff --git a/tests/Feature/Screens/LogRefreshFeedbackTest.php b/tests/Feature/Screens/LogRefreshFeedbackTest.php index 7cb5f3f..7a7e916 100644 --- a/tests/Feature/Screens/LogRefreshFeedbackTest.php +++ b/tests/Feature/Screens/LogRefreshFeedbackTest.php @@ -26,7 +26,9 @@ | The refresh says it's waiting: a chip under the pager's tabs (see | partials/refreshing) from the moment of the press until the answer lands | or fails, without disturbing the log, its list or where you are. The -| quiet asks — the poll, a resume, the next page — never say so. +| quiet asks — the poll, a resume, the next page, a pull to refresh, a +| switch of logs — never say so: the pull has the list's own spinner in +| your hand, and a switch's opening says itself. | | These are the PHP half of the story. What they cannot reproduce is the | device's own: whether Android's tab bar still sends the reselect once the @@ -251,6 +253,118 @@ function keepOldestHeldRefresh(TestableComponent $screen): void $screen->assertSee('Retapped in')->assertSee('Kept row'); })->with(Log::Logs); +it('keeps a switch of logs quiet, its opening saying itself and never the re-tap chip', function (string $log) { + holdRefreshes(); + keep("log/{$log}", feedbackPage($log, [commitJson(['id' => 'k1', 'body' => 'Kept row'])])); + serveFeedbackFeeds([ + 'log/following' => [ + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept on Following'])]), + ], + "log/{$log}" => [ + feedbackPage($log, [commitJson(['id' => 'n1', 'body' => 'Switched in']), commitJson(['id' => 'k1', 'body' => 'Kept row'])]), + ], + ]); + + $screen = finishRefreshes(Native::visit('/log', platform: 'android'))->assertSee('Kept on Following'); + + $asks = feedbackAsks($log); + + // The pager settling on another log: what was kept for it opens at once + // and devnull is asked again — quietly, the kept rows saying the opening + // as the re-tap chip never may. + $screen = tapLogTab($screen, $log); + + expect(refreshFeedback($screen))->toBeNull('A switch of logs put up the re-tap refresh chip.') + ->and($screen->get('log'))->toBe($log); + + $screen->assertSee('Kept row'); + + finishRefreshes($screen); + + // The ask went out and landed quietly. Trending is ranked afresh, so its + // rows go straight in; a feed's newer rows wait for the pill, as ever. + expect(refreshFeedback($screen))->toBeNull() + ->and(feedbackAsks($log))->toBe($asks + 1) + ->and($screen->get('log'))->toBe($log); + + $screen->assertSee('Kept row'); +})->with(['latest', 'trending']); + +it('keeps a pull to refresh quiet, the list\'s own spinner saying it instead', function (string $log) { + holdRefreshes(); + serveFeedbackFeeds([ + 'log/following' => [ + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept row'])]), + feedbackPage('following', [commitJson(['id' => 'c2', 'body' => 'Pulled in']), commitJson(['id' => 'c1', 'body' => 'Kept row'])]), + ], + 'log/latest' => [ + feedbackPage('latest', [commitJson(['id' => 'l1', 'body' => 'Kept row'])]), + feedbackPage('latest', [commitJson(['id' => 'l2', 'body' => 'Pulled in']), commitJson(['id' => 'l1', 'body' => 'Kept row'])]), + ], + 'log/trending' => [ + feedbackPage('trending', [commitJson(['id' => 't1', 'body' => 'Kept row'])]), + feedbackPage('trending', [commitJson(['id' => 't2', 'body' => 'Pulled in']), commitJson(['id' => 't1', 'body' => 'Kept row'])]), + ], + ]); + + $screen = finishRefreshes(Native::visit('/log', platform: 'android')); + $screen = finishRefreshes(tapLogTab($screen, $log))->assertSee('Kept row'); + + $asks = feedbackAsks($log); + + // The pull, as the list sends it with its own spinner in your hand: the + // ask still goes out, but the chip is the re-tap's alone. + $screen->call('load'); + + expect(refreshFeedback($screen))->toBeNull('A pull to refresh put up the re-tap refresh chip.'); + + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull() + ->and(feedbackAsks($log))->toBe($asks + 1); + + $screen->assertSee('Pulled in')->assertSee('Kept row'); +})->with(Log::Logs); + +it('keeps the asks quiet on iOS, where the tab bar never sends a re-tap at all', function () { + holdRefreshes(); + keep('log/latest', feedbackPage('latest', [commitJson(['id' => 'l1', 'body' => 'Kept on Latest'])])); + serveFeedbackFeeds([ + 'log/following' => [ + feedbackPage('following', [commitJson(['id' => 'c1', 'body' => 'Kept on Following'])]), + ], + 'log/latest' => [ + feedbackPage('latest', [commitJson(['id' => 'l2', 'body' => 'Switched in']), commitJson(['id' => 'l1', 'body' => 'Kept on Latest'])]), + feedbackPage('latest', [commitJson(['id' => 'l3', 'body' => 'Pulled in']), commitJson(['id' => 'l2', 'body' => 'Switched in'])]), + ], + ]); + + $screen = finishRefreshes(Native::visit('/log', platform: 'ios'))->assertSee('Kept on Following'); + + // A switch and a pull, the same asks as Android's: on the platform with + // no reselect to carry the chip, nothing may put it up. + $screen = tapLogTab($screen, 'latest'); + + expect(refreshFeedback($screen))->toBeNull('A switch of logs put up the re-tap refresh chip.'); + + finishRefreshes($screen); + + // The feed's newer rows wait for the pill, as ever — quietly either way. + expect(refreshFeedback($screen))->toBeNull(); + + $screen->assertSee('Kept on Latest'); + + $screen->call('load'); + + expect(refreshFeedback($screen))->toBeNull('A pull to refresh put up the re-tap refresh chip.'); + + finishRefreshes($screen); + + expect(refreshFeedback($screen))->toBeNull(); + + $screen->assertSee('Pulled in'); +}); + it('keeps the log, its list and its rows where they are while the re-tap refresh is answered, and lays the fresh rows in place', function () { holdRefreshes(); serveFeedbackFeeds([ @@ -364,7 +478,8 @@ function keepOldestHeldRefresh(TestableComponent $screen): void feedbackPage('following', [commitJson(['id' => 'c2', 'body' => 'Late from following'])]), ], 'log/latest' => [ - feedbackPage('latest', [commitJson(['id' => 'l1', 'body' => 'Latest landed'])]), + feedbackPage('latest', [commitJson(['id' => 'l1', 'body' => 'Kept on Latest'])]), + feedbackPage('latest', [commitJson(['id' => 'l2', 'body' => 'Latest landed']), commitJson(['id' => 'l1', 'body' => 'Kept on Latest'])]), ], ]); @@ -373,10 +488,14 @@ function keepOldestHeldRefresh(TestableComponent $screen): void // A re-tap refresh of Following, then away to Latest before it answers. $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); - expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); - + // The switch stays quiet; a re-tap on Latest owns its own feedback while + // Following's earlier answer is still out. tapLogTab($screen, 'latest')->assertSee('Kept on Latest'); + $screen->fireEvent('refreshLog', TestableComponent::EVENT_PRESS); + + expect(refreshFeedback($screen))->not->toBeNull('Nothing on the Log says a re-tap refresh is under way.'); + // Following's answer lands first, stale now that you've left: an answer // for a log nobody is on must not settle the refresh that's showing. landOneHeldRefresh($screen);