diff --git a/README.md b/README.md index 39e008b..79bfe9a 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,15 @@ composer setup For now the app tracks unreleased branches, which Composer pulls straight from GitHub: -- `nativephp/mobile` on `feat/custom-icons`, which adds custom SVG icons ([NativePHP/mobile-air#544](https://github.com/NativePHP/mobile-air/pull/544)). -- `nativephp/mobile-ui` on `combined/dev-null-app`, a branch just for this app. It's `feat/custom-icons` ([NativePHP/mobile-ui#132](https://github.com/NativePHP/mobile-ui/pull/132)) plus the fix that lets a swiping pager's lists run under the tab bar ([NativePHP/mobile-ui#134](https://github.com/NativePHP/mobile-ui/pull/134)). +- `nativephp/mobile` on `combined/dev-null-app`, a branch just for this app. It's `feat/custom-icons`, which adds custom SVG icons ([NativePHP/mobile-air#544](https://github.com/NativePHP/mobile-air/pull/544)), plus the fix that stops the keyboard-dismiss tap swallowing taps on UIKit controls such as `native:toggle` and the Sign in with Apple button ([NativePHP/mobile-air#545](https://github.com/NativePHP/mobile-air/pull/545)). +- `nativephp/mobile-ui` on `combined/dev-null-app`, a branch just for this app. It's `feat/custom-icons` ([NativePHP/mobile-ui#132](https://github.com/NativePHP/mobile-ui/pull/132)) plus: + - opening the iOS pager at its start page ([#131](https://github.com/NativePHP/mobile-ui/pull/131)) and running a swiping pager's lists under the tab bar ([#134](https://github.com/NativePHP/mobile-ui/pull/134)), + - a pager that reports its page once the swipe settles ([#135](https://github.com/NativePHP/mobile-ui/pull/135)) and draws its tabs natively ([#136](https://github.com/NativePHP/mobile-ui/pull/136)), + - button variants named after a theme colour ([#137](https://github.com/NativePHP/mobile-ui/pull/137)), + - a sticky header for lists ([#138](https://github.com/NativePHP/mobile-ui/pull/138)), + - a pager header that scrolls with the page showing ([#139](https://github.com/NativePHP/mobile-ui/pull/139)). -Once those are released, the app goes back to tagged versions and the combined branch can be deleted. +Once those are released, the app goes back to tagged versions and the combined branches can be deleted. ### Without plugin credentials @@ -74,4 +79,4 @@ php artisan test ## License -MIT. Geist and Geist Mono are under the SIL Open Font License, see `resources/fonts/OFL.txt`. The icons in `resources/icons` are from [Lucide](https://lucide.dev), as on the web, under the ISC License, see `resources/icons/LICENSE`. +MIT. Geist and Geist Mono are under the SIL Open Font License, see `resources/fonts/OFL.txt`. The icons in `resources/icons` are from [Lucide](https://lucide.dev), as on the web, under the ISC License, see `resources/icons/LICENSE`. The pastel de nata in `public/images` is drawn from the web's own `public/pastel-de-nata.svg`. diff --git a/app/Devnull/Account.php b/app/Devnull/Account.php index b02bb96..cf5b237 100644 --- a/app/Devnull/Account.php +++ b/app/Devnull/Account.php @@ -2,6 +2,7 @@ namespace App\Devnull; +use App\Devnull\Data\Pasteis; use App\Devnull\Data\User; use Illuminate\Support\Facades\Cache; @@ -16,6 +17,8 @@ final class Account private const string UnreadKey = 'devnull.unread-notifications'; + private const string PasteisKey = 'devnull.pasteis'; + public function user(): ?User { $attributes = Cache::get(self::CacheKey); @@ -44,7 +47,32 @@ public function rememberUnreadNotifications(int $count): void } /** - * Signs this phone out: who it was, the badge, and every answer kept for a quick start. + * Your pastéis for today while pastéis mode is on, as devnull last said or + * as this phone has counted them since, or null while the mode is off. + */ + public function pasteis(): ?Pasteis + { + $kept = Cache::get(self::PasteisKey); + + return is_array($kept) ? Pasteis::fromKept($kept) : null; + } + + /** + * Remembers your pastéis, or with null that pastéis mode is off. + */ + public function rememberPasteis(?Pasteis $pasteis): void + { + if ($pasteis === null) { + Cache::forget(self::PasteisKey); + + return; + } + + Cache::forever(self::PasteisKey, $pasteis->toArray()); + } + + /** + * Signs this phone out: who it was, the badge, its pastéis, and every answer kept for a quick start. */ public function forget(): void { @@ -52,5 +80,6 @@ public function forget(): void Cache::forget(self::CacheKey); Cache::forget(self::UnreadKey); + Cache::forget(self::PasteisKey); } } diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index d9f0566..19313d0 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -61,8 +61,21 @@ public const string BlockedPath = 'me/blocked'; + /** + * Where pastéis mode is switched on and off for the signed-in account. + */ + public const string PasteisModePath = 'me/pasteis-mode'; + public function __construct(private DeviceKey $deviceKey, private string $baseUrl, private ResponseCache $cache) {} + /** + * Where a pastel is given to a commit, one per request. + */ + public static function pastelPath(string $id): string + { + return 'commits/'.rawurlencode($id).'/pasteis'; + } + /** * Trades the code dev-null.sh sent back, with the PKCE verifier only this * phone knows, for the user it approved this phone for. It's signed like diff --git a/app/Devnull/Data/Commit.php b/app/Devnull/Data/Commit.php index 60c850f..ece9692 100644 --- a/app/Devnull/Data/Commit.php +++ b/app/Devnull/Data/Commit.php @@ -70,6 +70,7 @@ public function __construct( public ?Commit $quotedCommit = null, public ?array $previewContent = null, public ?ReplyPreview $replyPreview = null, + public int $pasteisCount = 0, ) {} /** @@ -128,6 +129,7 @@ public static function fromArray(array $data): self quotedCommit: is_array($data['quoted_commit'] ?? null) ? self::fromArray($data['quoted_commit']) : null, previewContent: $previewSegments === null ? null : CommitBody::blocks($body, $previewSegments, $mentions), replyPreview: is_array($data['reply_preview'] ?? null) ? ReplyPreview::fromArray($data['reply_preview']) : null, + pasteisCount: max(0, (int) ($data['pasteis_count'] ?? 0)), ); } @@ -141,14 +143,22 @@ public function withStar(bool $starred): self return new self(...[...get_object_vars($this), 'starred' => $starred, 'starsCount' => max(0, $this->starsCount + $change)]); } + /** + * This commit with $change more pastéis than it has, or fewer for a negative change. + */ + public function withMorePasteis(int $change): self + { + return new self(...[...get_object_vars($this), 'pasteisCount' => max(0, $this->pasteisCount + $change)]); + } + public function withSaved(bool $isSaved): self { return new self(...[...get_object_vars($this), 'isSaved' => $isSaved]); } /** - * This commit with the star and save state of a fresher copy of it, keeping - * everything else, such as the parent a list loaded with it. + * This commit with the star, save and pastéis state of a fresher copy of + * it, keeping everything else, such as the parent a list loaded with it. */ public function withReactionsFrom(self $fresh): self { @@ -157,6 +167,7 @@ public function withReactionsFrom(self $fresh): self 'starred' => $fresh->starred, 'starsCount' => $fresh->starsCount, 'isSaved' => $fresh->isSaved ?? $this->isSaved, + 'pasteisCount' => $fresh->pasteisCount, ]); } diff --git a/app/Devnull/Data/CurrentUser.php b/app/Devnull/Data/CurrentUser.php index 8c792dd..62eb9ca 100644 --- a/app/Devnull/Data/CurrentUser.php +++ b/app/Devnull/Data/CurrentUser.php @@ -12,7 +12,8 @@ * It also has what only you see in the web's profile settings: the lane you * chose (null for Automatic) and the one Automatic gives you, your GitHub * Sponsors page, and when your profile was last synced from GitHub (null when - * it only was when you signed in). + * it only was when you signed in). It has whether pastéis mode is on, how + * many pastéis you can still give today, and how many others have given you. */ final readonly class CurrentUser { @@ -24,6 +25,9 @@ public function __construct( public int $automaticLane = 0, public ?string $sponsorsUrl = null, public ?CarbonImmutable $githubSyncedAt = null, + public bool $pasteisMode = false, + public Pasteis $pasteisToday = new Pasteis, + public int $pasteisReceived = 0, ) {} /** @@ -48,6 +52,9 @@ public static function fromArray(array $json): self automaticLane: $automaticLane, sponsorsUrl: is_string($sponsorsUrl) && preg_match('#^https://#i', $sponsorsUrl) === 1 ? $sponsorsUrl : null, githubSyncedAt: Commit::date($data['github_synced_at'] ?? null), + pasteisMode: ($data['pasteis_mode'] ?? false) === true, + pasteisToday: Pasteis::fromKept(['remaining' => $data['pasteis_remaining'] ?? null, 'resets_at' => $data['pasteis_resets_at'] ?? null]), + pasteisReceived: is_int($data['pasteis_received'] ?? null) ? max(0, $data['pasteis_received']) : 0, ); } @@ -59,6 +66,19 @@ public static function remembered(User $user): self return self::fromArray(['data' => $user->toArray()]); } + /** + * Your pastéis for today, or null while pastéis mode is off. + */ + public function pasteis(): ?Pasteis + { + return $this->pasteisMode ? $this->pasteisToday : null; + } + + public function withPasteisMode(bool $pasteisMode): self + { + return new self(...[...get_object_vars($this), 'pasteisMode' => $pasteisMode]); + } + /** * Your GitHub Sponsors page as the web prints it, without the scheme. */ diff --git a/app/Devnull/Data/Pasteis.php b/app/Devnull/Data/Pasteis.php new file mode 100644 index 0000000..1066c40 --- /dev/null +++ b/app/Devnull/Data/Pasteis.php @@ -0,0 +1,123 @@ + $kept + */ + public static function fromKept(array $kept): self + { + $remaining = $kept['remaining'] ?? null; + + return new self(is_int($remaining) ? max(0, min(self::Daily, $remaining)) : self::Daily, Commit::date($kept['resets_at'] ?? null)); + } + + /** + * How many you can give now: all ten once the day they were counted for is over. + */ + public function left(): int + { + return $this->today()->remaining; + } + + /** + * After one more goes, before devnull has said so. + */ + public function afterGiving(): self + { + $today = $this->today(); + + return new self(max(0, $today->remaining - 1), $today->resetsAt); + } + + /** + * After a pastel that didn't go is handed back. + */ + public function afterTakingBack(): self + { + $today = $this->today(); + + return new self(min(self::Daily, $today->remaining + 1), $today->resetsAt); + } + + /** + * None left until the reset, as devnull said when it turned one down. + */ + public function exhausted(): self + { + return new self(0, $this->today()->resetsAt); + } + + /** + * What devnull said is left after a pastel went. With others still on + * their way, the fewer of the two is the truer, unless devnull has moved + * on to another day. + */ + public function settledWith(int $remaining, ?CarbonImmutable $resetsAt): self + { + $today = $this->today(); + $sameDay = $resetsAt === null || $resetsAt->equalTo($today->resetsAt); + + return new self(max(0, $sameDay ? min($today->remaining, $remaining) : $remaining), $resetsAt ?? $today->resetsAt); + } + + /** + * @return array{remaining: int, resets_at: ?string} + */ + public function toArray(): array + { + return ['remaining' => $this->remaining, 'resets_at' => $this->resetsAt?->toIso8601ZuluString()]; + } + + /** + * These pastéis as of now: a fresh ten once the reset has passed. + */ + private function today(): self + { + if ($this->resetsAt === null) { + return new self($this->remaining, self::nextReset()); + } + + return CarbonImmutable::now()->greaterThanOrEqualTo($this->resetsAt) ? new self(self::Daily, self::nextReset()) : $this; + } + + /** + * The next 00:00 UTC, when the web gives everyone ten again. + */ + private static function nextReset(): CarbonImmutable + { + return CarbonImmutable::now('UTC')->addDay()->startOfDay(); + } +} diff --git a/app/Devnull/Data/Profile.php b/app/Devnull/Data/Profile.php index d851eef..946ccde 100644 --- a/app/Devnull/Data/Profile.php +++ b/app/Devnull/Data/Profile.php @@ -16,12 +16,14 @@ public function __construct( public Activity $activity = new Activity, public bool $isMuting = false, public ?Achievements $achievements = null, + public ?int $pasteisReceived = null, ) {} /** * Reads a `GET /users/{username}` response: the user and counts in `data`, * and alongside it whether you follow or mute them, their pinned commit, - * their streaks, their activity and their achievements. + * their streaks, their activity, their achievements and how many pastéis + * they have been given (null when devnull doesn't say). * * @param array $json */ @@ -30,6 +32,7 @@ public static function fromArray(array $json): self $data = is_array($json['data'] ?? null) ? $json['data'] : $json; $counts = is_array($data['counts'] ?? null) ? $data['counts'] : []; $pinned = $json['pinned_commit'] ?? $data['pinned_commit'] ?? null; + $received = $json['pasteis_received'] ?? $data['pasteis_received'] ?? null; return new self( user: User::fromArray(is_array($data['user'] ?? null) ? $data['user'] : $data), @@ -43,6 +46,7 @@ public static function fromArray(array $json): self activity: Activity::fromArray($json['activity'] ?? $data['activity'] ?? null), isMuting: ($json['is_muting'] ?? $data['is_muting'] ?? false) === true, achievements: Achievements::fromArray($json['achievements'] ?? $data['achievements'] ?? null), + pasteisReceived: is_int($received) ? max(0, $received) : null, ); } diff --git a/app/Devnull/ErrorCode.php b/app/Devnull/ErrorCode.php index 805c709..6a05ac1 100644 --- a/app/Devnull/ErrorCode.php +++ b/app/Devnull/ErrorCode.php @@ -2,6 +2,8 @@ namespace App\Devnull; +use App\Devnull\Data\Pasteis; + /** * The `code` devnull sends next to an error's message, for the errors the app * has to tell apart. A code never changes once released, where the wording @@ -22,6 +24,7 @@ enum ErrorCode: string case OwnCommit = 'own_commit'; case CommitUnavailable = 'commit_unavailable'; case GitHubSyncFailed = 'github_sync_failed'; + case PasteisExhausted = 'pasteis_exhausted'; /** * What the app says for it, or null where devnull's own message says it @@ -42,6 +45,7 @@ public function message(): ?string self::UserUnavailable => "fatal: this profile isn't available.", self::CommitUnavailable => "fatal: this commit isn't available.", self::GitHubSyncFailed => 'Could not sync from GitHub. Try again in a minute.', + self::PasteisExhausted => Pasteis::Exhausted, self::RateLimited, self::OwnCommit => null, }; } diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index 6e8ac8e..63702ef 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -231,14 +231,14 @@ public function flush(): void // ── The app's own writes ────────────────────── /** - * Your star or bookmark changed: every copy of the commit takes the counts devnull sent back. + * Your star, bookmark or pastel changed: every copy of the commit takes the counts devnull sent back. * * @param array $commit */ public function commitReacted(array $commit): void { $id = (string) ($commit['id'] ?? ''); - $reactions = array_intersect_key($commit, array_flip(['starred', 'stars_count', 'is_saved'])); + $reactions = array_intersect_key($commit, array_flip(['starred', 'stars_count', 'is_saved', 'pasteis_count'])); if ($id === '' || $reactions === []) { return; @@ -285,6 +285,32 @@ public function unsavedIds(): array return is_array($ids) ? array_values(array_filter($ids, is_string(...))) : []; } + /** + * You gave a pastel, and devnull answered with the commit, how many you + * have left today, and how many its author has been given in all. Every + * copy of the commit takes its count, their profile its total, and the + * kept `me` what you have left. + * + * @param array $json + */ + public function pastelGiven(array $json): void + { + $commit = is_array($json['data'] ?? null) ? $json['data'] : []; + $author = is_array($commit['author'] ?? null) ? $commit['author'] : []; + + $this->commitReacted($commit); + + if (is_string($author['username'] ?? null) && is_int($json['author_pasteis_received'] ?? null)) { + $this->patch('users/'.rawurlencode($author['username']), [], fn (array $kept): array => [...$kept, 'pasteis_received' => $json['author_pasteis_received']]); + } + + if (is_int($json['pasteis_remaining'] ?? null)) { + $this->patch('me', [], fn (array $kept): array => is_array($kept['data'] ?? null) + ? [...$kept, 'data' => [...$kept['data'], 'pasteis_remaining' => $json['pasteis_remaining'], 'pasteis_resets_at' => $json['pasteis_resets_at'] ?? null]] + : $kept); + } + } + /** * You committed. A commit goes on top of your logs and your profile; a reply * goes under its parent and counts there, and on top of your replies. diff --git a/app/NativeComponents/CommitDetail.php b/app/NativeComponents/CommitDetail.php index 3bb817a..e71b610 100644 --- a/app/NativeComponents/CommitDetail.php +++ b/app/NativeComponents/CommitDetail.php @@ -2,6 +2,7 @@ namespace App\NativeComponents; +use App\Devnull\Account; use App\Devnull\Data\Commit; use App\Devnull\Data\CommitThread; use App\Devnull\Data\Page; @@ -340,6 +341,7 @@ public function render(): View return view('native.commit-detail', [ 'myColour' => $this->myColour(), 'viewer' => $this->me(), + 'pasteis' => app(Account::class)->pasteis(), 'rows' => $this->commit === null ? [] : Timeline::thread( [...($this->earlierShown ? $this->ancestors : []), $this->commit, ...$this->series], laneAbove: $earlier === [] ? null : end($earlier)->lane(), diff --git a/app/NativeComponents/Concerns/RefreshesInBackground.php b/app/NativeComponents/Concerns/RefreshesInBackground.php index cfe645f..e9cedee 100644 --- a/app/NativeComponents/Concerns/RefreshesInBackground.php +++ b/app/NativeComponents/Concerns/RefreshesInBackground.php @@ -138,6 +138,7 @@ protected function refreshUnreadNotifications(): void if ($current->user->username !== '') { app(Account::class)->remember($current->user); + app(Account::class)->rememberPasteis($current->pasteis()); } app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount); diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index e40efbc..e506842 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -2,9 +2,14 @@ namespace App\NativeComponents\Concerns; +use App\Devnull\Account; use App\Devnull\Client; use App\Devnull\Data\Commit; +use App\Devnull\Data\Pasteis; use App\Devnull\Data\User; +use App\Devnull\ErrorCode; +use App\Devnull\Exceptions\ApiException; +use App\Devnull\ResponseCache; use Closure; use Native\Mobile\Attributes\On; use Native\Mobile\Events\Alert\ButtonPressed; @@ -13,11 +18,12 @@ use Native\Mobile\Facades\Share; /** - * The actions under a commit: the star, Quote, Reply, and the More menu's - * Save commit, Share link, Report commit and Delete commit (Quote and Report - * open a sheet, see OpensCommitSheet), plus opening its link preview or images - * in the in-app browser, and "Show more" on a long one. Taps name the commit - * rather than a URL, so a URL is never rebuilt from an attribute. + * The actions under a commit: the star, the pastel in pastéis mode, Quote, + * Reply, and the More menu's Save commit, Share link, Report commit and Delete + * commit (Quote and Report open a sheet, see OpensCommitSheet), plus opening + * its link preview or images in the in-app browser, and "Show more" on a long + * one. Taps name the commit rather than a URL, so a URL is never rebuilt from + * an attribute. * * A commit can be tapped where a row nests it too: the commit a row quotes, * and the replies a feed shows under a commit. @@ -25,6 +31,7 @@ trait ShowsCommits { use OpensCommitSheet; + use WritesInBackground; public const string DeleteAlert = 'delete-commit'; @@ -118,6 +125,37 @@ public function toggleStar(string $id): void : $devnull->star($id)); } + /** + * Gives the commit a pastel, as the web's pastel button does in pastéis + * mode: one per tap, from the ten you get each day, on someone else's + * published commit. Its count goes up and your allowance down at once, + * and the pastel goes from the background, so you can keep tapping. When + * devnull turns one down, it comes back, and a toast says why in the web's + * words. With none left, the toast says so without asking devnull. + */ + public function givePastel(string $id): void + { + $commit = $this->shownCommit($id); + $pasteis = app(Account::class)->pasteis(); + + if ($commit === null || $pasteis === null || ! $this->canStar($commit)) { + return; + } + + if ($pasteis->left() === 0) { + Dialog::toast(Pasteis::Exhausted); + + return; + } + + app(Account::class)->rememberPasteis($pasteis->afterGiving()); + $this->replaceShownCommit($commit->withMorePasteis(1)); + + $this->writeInBackground('POST', Client::pastelPath($id), null, fn (array $json) => $this->pastelGiven($id, $json), function (string $message, ?ApiException $exception = null) use ($id): void { + $this->pastelTurnedDown($id, $exception); + }); + } + /** * Saves or unsaves the commit privately, the same way. */ @@ -202,6 +240,38 @@ protected function answerDeleteAlert(string $label, ?string $id): void } } + /** + * devnull took the pastel: the commit shows at least the count it sent, + * since others may still be on their way, and so does every kept copy. + * + * @param array $json + */ + private function pastelGiven(string $id, array $json): void + { + $fresh = Commit::fromArray(is_array($json['data'] ?? null) ? $json['data'] : []); + $shown = $this->shownCommit($id); + + if ($shown !== null && $fresh->pasteisCount > $shown->pasteisCount) { + $this->replaceShownCommit($shown->withMorePasteis($fresh->pasteisCount - $shown->pasteisCount)); + } + + app(ResponseCache::class)->pastelGiven($json); + + $pasteis = app(Account::class)->pasteis(); + $remaining = $json['pasteis_remaining'] ?? null; + + if ($pasteis === null || ! is_int($remaining)) { + return; + } + + $pasteis = $pasteis->settledWith($remaining, Commit::date($json['pasteis_resets_at'] ?? null)); + app(Account::class)->rememberPasteis($pasteis); + + if ($pasteis->left() === 0) { + Dialog::toast(Pasteis::Exhausted); + } + } + /** * Deletes your commit on devnull, which takes it out of every list kept on * this phone, then the screen lets it go (see showDeleted()). @@ -235,6 +305,33 @@ protected function showDeleted(string $id): void $this->onResume(); } + /** + * devnull turned the pastel down, so it comes back off the commit. With + * none left today, you have none until the reset. A rate limit says so in + * devnull's words; anything else in the web's. + */ + private function pastelTurnedDown(string $id, ?ApiException $exception): void + { + $shown = $this->shownCommit($id); + + if ($shown !== null) { + $this->replaceShownCommit($shown->withMorePasteis(-1)); + } + + $exhausted = $exception?->is(ErrorCode::PasteisExhausted) === true; + $pasteis = app(Account::class)->pasteis(); + + if ($pasteis !== null) { + app(Account::class)->rememberPasteis($exhausted ? $pasteis->exhausted() : $pasteis->afterTakingBack()); + } + + Dialog::toast(match (true) { + $exhausted => Pasteis::Exhausted, + $exception?->is(ErrorCode::RateLimited) === true => $exception->firstError(), + default => Pasteis::CouldNotGive, + }); + } + private function canReactTo(Commit $commit): bool { return ! $commit->isDeleted() && ($commit->isApproved() || $commit->author->id === $this->me()?->id); diff --git a/app/NativeComponents/Concerns/WritesInBackground.php b/app/NativeComponents/Concerns/WritesInBackground.php index d2d3c7f..625cd13 100644 --- a/app/NativeComponents/Concerns/WritesInBackground.php +++ b/app/NativeComponents/Concerns/WritesInBackground.php @@ -17,16 +17,17 @@ * paints its spinner first (see FetchInBackground). * * A 401 signs the phone out, as any request does. Anything else that goes wrong - * reaches $failed worded for the screen, by its code when the app knows it. - * NativePHP drops the answer if the screen is no longer on top, and the next - * `GET /me` picks up what changed. + * reaches $failed worded for the screen, by its code when the app knows it, + * with the error itself for a screen that tells codes apart. NativePHP drops + * the answer if the screen is no longer on top, and the next `GET /me` picks + * up what changed. */ trait WritesInBackground { /** * @param array|null $payload * @param Closure(array): void $done - * @param Closure(string): void $failed + * @param Closure(string, ?ApiException): void $failed */ protected function writeInBackground(string $method, string $path, ?array $payload, Closure $done, Closure $failed): void { @@ -60,7 +61,7 @@ protected function writeInBackground(string $method, string $path, ?array $paylo return; } - $failed($exception->firstError()); + $failed($exception->firstError(), $exception); }) ->failed(fn (Throwable $exception) => $failed('fatal: '.$exception->getMessage())); } diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index c1b413a..da42a12 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -2,6 +2,7 @@ namespace App\NativeComponents; +use App\Devnull\Account; use App\Devnull\Data\Commit; use App\Devnull\Data\Page; use App\Devnull\Data\User; @@ -719,6 +720,7 @@ public function render(): View return view('native.log', [ 'myColour' => $this->myColour(), 'viewer' => $viewer, + 'pasteis' => app(Account::class)->pasteis(), 'composerOpen' => $this->composing || $this->body !== '', 'remaining' => $this->remaining(), 'canCommit' => $this->canCommit(), diff --git a/app/NativeComponents/Onboarding.php b/app/NativeComponents/Onboarding.php index 809039f..b799331 100644 --- a/app/NativeComponents/Onboarding.php +++ b/app/NativeComponents/Onboarding.php @@ -252,6 +252,7 @@ private function signInWithExistingKey(): void app(Account::class)->remember($current->user); app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount); + app(Account::class)->rememberPasteis($current->pasteis()); $this->replace('/log'); } diff --git a/app/NativeComponents/Profile.php b/app/NativeComponents/Profile.php index 6352d59..ad0cd17 100644 --- a/app/NativeComponents/Profile.php +++ b/app/NativeComponents/Profile.php @@ -2,6 +2,7 @@ namespace App\NativeComponents; +use App\Devnull\Account; use App\Devnull\Data\Commit; use App\Devnull\Data\Page; use App\Devnull\Data\Profile as ProfileData; @@ -723,6 +724,7 @@ public function render(): View 'pages' => $pages, 'pageIndex' => array_search($this->tab, self::Tabs, true), 'tabs' => array_values(self::Labels), + 'pasteis' => app(Account::class)->pasteis(), ]); } diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php index b144119..b56cc49 100644 --- a/app/NativeComponents/ThisDevice.php +++ b/app/NativeComponents/ThisDevice.php @@ -26,8 +26,8 @@ /** * Your account, reached from your own profile, laid out like the web's profile * settings: your profile as devnull mirrors it from GitHub, with a sync, your - * colour, the people you mute and block, then this phone's fingerprint, - * signing out and deleting the account. + * colour, pastéis mode, the people you mute and block, then this phone's + * fingerprint, signing out and deleting the account. * Where the app talks to only shows when it isn't dev-null.sh. * * It opens on you as devnull last said (or as this phone remembers you) and @@ -81,6 +81,15 @@ class ThisDevice extends NativeComponent public ?string $laneError = null; + public bool $savingPasteisMode = false; + + public ?string $pasteisModeError = null; + + /** + * What the web says when the switch couldn't be saved. + */ + public const string PasteisModeFailed = 'Could not save pastéis mode. Please try again.'; + /** * Whether devnull has an Apple ID linked to you, or null until it says. */ @@ -201,6 +210,53 @@ public function openSafety(): void $this->navigate('/profile/safety'); } + /** + * Switches pastéis mode, as the web's switch does. The switch, and every + * screen's commits, take it at once, and devnull is told from the + * background. Flipped again before devnull answers, it's saved again once + * it has, so the last flip wins. When devnull says no, the switch goes + * back to what devnull has and the section says so in the web's words. + */ + public function setPasteisMode(bool $enabled): void + { + if ($this->current === null || $this->current->pasteisMode === $enabled) { + return; + } + + $this->pasteisModeError = null; + $this->current = $this->current->withPasteisMode($enabled); + app(Account::class)->rememberPasteis($this->current->pasteis()); + + if (! $this->savingPasteisMode) { + $this->savePasteisMode($enabled); + } + } + + private function savePasteisMode(bool $enabled): void + { + $this->savingPasteisMode = true; + + $this->writeInBackground('PUT', Client::PasteisModePath, ['enabled' => $enabled], function (array $json) use ($enabled): void { + $this->savingPasteisMode = false; + + if ($this->current !== null && $this->current->pasteisMode !== $enabled) { + $this->savePasteisMode($this->current->pasteisMode); + + return; + } + + $this->takeMe($json); + }, function () use ($enabled): void { + $this->savingPasteisMode = false; + $this->pasteisModeError = self::PasteisModeFailed; + + if ($this->current !== null) { + $this->current = $this->current->withPasteisMode(! $enabled); + app(Account::class)->rememberPasteis($this->current->pasteis()); + } + }); + } + /** * The devices page on dev-null.sh, where this phone is listed once approved. */ @@ -329,6 +385,7 @@ public function render(): View 'previewLane' => $this->chosenLane ?? $current?->automaticLane, 'lanes' => $current === null ? [] : $this->laneOptions($current), 'laneChanged' => $current !== null && $this->chosenLane !== $current->preferredLane, + 'pasteisMode' => $current?->pasteisMode ?? false, 'remote' => self::remote(), 'offersApple' => AppleSignIn::isAvailable(), 'appleStatus' => match ($this->appleLinked) { @@ -385,8 +442,9 @@ private function laneOptions(CurrentUser $current): array /** * Shows you as devnull sent you. A colour picked and not saved yet stays - * picked; otherwise the picker follows the one devnull has. Whether Apple - * is linked only comes with `GET /me`, so it's left to the refresh. + * picked; otherwise the picker follows the one devnull has. While the + * pastéis switch is saving, it keeps what it shows. Whether Apple is + * linked only comes with `GET /me`, so it's left to the refresh. * * @param array $json */ @@ -402,6 +460,10 @@ private function showMe(array $json): ?CurrentUser $this->chosenLane = $current->preferredLane; } + if ($this->savingPasteisMode && $this->current !== null) { + $current = $current->withPasteisMode($this->current->pasteisMode); + } + return $this->current = $current; } @@ -419,6 +481,7 @@ private function takeMe(array $json): void app(Account::class)->remember($current->user); app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount); + app(Account::class)->rememberPasteis($current->pasteis()); app(ResponseCache::class)->meChanged($json); } diff --git a/composer.json b/composer.json index bf837e8..fdca3b6 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "laravel/boost": "^2.0", "laravel/framework": "^13.0", "laravel/tinker": "^3.0", - "nativephp/mobile": "dev-feat/custom-icons as 4.6.0", + "nativephp/mobile": "dev-combined/dev-null-app as 4.6.0", "nativephp/mobile-browser": "^1.0", "nativephp/mobile-secure-storage": "^1.1", "nativephp/mobile-share": "^1.1", diff --git a/composer.lock b/composer.lock index d4af933..0b36930 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": "5a87d12e362f405fff8841e0c4c7a81e", + "content-hash": "db9e34f24e114a45a188e38b4d6524f8", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2717,16 +2717,16 @@ }, { "name": "nativephp/mobile", - "version": "dev-feat/custom-icons", + "version": "dev-combined/dev-null-app", "source": { "type": "git", "url": "https://github.com/NativePHP/mobile-air.git", - "reference": "ebfb62b73c62a94c48bdbd8d8de53005f855cb77" + "reference": "8c53c6b097fbc050686f8e23dfd55d7d8146bd1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/ebfb62b73c62a94c48bdbd8d8de53005f855cb77", - "reference": "ebfb62b73c62a94c48bdbd8d8de53005f855cb77", + "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/8c53c6b097fbc050686f8e23dfd55d7d8146bd1a", + "reference": "8c53c6b097fbc050686f8e23dfd55d7d8146bd1a", "shasum": "" }, "require": { @@ -2833,10 +2833,10 @@ "nativephp" ], "support": { - "source": "https://github.com/NativePHP/mobile-air/tree/feat/custom-icons", + "source": "https://github.com/NativePHP/mobile-air/tree/combined/dev-null-app", "issues": "https://github.com/NativePHP/mobile-air/issues" }, - "time": "2026-10-16T13:13:23+00:00" + "time": "2026-10-18T16:49:53+00:00" }, { "name": "nativephp/mobile-browser", @@ -11075,7 +11075,7 @@ "aliases": [ { "package": "nativephp/mobile", - "version": "dev-feat/custom-icons", + "version": "dev-combined/dev-null-app", "alias": "4.6.0", "alias_normalized": "4.6.0.0" }, diff --git a/public/images/pastel-de-nata@3x.png b/public/images/pastel-de-nata@3x.png new file mode 100644 index 0000000..275f74c Binary files /dev/null and b/public/images/pastel-de-nata@3x.png differ diff --git a/resources/views/native/partials/commit-actions.blade.php b/resources/views/native/partials/commit-actions.blade.php index 2c71cfd..a0cb72c 100644 --- a/resources/views/native/partials/commit-actions.blade.php +++ b/resources/views/native/partials/commit-actions.blade.php @@ -1,6 +1,7 @@ {{-- - The action row under a commit, in the web's order. On the left: the star and its count, Quote, Reply, and in Commit - Blue "› N replies" when the row has no thread under it already. On the right: More. + The action row under a commit, in the web's order. On the left: the star and its count, in pastéis mode the pastel + and its count, Quote, Reply, and in Commit Blue "› N replies" when the row has no thread under it already. On the + right: More. The web's right side has the bookmark, copy link and Report, and a phone has no room for all three: a profile row with "› 3 replies" would need about 356pt of the 330pt it has. So they share More, which opens the platform's own @@ -12,6 +13,10 @@ Quote opens a sheet to write the quote in, over the commit being quoted (see commit-sheet). + The pastel is the web's: the pastel de nata and how many the commit was given, always, even 0. On someone else's + published commit it gives one more per tap; on your own, or one that isn't published, it's just the count. It + only shows in pastéis mode ($pasteis). + Each action is at least 44pt wide and 36pt tall to tap, as Apple asks, with the row pulled in by the extra height so the commit keeps its rhythm. EDGE makes a pressable's whole box tappable, so the box is the target. Off the log, "› N replies" sits beside Reply, and both won't fit on a phone with their labels, so Reply is just its icon there. @@ -35,6 +40,10 @@ @php($canReact = $commit->isApproved() || $isOwn) @php($hasThread = $expandable && $commit->replyPreview !== null) @php($showsReplies = $withReplies && ! $hasThread && $repliesCount > 0) +@php($pasteis ??= app(\App\Devnull\Account::class)->pasteis()) +@php($canGivePastel = $pasteis !== null && $viewer !== null && ! $isOwn && $commit->isApproved()) +@php($pastelLabel = \App\Devnull\Display::count($commit->pasteisCount, 'pastel', 'pastéis')) +@php($pastelSize = $compact ? 18 : 20) @php($starLabel = ($commit->starred ? 'Unstar commit' : 'Star commit').($commit->starsCount > 0 ? ', '.\App\Devnull\Display::count($commit->starsCount, 'star', 'stars') : '')) @if ($canReact && $isOwn) @@ -56,6 +65,20 @@ @endif + @if ($canGivePastel) + + + + {{ $commit->pasteisCount }} + + + @elseif ($pasteis !== null) + + + {{ $commit->pasteisCount }} + + @endif + @if ($withQuote && $canReact) diff --git a/resources/views/native/partials/profile-header.blade.php b/resources/views/native/partials/profile-header.blade.php index 5d2ac8b..03668eb 100644 --- a/resources/views/native/partials/profile-header.blade.php +++ b/resources/views/native/partials/profile-header.blade.php @@ -1,7 +1,7 @@ {{-- The profile's header, which the pager draws above its tabs (see profile.blade.php): the avatar ringed in their - lane, Follow or Edit on GitHub, who they are, what GitHub says about them, their counts, then their activity, - streaks and achievements. It scrolls up with the tab you're on until the tabs pin under the nav bar. + lane, Follow or Edit on GitHub, who they are, what GitHub says about them, their counts and, in pastéis mode, the + pastéis they've been given, then their activity, streaks and achievements. It scrolls up with the tab you're on until the tabs pin under the nav bar. --}} @@ -95,6 +95,15 @@ @endforeach @endif + + {{-- In pastéis mode, as on the web: how many pastéis they have been given. --}} + @if (! $sketched && ($pasteis ?? null) !== null && $profile->pasteisReceived !== null) + + + {{ $profile->pasteisReceived }} + pastéis received + + @endif diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index 0efc53d..4dce1d8 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -106,6 +106,24 @@ @endif + + {{-- + The web's pastéis mode section, word for word: its heading and two lines, with the switch beside them. + The switch is the system's own (a UISwitch on iOS), which takes taps since NativePHP/mobile-air#545. + --}} + + + + Pastéis mode + A little appreciation, fresh daily. Get 10 pastéis to give to other people’s commits, one per click. Your allowance resets at 00:00 UTC. + When off, all pastéis controls and counters are hidden. You still receive pastéis from others. + + + + @if ($pasteisModeError) + {{ $pasteisModeError }} + @endif + @endif diff --git a/tests/Feature/Screens/AccountPasteisTest.php b/tests/Feature/Screens/AccountPasteisTest.php new file mode 100644 index 0000000..395bc52 --- /dev/null +++ b/tests/Feature/Screens/AccountPasteisTest.php @@ -0,0 +1,152 @@ + 'http://devnull.test']); + signIn(); + $this->travelTo(CarbonImmutable::parse('2026-10-17 15:30:00', 'UTC')); +}); + +/** + * `GET /me` with pastéis mode as given. + * + * @return array + */ +function meWithPasteis(bool $on, int $remaining = 8): array +{ + return currentUserJson(['pasteis_mode' => $on, 'pasteis_remaining' => $remaining, 'pasteis_resets_at' => '2026-10-18T00:00:00Z', 'pasteis_received' => 3]); +} + +/** + * The `PUT /me/pasteis-mode` requests sent, as their bodies. + * + * @return list + */ +function pasteisModeSaves(): array +{ + return collect(Http::recorded()) + ->filter(fn (array $sent): bool => $sent[0]->method() === 'PUT' && str_ends_with($sent[0]->url(), Client::PasteisModePath)) + ->map(fn (array $sent): string => $sent[0]->body()) + ->values() + ->all(); +} + +it("has the web's pastéis mode section, word for word, with the system's switch", function (bool $on) { + serveApi(['GET /api/v1/me' => [meWithPasteis($on)]]); + + $screen = Native::visit('/profile/device') + ->assertSee('Pastéis mode') + ->assertSee('A little appreciation, fresh daily. Get 10 pastéis to give to other people’s commits, one per click. Your allowance resets at 00:00 UTC.') + ->assertSee('When off, all pastéis controls and counters are hidden. You still receive pastéis from others.'); + + $switch = nodeByRef($screen, 'pasteis-mode-switch'); + + expect($switch['type'])->toBe('toggle') + ->and($switch['props']['value'] ?? false)->toBe($on) + ->and($switch['props']['a11y_label'] ?? null)->toBe('Pastéis mode') + // Never greyed out, even while a change is saving. + ->and($switch['props']['disabled'] ?? false)->toBeFalse() + // After Color, as on the web. + ->and(array_column(findNodes($screen->tree(), fn (array $node): bool => in_array($node['ref'] ?? null, ['color', 'pasteis-mode', 'this-phone'], true)), 'ref'))->toBe(['color', 'pasteis-mode', 'this-phone']); +})->with(['on' => true, 'off' => false]); + +it('turns pastéis mode on at once, and every screen gives pastéis from then on', function () { + $bridge = holdRefreshes(); + keep('me', meWithPasteis(false)); + serveApi([ + 'GET /api/v1/me' => [meWithPasteis(false)], + 'PUT /api/v1/me/pasteis-mode' => [meWithPasteis(true, remaining: 8)], + ]); + + $screen = Native::visit('/profile/device')->toggle('pasteis-mode-switch', true); + + expect(nodeByRef($screen, 'pasteis-mode-switch')['props']['value'] ?? false)->toBeTrue() + ->and(nodeByRef($screen, 'pasteis-mode-switch')['props']['disabled'] ?? false)->toBeFalse() + ->and(app(Account::class)->pasteis())->not->toBeNull(); + + finishRefreshes($screen) + ->assertSet('savingPasteisMode', false) + ->assertSet('pasteisModeError', null); + + // The screen's own GET /me, sent before the switch moved, lands first with the mode off, and changes nothing. + expect(pasteisModeSaves())->toBe(['{"enabled":true}']) + ->and(nodeByRef($screen, 'pasteis-mode-switch')['props']['value'] ?? false)->toBeTrue() + ->and(app(Account::class)->pasteis()?->left())->toBe(8) + ->and(kept('me')['data']['pasteis_mode'])->toBeTrue(); + + $bridge->assertNotCalled('Dialog.Toast'); +}); + +it('turns pastéis mode off, and the pastéis leave the rows', function () { + app(Account::class)->rememberPasteis(new Pasteis); + serveApi([ + 'GET /api/v1/me' => [meWithPasteis(true)], + 'PUT /api/v1/me/pasteis-mode' => [meWithPasteis(false)], + ]); + + Native::visit('/profile/device')->toggle('pasteis-mode-switch', false)->assertSet('pasteisModeError', null); + + expect(pasteisModeSaves())->toBe(['{"enabled":false}']) + ->and(app(Account::class)->pasteis())->toBeNull(); +}); + +it('puts the switch back when devnull says no, and says so in the web words', function () { + serveApi([ + 'GET /api/v1/me' => [meWithPasteis(false)], + 'PUT /api/v1/me/pasteis-mode' => [['message' => 'Server Error'], 500], + ]); + + $screen = Native::visit('/profile/device') + ->toggle('pasteis-mode-switch', true) + ->assertSet('savingPasteisMode', false) + ->assertSet('pasteisModeError', ThisDevice::PasteisModeFailed) + ->assertSee('Could not save pastéis mode. Please try again.'); + + expect(nodeByRef($screen, 'pasteis-mode-switch')['props']['value'] ?? false)->toBeFalse() + ->and(app(Account::class)->pasteis())->toBeNull(); +}); + +it('saves the last flip when the switch moves again before devnull answers', function (array $flips, array $saves, bool $on) { + holdRefreshes(); + serveApi(function (Request $request) { + if ($request->method() === 'PUT') { + return Http::response(meWithPasteis(json_decode($request->body(), true)['enabled'])); + } + + return Http::response(meWithPasteis(false)); + }); + + $screen = finishRefreshes(Native::visit('/profile/device')); + + foreach ($flips as $flip) { + $screen->toggle('pasteis-mode-switch', $flip); + } + + expect(nodeByRef($screen, 'pasteis-mode-switch')['props']['value'] ?? false)->toBe($on); + + finishRefreshes($screen)->assertSet('savingPasteisMode', false); + + expect(pasteisModeSaves())->toBe($saves) + ->and($screen->instance()->current?->pasteisMode)->toBe($on) + ->and(app(Account::class)->pasteis() !== null)->toBe($on) + ->and(nodeByRef($screen, 'pasteis-mode-switch')['props']['value'] ?? false)->toBe($on); +})->with([ + 'on, then off' => [[true, false], ['{"enabled":true}', '{"enabled":false}'], false], + 'on, off, on again' => [[true, false, true], ['{"enabled":true}'], true], +]); + +it('sends nothing when the switch already says what devnull has', function () { + serveApi(['GET /api/v1/me' => [meWithPasteis(true)]]); + + finishRefreshes(Native::visit('/profile/device'))->call('setPasteisMode', true); + + expect(pasteisModeSaves())->toBe([]); +}); diff --git a/tests/Feature/Screens/PasteisTest.php b/tests/Feature/Screens/PasteisTest.php new file mode 100644 index 0000000..4217158 --- /dev/null +++ b/tests/Feature/Screens/PasteisTest.php @@ -0,0 +1,340 @@ + 'http://devnull.test']); + $this->bridge = signIn(); + $this->travelTo(CarbonImmutable::parse('2026-10-17 15:30:00', 'UTC')); +}); + +/** + * Someone else, the author of the commits pastéis go to. + * + * @return array + */ +function pastelAuthor(): array +{ + return userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada Lovelace']); +} + +/** + * Pastéis mode on, with $remaining left today, as `GET /me` would have said. + */ +function pasteisOn(int $remaining = Pasteis::Daily): void +{ + app(Account::class)->rememberPasteis(new Pasteis($remaining, CarbonImmutable::parse('2026-10-18T00:00:00Z'))); +} + +/** + * What devnull answers when a pastel goes. + * + * @param array $commit + * @return array + */ +function pastelAnswer(array $commit, int $remaining, int $received = 1): array +{ + return ['data' => $commit, 'pasteis_remaining' => $remaining, 'pasteis_resets_at' => '2026-10-18T00:00:00Z', 'author_pasteis_received' => $received]; +} + +/** + * The messages toasted so far. + * + * @return list + */ +function pastelToasts(FakeBridge $bridge): array +{ + return array_map(fn (array $call): string => $call['params']['message'], $bridge->callsTo('Dialog.Toast')); +} + +/** + * The pastéis given so far, as the commits they went to. + * + * @return list + */ +function pasteisSent(): array +{ + return collect(Http::recorded()) + ->filter(fn (array $sent): bool => $sent[0]->method() === 'POST' && str_ends_with($sent[0]->url(), '/pasteis')) + ->map(fn (array $sent): string => (string) preg_replace('#^.*/commits/([^/]+)/pasteis$#', '$1', $sent[0]->url())) + ->values() + ->all(); +} + +/** + * The first text inside an element, such as a count beside its icon. + */ +function textInside(TestableComponent $screen, string $ref): ?string +{ + return findNode(nodeByRef($screen, $ref), fn (array $node): bool => ($node['type'] ?? null) === 'text')['props']['text'] ?? null; +} + +it('leaves pastéis out of the rows while the mode is off, as the web hides them', function () { + fakeApi(['GET /api/v1/log/following' => [pageJson([ + commitJson(['id' => 'c2', 'pasteis_count' => 4, 'replies_count' => 3, 'author' => pastelAuthor()]), + commitJson(['id' => 'c1', 'pasteis_count' => 2]), + ])]]); + + $screen = Native::visit('/log', platform: 'ios')->assertSee('3 replies'); + + expect(array_values(array_filter(childRefs($screen, 'actions-c2'))))->toBe(['star-c2', 'quote-c2', 'reply-to-c2', 'replies-c2', 'more-c2']) + ->and(findNode($screen->tree(), fn (array $node): bool => str_starts_with((string) ($node['ref'] ?? ''), 'pastel')))->toBeNull() + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['props']['src'] ?? null) === Pasteis::Image))->toBeNull(); + + $screen->call('givePastel', 'c2'); + + expect(pasteisSent())->toBe([]); +}); + +it('puts the pastel and its count beside the star in pastéis mode', function () { + pasteisOn(7); + fakeApi(['GET /api/v1/log/following' => [pageJson([ + commitJson(['id' => 'c2', 'pasteis_count' => 3, 'stars_count' => 1, 'replies_count' => 1, 'author' => pastelAuthor()]), + commitJson(['id' => 'c1', 'pasteis_count' => 0, 'author' => pastelAuthor()]), + ])]]); + + $screen = Native::visit('/log', platform: 'ios'); + + expect(array_values(array_filter(childRefs($screen, 'actions-c2'))))->toBe(['star-c2', 'pastel-c2', 'quote-c2', 'reply-to-c2', 'replies-c2', 'more-c2']) + ->and(nodeByRef($screen, 'pastel-c2')['props']['a11y_label'])->toBe('Give a pastel, 3 pastéis, 7 left today') + ->and(nodeByRef($screen, 'pastel-c1')['props']['a11y_label'])->toBe('Give a pastel, 0 pastéis, 7 left today') + // The web shows the count even when it's 0. + ->and(textInside($screen, 'pastel-c1'))->toBe('0') + ->and(textInside($screen, 'pastel-c2'))->toBe('3') + ->and(findNode(nodeByRef($screen, 'pastel-c2'), fn (array $node): bool => ($node['type'] ?? null) === 'image')['props']['src'])->toBe(Pasteis::Image); +}); + +it('fits the pastel into the row beside the full Reply and replies labels, with More holding the rest', function (bool $pasteisMode) { + if ($pasteisMode) { + pasteisOn(); + } + + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([ + commitJson(['id' => 'c2', 'replies_count' => 3, 'author' => pastelAuthor()]), + commitJson(['id' => 'c1', 'author' => pastelAuthor()]), + ])], + 'GET /api/v1/commits/c2/replies' => [pageJson([replyJson(['id' => 'r1', 'parent_id' => 'c2'])])], + ]); + + $screen = Native::visit('/log', platform: 'ios'); + $pastel = $pasteisMode ? ['pastel-c1'] : []; + + // Star, the pastel in pastéis mode, Quote, Reply with its word, then More: no bookmark or link in the row. + expect(array_values(array_filter(childRefs($screen, 'actions-c1'))))->toBe(['star-c1', ...$pastel, 'quote-c1', 'reply-to-c1', 'more-c1']) + ->and(textInside($screen, 'reply-to-c1'))->toBe('Reply') + ->and(textInside($screen, 'replies-c2'))->toBe('3 replies') + ->and(nodeByRef($screen, 'replies-c2')['props']['a11y_label'])->toBe('3 replies'); + + $screen->tap('replies-c2'); + + expect(textInside($screen, 'replies-c2'))->toBe('Hide replies'); +})->with(['pastéis mode on' => true, 'pastéis mode off' => false]); + +it('only counts the pastéis on your own commits, and on ones that are not published', function () { + pasteisOn(); + fakeApi(['GET /api/v1/log/following' => [pageJson([ + commitJson(['id' => 'c2', 'pasteis_count' => 5]), + commitJson(['id' => 'c1', 'pasteis_count' => 1, 'status' => 'pending']), + ])]]); + + $screen = Native::visit('/log', platform: 'ios') + ->assertMissingElement('pressable', fn (array $node): bool => str_starts_with((string) ($node['ref'] ?? ''), 'pastel-')) + ->assertElement('row', fn (array $node): bool => ($node['ref'] ?? null) === 'pastel-count-c2' && ($node['props']['a11y_label'] ?? null) === '5 pastéis') + ->assertElement('row', fn (array $node): bool => ($node['ref'] ?? null) === 'pastel-count-c1' && ($node['props']['a11y_label'] ?? null) === '1 pastel') + ->call('givePastel', 'c2') + ->call('givePastel', 'c1'); + + expect(pasteisSent())->toBe([]) + ->and($screen->instance()->commits[0]->pasteisCount)->toBe(5); +}); + +it('gives a pastel at once, then takes the count devnull sends back and keeps it everywhere', function () { + pasteisOn(3); + holdRefreshes(); + $commit = commitJson(['id' => 'c1', 'pasteis_count' => 2, 'author' => pastelAuthor()]); + keep('users/ada', ['data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada']), 'pasteis_received' => 8]); + keep('me', currentUserJson(['pasteis_mode' => true, 'pasteis_remaining' => 3, 'pasteis_resets_at' => '2026-10-18T00:00:00Z'])); + serveApi([ + 'GET /api/v1/log/following' => [pageJson([$commit])], + 'GET /api/v1/me' => [currentUserJson(['pasteis_mode' => true, 'pasteis_remaining' => 3, 'pasteis_resets_at' => '2026-10-18T00:00:00Z'])], + 'POST /api/v1/commits/c1/pasteis' => [pastelAnswer([...$commit, 'pasteis_count' => 4], remaining: 2, received: 10)], + ]); + + $screen = finishRefreshes(Native::visit('/log', platform: 'ios'))->tap('pastel-c1'); + + // Before devnull answers: one more on the commit, one fewer for you. + expect($screen->instance()->commits[0]->pasteisCount)->toBe(3) + ->and(textInside($screen, 'pastel-c1'))->toBe('3') + ->and(app(Account::class)->pasteis()?->left())->toBe(2) + ->and(nodeByRef($screen, 'pastel-c1')['props']['a11y_label'])->toBe('Give a pastel, 3 pastéis, 2 left today'); + + finishRefreshes($screen); + + // Someone else gave it one meanwhile, so devnull's count is the truer. + expect($screen->instance()->commits[0]->pasteisCount)->toBe(4) + ->and(app(Account::class)->pasteis()?->left())->toBe(2) + ->and(kept('log/following')['data'][0]['pasteis_count'])->toBe(4) + ->and(kept('users/ada')['pasteis_received'])->toBe(10) + ->and(kept('me')['data']['pasteis_remaining'])->toBe(2) + ->and(pasteisSent())->toBe(['c1']) + ->and(pastelToasts($this->bridge))->toBe([]); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/'.Client::pastelPath('c1') && $request->hasHeader('X-SSH-Signature')); +}); + +it('gives one per tap, and keeps the count when devnull answers the first while others are on their way', function () { + pasteisOn(5); + holdRefreshes(); + $commit = commitJson(['id' => 'c1', 'pasteis_count' => 0, 'author' => pastelAuthor()]); + serveApi([ + 'GET /api/v1/log/following' => [pageJson([$commit])], + 'GET /api/v1/me' => [currentUserJson(['pasteis_mode' => true, 'pasteis_remaining' => 5, 'pasteis_resets_at' => '2026-10-18T00:00:00Z'])], + 'POST /api/v1/commits/c1/pasteis' => [pastelAnswer([...$commit, 'pasteis_count' => 1], remaining: 4)], + ]); + + $screen = finishRefreshes(Native::visit('/log', platform: 'ios'))->tap('pastel-c1')->tap('pastel-c1')->tap('pastel-c1'); + + expect($screen->instance()->commits[0]->pasteisCount)->toBe(3) + ->and(app(Account::class)->pasteis()?->left())->toBe(2); + + finishRefreshes($screen); + + expect($screen->instance()->commits[0]->pasteisCount)->toBe(3) + ->and(app(Account::class)->pasteis()?->left())->toBe(2) + ->and(pasteisSent())->toBe(['c1', 'c1', 'c1']); +}); + +it('says so after the last pastel of the day goes, and gives no more until the reset', function () { + pasteisOn(1); + $commit = commitJson(['id' => 'c1', 'pasteis_count' => 0, 'author' => pastelAuthor()]); + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([$commit])], + 'POST /api/v1/commits/c1/pasteis' => [pastelAnswer([...$commit, 'pasteis_count' => 1], remaining: 0)], + ]); + + $screen = Native::visit('/log', platform: 'ios')->tap('pastel-c1'); + + expect(app(Account::class)->pasteis()?->left())->toBe(0) + ->and(pastelToasts($this->bridge))->toBe(['you ran out of pastéis. wait until tomorrow.']); + + // None left: the toast again, and nothing sent. + $screen->tap('pastel-c1'); + + expect(pasteisSent())->toBe(['c1']) + ->and($screen->instance()->commits[0]->pasteisCount)->toBe(1) + ->and(pastelToasts($this->bridge))->toHaveCount(2); + + // At 00:00 UTC there are ten again. + $this->travelTo(CarbonImmutable::parse('2026-10-18 00:00:00', 'UTC')); + + expect(app(Account::class)->pasteis()?->left())->toBe(Pasteis::Daily); + + $screen->tap('pastel-c1'); + + expect(pasteisSent())->toBe(['c1', 'c1']); +}); + +it('takes the pastel back when devnull says you ran out, and gives no more today', function () { + pasteisOn(4); + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'pasteis_count' => 6, 'author' => pastelAuthor()])])], + 'POST /api/v1/commits/c1/pasteis' => [['message' => 'you ran out of pastéis. wait until tomorrow.', 'code' => 'pasteis_exhausted'], 422], + ]); + + $screen = Native::visit('/log', platform: 'ios')->tap('pastel-c1'); + + expect($screen->instance()->commits[0]->pasteisCount)->toBe(6) + ->and(app(Account::class)->pasteis()?->left())->toBe(0) + ->and(pastelToasts($this->bridge))->toBe(['you ran out of pastéis. wait until tomorrow.']) + ->and($screen->instance()->error)->toBeNull(); +}); + +it('takes the pastel back when it does not go, and says so as the web does', function (array $answer, string $toast) { + pasteisOn(4); + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'pasteis_count' => 6, 'author' => pastelAuthor()])])], + 'POST /api/v1/commits/c1/pasteis' => $answer, + ]); + + $screen = Native::visit('/log', platform: 'ios')->tap('pastel-c1'); + + expect($screen->instance()->commits[0]->pasteisCount)->toBe(6) + ->and(app(Account::class)->pasteis()?->left())->toBe(4) + ->and(pastelToasts($this->bridge))->toBe([$toast]); +})->with([ + 'a server error' => [[['message' => 'Server Error'], 500], 'Could not award a pastel. Please try again.'], + 'a commit gone' => [[['message' => 'Not Found'], 404], 'Could not award a pastel. Please try again.'], + 'too many at once' => [[['message' => 'Too Many Attempts.', 'code' => 'rate_limited'], 429], 'Too Many Attempts.'], +]); + +it('gives pastéis to replies in a thread and on a commit page', function () { + pasteisOn(); + $reply = commitJson(['id' => 'r1', 'parent_id' => 'c1', 'pasteis_count' => 1, 'author' => pastelAuthor()]); + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'replies_count' => 1, 'author' => pastelAuthor(), 'reply_preview' => ['data' => [$reply], 'next_cursor' => null]])])], + 'POST /api/v1/commits/r1/pasteis' => [pastelAnswer([...$reply, 'pasteis_count' => 2], remaining: 9)], + ]); + + $screen = Native::visit('/log', platform: 'ios')->tap('pastel-r1'); + + expect(pasteisSent())->toBe(['r1']) + ->and(textInside($screen, 'pastel-r1'))->toBe('2'); +}); + +it("shows how many pastéis a profile was given in pastéis mode, as the web does, and nothing while it's off", function (bool $on) { + if ($on) { + pasteisOn(); + } + + fakeApi([ + 'GET /api/v1/users/ada' => [['data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada']), 'is_following' => false, 'pasteis_received' => 12]], + 'GET /api/v1/users/ada/commits' => [pageJson([])], + ]); + + $screen = Native::visit('/log/users/ada', platform: 'ios'); + + if ($on) { + expect(nodeByRef($screen, 'pasteis-received')['props']['a11y_label'])->toBe('12 pastéis received'); + $screen->assertSee('pastéis received'); + } else { + $screen->assertDontSee('pastéis received'); + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'pasteis-received'))->toBeNull(); + } +})->with(['on' => true, 'off' => false]); + +it('learns pastéis mode and what is left today whenever it asks devnull about you', function () { + serveApi([ + 'GET /api/v1/log/following' => [pageJson([])], + 'GET /api/v1/me' => [currentUserJson(['pasteis_mode' => true, 'pasteis_remaining' => 6, 'pasteis_resets_at' => '2026-10-18T00:00:00Z', 'pasteis_received' => 2])], + ]); + + Native::visit('/log', platform: 'ios'); + + expect(app(Account::class)->pasteis()?->left())->toBe(6); + + serveApi([ + 'GET /api/v1/log/following' => [pageJson([])], + 'GET /api/v1/me' => [currentUserJson(['pasteis_mode' => false, 'pasteis_remaining' => 6])], + ]); + + Native::visit('/log', platform: 'ios'); + + expect(app(Account::class)->pasteis())->toBeNull(); +}); + +it('forgets your pastéis when you sign out', function () { + pasteisOn(); + + app(Account::class)->forget(); + + expect(app(Account::class)->pasteis())->toBeNull(); +}); diff --git a/tests/Unit/Devnull/PasteisTest.php b/tests/Unit/Devnull/PasteisTest.php new file mode 100644 index 0000000..7c22e9d --- /dev/null +++ b/tests/Unit/Devnull/PasteisTest.php @@ -0,0 +1,55 @@ +midnight = CarbonImmutable::parse('2026-10-18T00:00:00Z'); +}); + +afterEach(fn () => CarbonImmutable::setTestNow()); + +it('gives everyone ten again at 00:00 UTC, not 24 hours after the first one went', function () { + $pasteis = new Pasteis(0, $this->midnight); + + expect($pasteis->left())->toBe(0); + + CarbonImmutable::setTestNow(CarbonImmutable::now()->addSecond()); + + expect($pasteis->left())->toBe(Pasteis::Daily) + ->and($pasteis->afterGiving())->toEqual(new Pasteis(9, CarbonImmutable::parse('2026-10-19T00:00:00Z'))); +}); + +it('counts one down per pastel and back up when one is handed back, never past ten or below none', function () { + $pasteis = new Pasteis(1, $this->midnight); + + expect($pasteis->afterGiving()->left())->toBe(0) + ->and($pasteis->afterGiving()->afterGiving()->left())->toBe(0) + ->and($pasteis->afterTakingBack()->left())->toBe(2) + ->and((new Pasteis(Pasteis::Daily, $this->midnight))->afterTakingBack()->left())->toBe(Pasteis::Daily) + ->and($pasteis->exhausted())->toEqual(new Pasteis(0, $this->midnight)); +}); + +it("takes devnull's word on what is left, unless more pastéis are still on their way, or devnull is on to another day", function () { + $pasteis = new Pasteis(5, $this->midnight); + + expect($pasteis->settledWith(7, $this->midnight)->left())->toBe(5) + ->and($pasteis->settledWith(3, $this->midnight)->left())->toBe(3) + ->and($pasteis->settledWith(9, CarbonImmutable::parse('2026-10-19T00:00:00Z')))->toEqual(new Pasteis(9, CarbonImmutable::parse('2026-10-19T00:00:00Z'))); +}); + +it('reads what it kept, keeping it between none and ten, and starts a day it was never told about', function (array $kept, int $left) { + expect(Pasteis::fromKept($kept)->left())->toBe($left); +})->with([ + [['remaining' => 4, 'resets_at' => '2026-10-18T00:00:00Z'], 4], + [['remaining' => 40], Pasteis::Daily], + [['remaining' => -2], 0], + [['remaining' => 'six'], Pasteis::Daily], + [[], Pasteis::Daily], +]); + +it('keeps itself as plain values', function () { + expect((new Pasteis(3, $this->midnight))->toArray())->toBe(['remaining' => 3, 'resets_at' => '2026-10-18T00:00:00Z']) + ->and(Pasteis::fromKept((new Pasteis(3, $this->midnight))->toArray()))->toEqual(new Pasteis(3, $this->midnight)); +});