From 857f9dcb19e2c6eede6b474482f335a514d1721e Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 19:21:42 +0100 Subject: [PATCH 1/3] Schedule commits, list them, and choose a timezone, as on the web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer sheet gets the web's clock button. It opens "Submit at" with the system date and time picker in the phone's timezone, and Commit becomes Schedule commit. Photos and a poll can go with it, as on the web. Scheduled commits are listed on their own screen, reached from "Scheduled commits →" as on the web, where one still waiting can be committed now or canceled. Account gets the web's Timezone section between Color and Pastéis mode, with its "Show my local time on my profile" switch, read from your devnull settings. NativePHP doesn't tell PHP the phone's timezone, so App\Devnull\Timezone reads it from where /etc/localtime links, as the C library does. Day markers now count days in the phone's timezone, and tapping the focused commit's time says when it was made in yours. Uses devnull's app API for settings, the timezone and scheduled commits. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Account.php | 28 +- app/Devnull/Client.php | 74 ++++++ app/Devnull/Data/Page.php | 6 +- app/Devnull/Data/ScheduledCommit.php | 132 +++++++++ app/Devnull/Display.php | 34 ++- app/Devnull/ResponseCache.php | 5 +- app/Devnull/Timeline.php | 3 +- app/Devnull/Timezone.php | 109 ++++++++ app/Icons/Lucide.php | 2 + .../Concerns/DraftsIssues.php | 2 +- .../Concerns/OpensCommitSheet.php | 150 ++++++++++- .../Concerns/RefreshesInBackground.php | 16 ++ .../Concerns/ShowsCommits.php | 21 +- app/NativeComponents/Log.php | 2 + app/NativeComponents/ScheduledCommits.php | 251 ++++++++++++++++++ app/NativeComponents/ThisDevice.php | 212 ++++++++++++++- resources/icons/clock-3.svg | 1 + .../partials/commit-row-content.blade.php | 13 +- .../native/partials/commit-sheet.blade.php | 11 +- .../native/partials/composer-tools.blade.php | 5 +- .../native/partials/schedule-picker.blade.php | 28 ++ .../native/partials/timezone-sheet.blade.php | 47 ++++ .../views/native/scheduled-commits.blade.php | 95 +++++++ resources/views/native/this-device.blade.php | 57 ++++ routes/mobile.php | 7 +- tests/Feature/Devnull/ClientCacheTest.php | 47 ++++ tests/Feature/Devnull/TimezoneTest.php | 148 +++++++++++ tests/Feature/Screens/AccountProfileTest.php | 4 +- tests/Feature/Screens/AccountTimezoneTest.php | 241 +++++++++++++++++ tests/Feature/Screens/CommitDetailTest.php | 13 + tests/Feature/Screens/LogCacheTest.php | 4 +- tests/Feature/Screens/ScheduleCommitTest.php | 241 +++++++++++++++++ .../Feature/Screens/ScheduledCommitsTest.php | 186 +++++++++++++ tests/Feature/Screens/ThisDeviceAppleTest.php | 9 +- tests/Pest.php | 56 ++++ tests/TestCase.php | 5 + 36 files changed, 2229 insertions(+), 36 deletions(-) create mode 100644 app/Devnull/Data/ScheduledCommit.php create mode 100644 app/Devnull/Timezone.php create mode 100644 app/NativeComponents/ScheduledCommits.php create mode 100644 resources/icons/clock-3.svg create mode 100644 resources/views/native/partials/schedule-picker.blade.php create mode 100644 resources/views/native/partials/timezone-sheet.blade.php create mode 100644 resources/views/native/scheduled-commits.blade.php create mode 100644 tests/Feature/Devnull/TimezoneTest.php create mode 100644 tests/Feature/Screens/AccountTimezoneTest.php create mode 100644 tests/Feature/Screens/ScheduleCommitTest.php create mode 100644 tests/Feature/Screens/ScheduledCommitsTest.php diff --git a/app/Devnull/Account.php b/app/Devnull/Account.php index e2ba3138..4bd99be0 100644 --- a/app/Devnull/Account.php +++ b/app/Devnull/Account.php @@ -32,6 +32,8 @@ final class Account */ private const string RemotesOff = 'off'; + private const string TimezoneKey = 'devnull.timezone'; + public function user(): ?User { $attributes = Cache::get(self::CacheKey); @@ -168,8 +170,29 @@ public function rememberSkipsPinWarning(): void } /** - * Signs this phone out: who it was, the badges, its pastéis and Remotes, every answer, image and voice note kept - * for a quick start, and your unsent drafts and recordings. + * The timezone you chose on devnull, as it last said, or null while you haven't chosen one. + */ + public function timezone(): ?string + { + $timezone = Cache::get(self::TimezoneKey); + + return Timezone::isKnown($timezone) ? $timezone : null; + } + + public function rememberTimezone(?string $timezone): void + { + if ($timezone === null) { + Cache::forget(self::TimezoneKey); + + return; + } + + Cache::forever(self::TimezoneKey, $timezone); + } + + /** + * Signs this phone out: who it was, the badges, its pastéis and Remotes, your timezone, every answer, image and + * voice note kept for a quick start, and your unsent drafts and recordings. */ public function forget(): void { @@ -185,5 +208,6 @@ public function forget(): void Cache::forget(self::RemotesKey); Cache::forget(self::ProfileKey); Cache::forget(self::PinWarningKey); + Cache::forget(self::TimezoneKey); } } diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 3ab0a4c4..14ce5b72 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -11,9 +11,11 @@ use App\Devnull\Data\Notification; use App\Devnull\Data\Page; use App\Devnull\Data\Profile; +use App\Devnull\Data\ScheduledCommit; use App\Devnull\Data\User; use App\Devnull\Exceptions\ApiException; use Carbon\CarbonImmutable; +use Carbon\CarbonInterface; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -103,6 +105,20 @@ */ public const int RepliesPerPage = 5; + /** + * Where the signed-in account's timezone is saved, or unset, with whether their profile shows their local time. + * devnull sends both with the rest of what only the user sees in the web's settings. + */ + public const string TimezonePath = 'me/timezone'; + + public const string SettingsPath = 'me/settings'; + + /** + * The commits the signed-in account scheduled, as the web's Scheduled + * commits page lists them. + */ + public const string ScheduledCommitsPath = 'scheduled-commits'; + public function __construct(private DeviceKey $deviceKey, private string $baseUrl, private ResponseCache $cache) {} /** @@ -287,6 +303,64 @@ public function signedMessage(string $threadPath, string $body, string $clientId }; } + /** + * Signs a commit to be submitted at $at, as the web's Schedule commit + * does, for the background to send. Only you see it until then. With + * photos it goes as multipart, as a commit with photos does, and a poll + * goes in its JSON. + * + * @param list $images + * @param array|null $poll + */ + public function signedScheduledCommit(string $body, CarbonInterface $at, array $images = [], ?array $poll = null): SignedRequest + { + $payload = array_filter(['body' => $body, 'scheduled_at' => self::scheduleTime($at), 'poll' => $poll], fn (mixed $value): bool => $value !== null); + + return $images === [] + ? $this->signedWrite('POST', self::ScheduledCommitsPath, $payload) + : $this->signedWithImages(self::ScheduledCommitsPath, $payload, $images); + } + + /** + * Your scheduled commits, the ones waiting first, 20 a page. They're + * paged by number, which a Page carries as its cursor. + * + * @return Page + */ + public function scheduledCommits(?string $page = null): Page + { + return Page::fromArray($this->get(self::ScheduledCommitsPath, ['page' => $page]), ScheduledCommit::fromArray(...)); + } + + /** + * The web's Commit now: one still waiting goes for review now instead of + * at its time. devnull answers with it as the list now shows it. + */ + public function submitScheduledCommit(string $id): ScheduledCommit + { + $scheduled = $this->data($this->send('POST', self::ScheduledCommitsPath.'/'.rawurlencode($id).'/submission')); + $this->cache->forget(self::ScheduledCommitsPath); + + return ScheduledCommit::fromArray($scheduled); + } + + /** + * Cancels a scheduled commit that's still waiting, as the web's Cancel does. + */ + public function cancelScheduledCommit(string $id): void + { + $this->send('DELETE', self::ScheduledCommitsPath.'/'.rawurlencode($id)); + $this->cache->forget(self::ScheduledCommitsPath); + } + + /** + * A time as devnull takes one to schedule for: ISO 8601 with its offset, in UTC. + */ + public static function scheduleTime(CarbonInterface $at): string + { + return CarbonImmutable::instance($at)->utc()->format('Y-m-d\TH:i:sP'); + } + public function deleteCommit(string $id): void { $this->send('DELETE', 'commits/'.rawurlencode($id)); diff --git a/app/Devnull/Data/Page.php b/app/Devnull/Data/Page.php index c7515298..41dde162 100644 --- a/app/Devnull/Data/Page.php +++ b/app/Devnull/Data/Page.php @@ -5,7 +5,8 @@ use Closure; /** - * One page of a cursor-paginated list. + * One page of a cursor-paginated list. A list paged by number, as scheduled + * commits are, carries the next page's number as its cursor. * * @template T */ @@ -49,7 +50,8 @@ private static function cursorFromUrl(mixed $url): ?string } parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + $cursor = $query['cursor'] ?? $query['page'] ?? null; - return is_string($query['cursor'] ?? null) ? $query['cursor'] : null; + return is_string($cursor) ? $cursor : null; } } diff --git a/app/Devnull/Data/ScheduledCommit.php b/app/Devnull/Data/ScheduledCommit.php new file mode 100644 index 00000000..31280b98 --- /dev/null +++ b/app/Devnull/Data/ScheduledCommit.php @@ -0,0 +1,132 @@ + 'Submitted for review', + 'approved' => 'Approved', + 'rejected' => 'Rejected', + 'deleted' => 'Deleted', + ]; + + /** + * @param list $content + * @param list|null $previewContent + * @param list|null $pollOptions + */ + public function __construct( + public string $id, + public ?string $body, + public array $content, + public ?array $previewContent, + public ?array $pollOptions, + public int $imageCount, + public CarbonImmutable $scheduledAt, + public ?CarbonImmutable $submittedAt, + public string $status, + ) {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $body = is_string($data['body'] ?? null) ? $data['body'] : null; + $segments = is_array($data['body_segments'] ?? null) && $data['body_segments'] !== [] ? array_values(array_filter($data['body_segments'], is_array(...))) : null; + $previewSegments = is_array($data['preview_segments'] ?? null) ? array_values(array_filter($data['preview_segments'], is_array(...))) : null; + $options = is_array($data['poll_options'] ?? null) ? array_values(array_filter($data['poll_options'], is_string(...))) : null; + $status = $data['status'] ?? null; + + return new self( + id: (string) ($data['id'] ?? ''), + body: $body, + content: $body === null ? [] : CommitBody::blocks($body, $segments), + previewContent: $body === null || $previewSegments === null ? null : CommitBody::blocks($body, $previewSegments), + pollOptions: $options, + imageCount: max(0, (int) ($data['image_count'] ?? 0)), + scheduledAt: Commit::date($data['scheduled_at'] ?? null) ?? CarbonImmutable::now(), + submittedAt: Commit::date($data['submitted_at'] ?? null), + status: is_string($status) && ($status === 'scheduled' || array_key_exists($status, self::Labels)) ? $status : 'scheduled', + ); + } + + /** + * Whether it's still waiting to be submitted, so it can be edited or canceled. + */ + public function isWaiting(): bool + { + return $this->status === 'scheduled'; + } + + /** + * Whether it's due and waiting for the scheduler, which submits due commits every minute. + */ + public function isDue(?CarbonInterface $now = null): bool + { + return $this->isWaiting() && $this->scheduledAt->lessThanOrEqualTo($now ?? CarbonImmutable::now()); + } + + /** + * What the web's list says of it. + */ + public function label(?CarbonInterface $now = null): string + { + return match (true) { + $this->isDue($now) => 'Waiting for submission', + $this->isWaiting() => 'Scheduled', + default => self::Labels[$this->status], + }; + } + + /** + * This one as devnull says it is now it's been committed early, keeping + * what was sent, as the web's list keeps showing it. + */ + public function submittedAs(self $now): self + { + return new self(...[...get_object_vars($this), 'submittedAt' => $now->submittedAt, 'status' => $now->status]); + } + + /** + * The links in its body, in order, which its rows open by their place among them, as a commit's do. + * + * @return list + */ + public function links(): array + { + $links = []; + + foreach ($this->content as $block) { + foreach ($block->runs as $run) { + if ($run->link !== null) { + $links[] = $run->link; + } + } + } + + return $links; + } + + /** + * Whether its body is long, so the list shows only its start until "Show more", as a commit's is. + */ + public function isFolded(): bool + { + return $this->previewContent !== null; + } +} diff --git a/app/Devnull/Display.php b/app/Devnull/Display.php index 24632238..441cc2dc 100644 --- a/app/Devnull/Display.php +++ b/app/Devnull/Display.php @@ -87,11 +87,11 @@ public static function ago(CarbonInterface $date, ?CarbonInterface $now = null): /** * The label of a day marker in a feed, worded like the web: "Today", "Yesterday", * then "Tue, Oct 13", with the year once it isn't this year. Days are counted in - * the app's time zone, since PHP can't see the device's. + * the phone's timezone, as the web counts them in the browser's (see Timezone). */ public static function dayLabel(CarbonInterface $date, ?CarbonInterface $now = null): string { - $now = CarbonImmutable::instance($now ?? CarbonImmutable::now()); + $now = CarbonImmutable::instance($now ?? self::now()); $day = CarbonImmutable::instance($date)->setTimezone($now->getTimezone()); $daysAgo = (int) round(($now->startOfDay()->getTimestamp() - $day->startOfDay()->getTimestamp()) / 86400); @@ -108,7 +108,7 @@ public static function dayLabel(CarbonInterface $date, ?CarbonInterface $now = n */ public static function dayKey(CarbonInterface $date, ?CarbonInterface $now = null): string { - $timezone = ($now ?? CarbonImmutable::now())->getTimezone(); + $timezone = ($now ?? self::now())->getTimezone(); return CarbonImmutable::instance($date)->setTimezone($timezone)->toDateString(); } @@ -118,7 +118,33 @@ public static function dayKey(CarbonInterface $date, ?CarbonInterface $now = nul */ public static function clock(CarbonInterface $date, ?CarbonInterface $now = null): string { - return CarbonImmutable::instance($date)->setTimezone(($now ?? CarbonImmutable::now())->getTimezone())->format('H:i'); + return CarbonImmutable::instance($date)->setTimezone(($now ?? self::now())->getTimezone())->format('H:i'); + } + + /** + * Now, in the phone's timezone, which days are counted in. + */ + public static function now(): CarbonImmutable + { + return CarbonImmutable::now(app(Timezone::class)->here()); + } + + /** + * When a commit was made, as the web prints it under the commit a page is + * about: "3:04 PM · Oct 19, 2026", in your timezone (see Timezone). + */ + public static function fullDateTime(CarbonInterface $date, ?string $timezone = null): string + { + return CarbonImmutable::instance($date)->setTimezone($timezone ?? app(Timezone::class)->yours())->format('g:i A · M j, Y'); + } + + /** + * When a scheduled commit is due, as the web's Scheduled commits page + * prints it: "Oct 16, 2026, 10:30 AM", in the phone's timezone. + */ + public static function scheduleTime(CarbonInterface $date, ?string $timezone = null): string + { + return CarbonImmutable::instance($date)->setTimezone($timezone ?? app(Timezone::class)->here())->format('M j, Y, g:i A'); } /** diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index 534a205f..69e96fe0 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -51,6 +51,7 @@ final class ResponseCache '#^remotes$#', '#^remotes/@[^/]+/messages$#', '#^remotes/orgs/[^/]+/messages$#', + '#^scheduled-commits$#', ]; /** @@ -111,8 +112,8 @@ final class ResponseCache */ public static function caches(string $path, array $query = []): bool { - // A cursor's page, and a Remotes thread's messages before or after one, aren't the first page. - if (array_intersect_key(self::normalise($query), array_flip(['cursor', 'before', 'after'])) !== []) { + // A cursor's page, a numbered page, and a Remotes thread's messages before or after one, aren't the first page. + if (array_intersect_key(self::normalise($query), array_flip(['cursor', 'page', 'before', 'after'])) !== []) { return false; } diff --git a/app/Devnull/Timeline.php b/app/Devnull/Timeline.php index 4ddcff7b..56accbf1 100644 --- a/app/Devnull/Timeline.php +++ b/app/Devnull/Timeline.php @@ -4,7 +4,6 @@ use App\Devnull\Data\Commit; use App\Devnull\Data\TimelineRow; -use Carbon\CarbonImmutable; use Carbon\CarbonInterface; /** @@ -37,7 +36,7 @@ final class Timeline */ public static function feed(array $commits, ?string $firstSeenId = null, ?CarbonInterface $now = null, ?int $whoToFollowAfter = null): array { - $now ??= CarbonImmutable::now(); + $now ??= $commits === [] ? null : Display::now(); $rows = []; $previous = null; $previousDay = null; diff --git a/app/Devnull/Timezone.php b/app/Devnull/Timezone.php new file mode 100644 index 00000000..798b718d --- /dev/null +++ b/app/Devnull/Timezone.php @@ -0,0 +1,109 @@ +localtime; + + for ($hops = 0; $hops < 4 && is_link($path); $hops++) { + $target = (string) readlink($path); + + if (preg_match('#/zoneinfo/(.+)$#', $target, $matches) === 1) { + return self::isKnown($matches[1]) ? $matches[1] : null; + } + + $path = str_starts_with($target, '/') ? $target : dirname($path).'/'.$target; + } + + return null; + } + + /** + * The timezone you chose on devnull, or null while you haven't. + */ + public function chosen(): ?string + { + return app(Account::class)->timezone(); + } + + /** + * The timezone your times show in: the one you chose, or else the + * phone's, as the web falls back to the browser's. + */ + public function yours(): string + { + return $this->chosen() ?? $this->phone() ?? 'UTC'; + } + + /** + * Where the phone is, which a time to schedule is picked in, as the web + * picks it in the browser's timezone. + */ + public function here(): string + { + return $this->phone() ?? $this->chosen() ?? 'UTC'; + } + + /** + * Whether PHP knows the timezone by that name, as devnull checks it. + */ + public static function isKnown(mixed $name): bool + { + return is_string($name) && in_array($name, DateTimeZone::listIdentifiers(), true); + } + + /** + * Every timezone, as devnull takes them. + * + * @return list + */ + public static function all(): array + { + return array_values(DateTimeZone::listIdentifiers()); + } + + /** + * How far a timezone is from UTC at a moment, as the web's picker prints + * it: `GMT+01:00`, `GMT-03:00`, `GMT+05:45`. + */ + public static function offset(string $name, ?CarbonInterface $at = null): string + { + return 'GMT'.CarbonImmutable::instance($at ?? CarbonImmutable::now())->setTimezone($name)->format('P'); + } + + /** + * The city a timezone is named for, as the web's tooltip prints it: + * `Sao Paulo` for `America/Sao_Paulo`. + */ + public static function city(string $name): string + { + $city = str_contains($name, '/') ? substr($name, (int) strrpos($name, '/') + 1) : $name; + + return str_replace('_', ' ', $city); + } +} diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index 77dfaac4..41678223 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -42,6 +42,7 @@ enum Lucide case Building2; case MapPin; case CalendarDays; + case Clock3; /** The web draws GitHub's own mark, which neither system has, so this is the code glyph. */ case Github; case Quote; @@ -178,6 +179,7 @@ public function custom(bool $filled = false): ?string self::Flag => 'flag', self::RefreshCw => 'refresh-cw', self::LockKeyhole => 'lock-keyhole', + self::Clock3 => 'clock-3', self::VolumeX => 'volume-x', self::Volume2 => 'volume-2', self::Ban => 'ban', diff --git a/app/NativeComponents/Concerns/DraftsIssues.php b/app/NativeComponents/Concerns/DraftsIssues.php index 66e02a98..35809184 100644 --- a/app/NativeComponents/Concerns/DraftsIssues.php +++ b/app/NativeComponents/Concerns/DraftsIssues.php @@ -193,7 +193,7 @@ protected function typeIssueCommand(): void */ protected function canDraftIssue(): bool { - return $this->sheetOpen && $this->sheetView === 'compose'; + return $this->sheetOpen && $this->sheetView === 'compose' && ! $this->scheduling; } /** diff --git a/app/NativeComponents/Concerns/OpensCommitSheet.php b/app/NativeComponents/Concerns/OpensCommitSheet.php index 39f6aa50..2d039f01 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -7,10 +7,16 @@ use App\Devnull\Data\Draft; use App\Devnull\Data\PollDraft; use App\Devnull\Data\StashedDraft; +use App\Devnull\DraftImages; use App\Devnull\Outbox; use App\Devnull\ReportReason; +use App\Devnull\ResponseCache; +use App\Devnull\SignedRequest; use App\Devnull\Stash; +use App\Devnull\Timezone; +use App\Devnull\Toasts; use App\Models\PendingWrite; +use Carbon\CarbonImmutable; use Closure; use Native\Mobile\Facades\System; @@ -26,6 +32,11 @@ * (see SyncsOutbox), and a screen that lists your new commits puts it in * place, staged, until devnull has it (see showCommitted()). * + * A new commit can be scheduled instead, as the web's composer schedules one: + * the clock button opens "Submit at" with the system's date and time picker, + * in the phone's timezone (see Timezone), and Commit becomes Schedule commit. + * Only you see it until it's due, on the Scheduled commits page it links to. + * * What you write in it is stashed as you go, as on the web (see Stash): a * quote per commit quoted, and a new commit under the web's composer draft, * with its photos and its poll. Opened again, it starts from the draft, @@ -52,6 +63,16 @@ trait OpensCommitSheet use DraftsIssues; use SyncsOutbox; + /** + * What the web says once a commit is scheduled. + */ + public const string Scheduled = 'Commit scheduled. Only you can see it until it is submitted.'; + + /** + * What the web says of a time the timezone skips. + */ + public const string SkippedTime = 'This time does not exist in your timezone. Choose another time.'; + /** * Whether the sheet is up, or on its way up. */ @@ -109,6 +130,32 @@ trait OpensCommitSheet */ public ?string $sheetError = null; + /** + * Whether the new commit is to be scheduled for later, as the web's clock button toggles it. + */ + public bool $scheduling = false; + + /** + * When to submit it, as the picker's wall clock in the phone's timezone (`2026-10-20T09:00`), or '' until one is + * picked. + */ + public string $scheduledAt = ''; + + /** + * Whether the time picked is one to schedule for: there is one, and the phone's timezone doesn't skip it. + */ + public bool $hasScheduledTime = false; + + /** + * Whether Scheduled commits → was tapped, so the list opens once the sheet has gone. + */ + public bool $opensScheduledCommits = false; + + /** + * Whether the commit being scheduled is on its way to devnull. + */ + public bool $sendingSchedule = false; + /** * Report commit, in the More menu of someone else's published commit. */ @@ -199,6 +246,41 @@ public function composeInSheet(): void $this->restoreSheetDraft(); } + /** + * The web's clock button, in a new commit's composer: it opens "Submit at" under the field, with no time picked, + * or closes it, and the commit goes now again. Its photos or its poll go with it either way, as on the web. + */ + public function toggleSchedule(): void + { + if ($this->sheetView !== 'compose' || $this->sheetIssue !== null) { + return; + } + + $this->scheduling = ! $this->scheduling; + $this->scheduledAt = ''; + $this->hasScheduledTime = false; + $this->sheetError = null; + } + + /** + * A time picked: the web says when the phone's timezone skips it, as on the night the clocks go forward. + */ + public function updatedScheduledAt(): void + { + $this->hasScheduledTime = $this->scheduledTime() !== null; + $this->sheetError = $this->scheduledAt === '' || $this->hasScheduledTime ? null : self::SkippedTime; + } + + /** + * Scheduled commits →, under "Submit at": the sheet goes, keeping the draft, and the list opens once it has. + * Pushed while the sheet slides away, the list comes in without its title. + */ + public function openScheduledCommits(): void + { + $this->opensScheduledCommits = true; + $this->sheetOpen = false; + } + /** * Stashes the draft as it changes (see stashSheetDraft()). */ @@ -221,7 +303,7 @@ public function dropSheetStash(): void /** * Commits what's written in the sheet, quoting its commit if it's a quote, or replying to it, with its photos or - * its poll. The sheet goes at once, and the commit shows staged where the screen lists your new ones, while it + * its poll, or schedules it (see scheduleFromSheet()). The sheet goes at once, and the commit shows staged where the screen lists your new ones, while it * goes to devnull from the outbox (see SyncsOutbox). The photos go with it, so closing the sheet keeps them. */ public function commitFromSheet(): void @@ -236,6 +318,12 @@ public function commitFromSheet(): void return; } + if ($this->scheduling && $this->sheetView === 'compose') { + $this->scheduleFromSheet($body, $stashName); + + return; + } + $write = app(Outbox::class)->add(PendingWrite::Commit, $parent?->id ?? $quoted?->id, [ 'body' => $body, 'parent_id' => $parent?->id, @@ -257,6 +345,21 @@ public function commitFromSheet(): void $this->sendFromOutbox($write); } + /** + * The time picked to submit at, or null until one is, or when the phone's timezone skips it, like 01:30 on the + * night the clocks go forward. + */ + private function scheduledTime(): ?CarbonImmutable + { + if ($this->scheduledAt === '') { + return null; + } + + $at = CarbonImmutable::createFromFormat('!Y-m-d\TH:i', $this->scheduledAt, app(Timezone::class)->here()); + + return $at instanceof CarbonImmutable && $at->format('Y-m-d\TH:i') === $this->scheduledAt ? $at : null; + } + /** * Done, after a report, and Cancel, in the composer's bar: the sheet goes. A draft in it stays stashed, as when * it's swiped away, so this mustn't forget it. A screen whose composer handed it the draft takes it back now, @@ -275,6 +378,7 @@ public function commitSheetClosed(): void { $view = $this->sheetView; $held = $this->reopenAfterSheet; + $opensScheduledCommits = $this->opensScheduledCommits; $this->sheetClosing = false; $this->reopenAfterSheet = null; @@ -285,6 +389,12 @@ public function commitSheetClosed(): void if ($write !== null && $write->isCommit() && $write->isFailed()) { $this->openFailedCommit($write); + + return; + } + + if ($opensScheduledCommits) { + $this->openInTab('/scheduled'); } } @@ -400,6 +510,39 @@ private function canReport(Commit $commit): bool return $viewer !== null && ! $commit->isDeleted() && $commit->isApproved() && $commit->author->id !== $viewer->id; } + /** + * Schedules what's written for the time picked, with its photos or its poll, as the web schedules them. It goes + * from the background, photos and all, while the bar's check spins. Once devnull has it the sheet goes, the + * photos with it, and says so as the web does; otherwise it keeps the draft and says why. + */ + private function scheduleFromSheet(string $body, string $stashName): void + { + $at = $this->scheduledTime(); + $me = $this->me(); + + if ($at === null || $me === null || $this->sendingSchedule) { + return; + } + + $images = $this->sheetImages; + $poll = $this->sheetPollPayload(); + $this->sendingSchedule = true; + $this->sheetError = null; + + $this->sendInBackground(fn (Client $devnull): SignedRequest => $devnull->signedScheduledCommit($body, $at, $images, $poll), function () use ($me, $stashName, $images): void { + $this->sendingSchedule = false; + app(ResponseCache::class)->forget(Client::ScheduledCommitsPath); + app(Stash::class)->put($me->id, $stashName, new StashedDraft); + app(DraftImages::class)->discard($images); + $this->sheetImages = []; + $this->closeCommitSheet(); + Toasts::say(self::Scheduled); + }, function (string $message): void { + $this->sendingSchedule = false; + $this->sheetError = $message; + }, timeout: $images === [] ? 30 : 120); + } + private function forgetCommitSheet(): void { $this->sheetOpen = false; @@ -412,6 +555,11 @@ private function forgetCommitSheet(): void $this->sheetError = null; $this->forgetAttachments(); $this->forgetIssueDraft(); + $this->scheduling = false; + $this->scheduledAt = ''; + $this->hasScheduledTime = false; + $this->opensScheduledCommits = false; + $this->sendingSchedule = false; } /** diff --git a/app/NativeComponents/Concerns/RefreshesInBackground.php b/app/NativeComponents/Concerns/RefreshesInBackground.php index dfa80562..c75dde96 100644 --- a/app/NativeComponents/Concerns/RefreshesInBackground.php +++ b/app/NativeComponents/Concerns/RefreshesInBackground.php @@ -11,6 +11,7 @@ use App\Devnull\FetchInBackground; use App\Devnull\ImageCache; use App\Devnull\ResponseCache; +use App\Devnull\Timezone; use Closure; use Throwable; @@ -158,6 +159,21 @@ protected function refreshUnreadNotifications(): void }, quiet: true); } + /** + * Asks devnull for what only you see in the web's settings, and keeps the + * timezone you chose there, which `/me` doesn't carry, so the app shows + * times in it (see Timezone). It's a nicety, so a failure is never shown. + */ + protected function refreshSettings(): void + { + $this->refresh(Client::SettingsPath, [], function (array $json): void { + $settings = is_array($json['data'] ?? null) ? $json['data'] : []; + $timezone = $settings['timezone'] ?? null; + + app(Account::class)->rememberTimezone(Timezone::isKnown($timezone) ? $timezone : null); + }, quiet: true); + } + /** * A screen that has its own way of saying devnull refused it, such as a * profile across a block, says so here and returns true. Anything else diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index 25da190a..d0fa47ea 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -8,10 +8,12 @@ use App\Devnull\Data\CommitImage; use App\Devnull\Data\Pasteis; use App\Devnull\Data\User; +use App\Devnull\Display; use App\Devnull\ErrorCode; use App\Devnull\Exceptions\ApiException; use App\Devnull\ResponseCache; use App\Devnull\SiteLink; +use App\Devnull\Timezone; use App\Devnull\Toasts; use Closure; use Native\Mobile\Attributes\On; @@ -28,9 +30,9 @@ * OpensCommitSheet), plus opening its link preview in the in-app browser or, * for a page on dev/null, the app (see SiteLink), its * images full-screen in the app (see partials/image-viewer), "Show more" on a - * long one, and voting on its poll (see - * VotesOnPolls). Taps name the commit rather than a URL, so a URL is never - * rebuilt from an attribute. + * long one, voting on its poll (see VotesOnPolls), and the time of the + * commit a page is about. 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. @@ -177,6 +179,19 @@ public function showMore(string $id): void } } + /** + * The time of the commit a page is about, tapped: when it was made, in your + * timezone, as the web's tooltip shows it when you hover the time. + */ + public function showTime(string $id): void + { + $createdAt = $this->shownCommit($id)?->createdAt; + + if ($createdAt !== null) { + Toasts::say(Display::fullDateTime($createdAt).' in '.Timezone::city(app(Timezone::class)->yours())); + } + } + /** * Stars or unstars straight away, here and in every kept copy, while the * star goes to devnull from the outbox (see SyncsOutbox). The commit takes diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index 8fdaf84f..9d03448e 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -494,11 +494,13 @@ 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. + * Your settings come too, for the timezone the app shows times in. */ private function open(): void { $this->isRanked() ? $this->rank() : $this->revalidate(); $this->openWhoToFollow(); + $this->refreshSettings(); } /** diff --git a/app/NativeComponents/ScheduledCommits.php b/app/NativeComponents/ScheduledCommits.php new file mode 100644 index 00000000..180bc470 --- /dev/null +++ b/app/NativeComponents/ScheduledCommits.php @@ -0,0 +1,251 @@ + + */ + public array $scheduled = []; + + /** + * The next page's number, or null when this is all of them. + */ + public ?string $nextPage = null; + + public bool $loaded = false; + + /** + * The one being committed now or canceled while devnull is asked, and which ('commit' or 'cancel'). + */ + public ?string $busy = null; + + public ?string $busyWith = null; + + /** + * Long bodies opened in place with "Show more", by id. + * + * @var list + */ + public array $unfolded = []; + + public function mount(): void + { + if (($kept = $this->cached(Client::ScheduledCommitsPath)) !== null) { + $this->take($kept); + } + + $this->revalidate(); + } + + public function onResume(): void + { + $this->forgetRefreshes(); + $this->revalidate(); + } + + public function navTitle(): string + { + return 'Scheduled commits'; + } + + protected function revalidate(): void + { + $this->refresh(Client::ScheduledCommitsPath, [], fn (array $json) => $this->take($json)); + } + + protected function showsContent(): bool + { + return $this->loaded; + } + + /** + * The web's Commit now: it goes for review now instead of at its time. + * The row keeps what was sent, where it was, and says it was submitted. + */ + public function commitNow(string $id): void + { + $sent = $this->find($id); + $submitted = $this->whileBusy($id, 'commit', fn (Client $devnull): ScheduledCommit => $devnull->submitScheduledCommit($id)); + + if ($submitted instanceof ScheduledCommit && $sent !== null) { + $this->scheduled = array_map(fn (ScheduledCommit $scheduled): ScheduledCommit => $scheduled->id === $id ? $sent->submittedAs($submitted) : $scheduled, $this->scheduled); + Toasts::say(self::Submitted); + } + } + + /** + * The web's Cancel: it goes from the list once devnull has canceled it. + */ + public function cancel(string $id): void + { + $canceled = $this->whileBusy($id, 'cancel', function (Client $devnull) use ($id): bool { + $devnull->cancelScheduledCommit($id); + + return true; + }); + + if ($canceled === true) { + $this->scheduled = array_values(array_filter($this->scheduled, fn (ScheduledCommit $scheduled): bool => $scheduled->id !== $id)); + Toasts::say(self::Canceled); + } + } + + public function showMore(string $id): void + { + if (! in_array($id, $this->unfolded, true)) { + $this->unfolded[] = $id; + } + } + + /** + * A link in one's body, by its place among the body's links: a page on + * dev/null in the app's own screen for it, anything else in the browser, + * as in a feed (see SiteLink). + */ + public function openLink(string $id, int $index): void + { + $link = $this->find($id)?->links()[$index] ?? null; + $screen = $link?->commit !== null ? '/commits/'.$link->commit : SiteLink::screen($link?->href); + + if ($screen !== null) { + $this->openInTab($screen); + } elseif (SiteLink::isWebUrl($link?->href)) { + Browser::inApp((string) $link?->href); + } + } + + /** + * The next page, under the ones showing. + */ + public function loadMore(): void + { + if ($this->nextPage === null) { + return; + } + + $page = $this->attempt(fn (Client $devnull): Page => $devnull->scheduledCommits($this->nextPage)); + + if ($page !== null) { + $this->scheduled = [...$this->scheduled, ...$page->items]; + $this->nextPage = $page->nextCursor; + } + } + + public function render(): View + { + return view('native.scheduled-commits', [ + 'myColour' => $this->myColour(), + 'timezone' => app(Timezone::class)->here(), + 'now' => CarbonImmutable::now(), + ]); + } + + /** + * Asks devnull to commit or cancel one still waiting, one at a time. When + * devnull had submitted or canceled it already, it answers 409, which the + * web's toast says, and the list asks again to show what became of it. + * + * @template TResult + * + * @param Closure(Client): TResult $call + * @return TResult|false|null + */ + private function whileBusy(string $id, string $action, Closure $call): mixed + { + if ($this->busy !== null || $this->find($id)?->isWaiting() !== true) { + return null; + } + + $this->busy = $id; + $this->busyWith = $action; + + $result = $this->attempt(function (Client $devnull) use ($call): mixed { + try { + return $call($devnull); + } catch (ApiException $exception) { + return $exception->status === 409 ? false : throw $exception; + } + }); + + $this->busy = null; + $this->busyWith = null; + + if ($result === false) { + Toasts::say(self::AlreadySubmitted); + $this->retry(); + } + + return $result; + } + + /** + * @param array $json + */ + private function take(array $json): void + { + $page = Page::fromArray($json, ScheduledCommit::fromArray(...)); + + $this->scheduled = $page->items; + $this->nextPage = $page->nextCursor; + $this->loaded = true; + } + + private function find(string $id): ?ScheduledCommit + { + foreach ($this->scheduled as $scheduled) { + if ($scheduled->id === $id) { + return $scheduled; + } + } + + return null; + } +} diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php index 41c9b5ab..1630f33c 100644 --- a/app/NativeComponents/ThisDevice.php +++ b/app/NativeComponents/ThisDevice.php @@ -13,12 +13,14 @@ use App\Devnull\Hsl; use App\Devnull\Lane; use App\Devnull\ResponseCache; +use App\Devnull\Timezone; use App\Devnull\WhoToFollow; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\SignsInWithApple; use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; use App\NativeComponents\Concerns\WritesInBackground; +use Carbon\CarbonImmutable; use Illuminate\View\View; use Native\Mobile\Attributes\On; use Native\Mobile\Edge\NativeComponent; @@ -30,9 +32,9 @@ /** * 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, pastéis mode, Remotes and their settings, the people you mute and - * block, then this phone's - * fingerprint, signing out and deleting the account. + * colour, your timezone, pastéis mode, Remotes and their settings, 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 @@ -105,6 +107,38 @@ class ThisDevice extends NativeComponent public ?string $laneError = null; + /** + * The timezone picked on the screen, which shows until it's saved, or null to leave it unset and use the phone's. + */ + public ?string $chosenTimezone = null; + + /** + * Whether the sheet that lists the timezones is up, and what's typed to find one. It keeps its list while it + * slides away, until iOS says it's gone. + */ + public bool $pickingTimezone = false; + + public bool $listingTimezones = false; + + public string $timezoneQuery = ''; + + /** + * The timezone devnull has for you, and whether your profile shows your local time, as `me/settings` last said. + * Until it has, the timezone is the one this phone remembers. + */ + public ?string $savedTimezone = null; + + public bool $savedShowLocalTime = false; + + /** + * The web's "Show my local time on my profile", as picked on the screen. It only counts with a timezone. + */ + public bool $showLocalTime = false; + + public bool $savingTimezone = false; + + public ?string $timezoneError = null; + public bool $savingPasteisMode = false; public ?string $pasteisModeError = null; @@ -144,6 +178,8 @@ public function mount(): void $this->chosenLane = $this->current->preferredLane; } + $this->savedTimezone = $this->chosenTimezone = app(Account::class)->timezone(); + try { $this->fingerprint = app(DeviceKey::class)->current()->fingerprint(); } catch (KeychainUnavailable $exception) { @@ -164,6 +200,8 @@ protected function revalidate(): void $this->appleLinked = CurrentUser::fromArray($json)->appleLinked; } }); + + $this->refresh(Client::SettingsPath, [], fn (array $json) => $this->takeSettings(is_array($json['data'] ?? null) ? $json['data'] : []), quiet: true); } protected function showsContent(): bool @@ -305,6 +343,92 @@ public function setShowsWhoToFollow(bool $shows): void app(WhoToFollow::class)->show($this->me()?->id, $shows); } + /** + * Opens the sheet that lists every timezone, as the web's picker opens its list. + */ + public function pickTimezone(): void + { + $this->timezoneQuery = ''; + $this->pickingTimezone = true; + $this->listingTimezones = true; + } + + /** + * A timezone tapped in the sheet: the section shows it, and the sheet goes. Nothing changes on devnull until it's saved. + */ + public function chooseTimezone(string $timezone): void + { + if (! Timezone::isKnown($timezone)) { + return; + } + + $this->chosenTimezone = $timezone; + $this->timezoneError = null; + $this->pickingTimezone = false; + } + + /** + * The web's "Use … from this browser", with the phone's timezone. + */ + public function usePhoneTimezone(): void + { + if (($phone = app(Timezone::class)->phone()) !== null) { + $this->chosenTimezone = $phone; + $this->timezoneError = null; + } + } + + /** + * The web's Unset: no timezone, so the phone's is used and none is shared, nor your local time. + */ + public function unsetTimezone(): void + { + $this->chosenTimezone = null; + $this->showLocalTime = false; + $this->timezoneError = null; + } + + /** + * The web's "Show my local time on my profile". Nothing changes on devnull until the timezone is saved. + */ + public function setShowLocalTime(bool $shows): void + { + $this->showLocalTime = $shows && $this->chosenTimezone !== null; + $this->timezoneError = null; + } + + public function timezoneSheetClosed(): void + { + $this->pickingTimezone = false; + $this->listingTimezones = false; + } + + /** + * Saves the timezone picked, or that there's none, with whether your + * profile shows your local time, as the web's Save timezone does. Once + * devnull has it, this phone remembers it, so times show in it at once. + */ + public function saveTimezone(): void + { + if ($this->savingTimezone || $this->current === null || ! $this->timezoneChanged()) { + return; + } + + $timezone = $this->chosenTimezone; + $showsLocalTime = $timezone !== null && $this->showLocalTime; + $this->savingTimezone = true; + $this->timezoneError = null; + + $this->writeInBackground('PUT', Client::TimezonePath, ['timezone' => $timezone, 'show_local_time' => $showsLocalTime], function (array $json) use ($timezone, $showsLocalTime): void { + $this->savingTimezone = false; + $this->takeMe($json); + $this->takeSettings(['timezone' => $timezone, 'show_local_time' => $showsLocalTime]); + }, function (string $message): void { + $this->savingTimezone = false; + $this->timezoneError = $message; + }); + } + /** * The people you mute and block, as the web's Safety settings list them. */ @@ -579,6 +703,9 @@ public function render(): View 'previewLane' => $this->chosenLane ?? $current?->automaticLane, 'lanes' => $current === null ? [] : $this->laneOptions($current), 'laneChanged' => $current !== null && $this->chosenLane !== $current->preferredLane, + 'phoneTimezone' => app(Timezone::class)->phone(), + 'timezoneChanged' => $current !== null && $this->timezoneChanged(), + 'timezones' => $this->listingTimezones ? $this->timezones() : ['zones' => [], 'more' => 0], 'pasteisMode' => $current?->pasteisMode ?? false, 'showsWhoToFollow' => app(WhoToFollow::class)->shows($this->me()?->id), 'remotes' => $current?->remotes, @@ -648,6 +775,81 @@ private function laneOptions(CurrentUser $current): array return $options; } + /** + * Takes your timezone, and whether your profile shows your local time, as + * devnull has them. What's picked and not saved yet stays picked; + * otherwise each follows devnull. This phone remembers the timezone, so + * times show in it. + * + * @param array $settings + */ + private function takeSettings(array $settings): void + { + $timezone = Timezone::isKnown($settings['timezone'] ?? null) ? $settings['timezone'] : null; + $showsLocalTime = $timezone !== null && ($settings['show_local_time'] ?? false) === true; + + if ($this->chosenTimezone === $this->savedTimezone && $this->showLocalTime === $this->savedShowLocalTime) { + $this->chosenTimezone = $timezone; + $this->showLocalTime = $showsLocalTime; + } + + $this->savedTimezone = $timezone; + $this->savedShowLocalTime = $showsLocalTime; + app(Account::class)->rememberTimezone($timezone); + } + + /** + * Whether what's picked differs from what devnull has, so Save timezone has something to save. + */ + private function timezoneChanged(): bool + { + return $this->chosenTimezone !== $this->savedTimezone + || ($this->chosenTimezone !== null && $this->showLocalTime !== $this->savedShowLocalTime); + } + + /** + * The most timezones the sheet lists at once. All of them would make a + * frame bigger than EDGE takes. + */ + public const int TimezonesShown = 50; + + /** + * The timezones the sheet lists, by how far each is from UTC now and then + * by name, as the web's picker lists them. Before anything is typed, + * those at the phone's offset, with the one picked first. Then the ones + * whose name or city has every word typed, up to TimezonesShown, and how + * many more there are. + * + * @return array{zones: list, more: int} + */ + private function timezones(): array + { + $words = array_filter(explode(' ', strtolower(str_replace(['_', '/'], ' ', trim($this->timezoneQuery))))); + $now = CarbonImmutable::now(); + $here = $now->setTimezone(app(Timezone::class)->here())->getOffset(); + $zones = []; + + foreach (Timezone::all() as $name) { + $offset = $now->setTimezone($name)->getOffset(); + $haystack = strtolower(str_replace(['_', '/'], ' ', $name)); + + $listed = $words === [] + ? $offset === $here || $name === $this->chosenTimezone + : array_filter($words, fn (string $word): bool => ! str_contains($haystack, $word)) === []; + + if ($listed) { + $zones[] = ['name' => $name, 'offset' => Timezone::offset($name, $now), 'city' => Timezone::city($name), 'order' => [$name === $this->chosenTimezone && $words === [] ? 0 : 1, $offset, $name]]; + } + } + + usort($zones, fn (array $a, array $b): int => $a['order'] <=> $b['order']); + + return [ + 'zones' => array_map(fn (array $zone): array => ['name' => $zone['name'], 'offset' => $zone['offset'], 'city' => $zone['city']], array_slice($zones, 0, self::TimezonesShown)), + 'more' => max(0, count($zones) - self::TimezonesShown), + ]; + } + /** * The custom colour you're previewing, or else the one you have. */ @@ -663,8 +865,8 @@ private function customColour(): ?string } /** - * Shows you as devnull sent you. A colour picked and not saved yet stays - * picked; otherwise the picker follows the one devnull has. While the + * Shows you as devnull sent you. A colour or timezone picked and not saved + * yet stays picked; otherwise each follows the one devnull has. While the * pastéis or Remotes switches are saving, they keep what they show. * Whether Apple is linked only comes with `GET /me`, so it's left to the * refresh. diff --git a/resources/icons/clock-3.svg b/resources/icons/clock-3.svg new file mode 100644 index 00000000..7572241c --- /dev/null +++ b/resources/icons/clock-3.svg @@ -0,0 +1 @@ + diff --git a/resources/views/native/partials/commit-row-content.blade.php b/resources/views/native/partials/commit-row-content.blade.php index 948dd2a8..553ac21e 100644 --- a/resources/views/native/partials/commit-row-content.blade.php +++ b/resources/views/native/partials/commit-row-content.blade.php @@ -7,8 +7,8 @@ commit alone in its run ends in a dot of its own, and with replies under the row ($threadBelow) a dashed line in the lane carries on down to them, as the web draws it. - On a commit page the commit it's about ($focused) is the main thing: a larger name and body in full ink, its git - date and hash, and its replies behind a toggle in its action row. The commits around it ($muted) step back: + On a commit page the commit it's about ($focused) is the main thing: a larger name and body in full ink, its time, + which tapped says when in your timezone, its hash, and its replies behind a toggle in its action row. The commits around it ($muted) step back: smaller and in muted ink, with a smaller avatar and action row. They still open their own pages. A commit you just made that devnull doesn't have yet is staged: dimmed, with the staged chip and no actions, until @@ -37,7 +37,14 @@ {{ $author->name }} {{ $author->handle() }} · - {{ \App\Devnull\Display::relativeTime($commit->createdAt) }} + @if ($focused && $commit->createdAt) + {{-- Tapped, it says when in your timezone, as the web's tooltip does on hover. --}} + + {{ \App\Devnull\Display::relativeTime($commit->createdAt) }} + + @else + {{ \App\Devnull\Display::relativeTime($commit->createdAt) }} + @endif @if ($commit->isSeriesPart()) @include('native.partials.series-position') @endif diff --git a/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php index 18e57f6a..71a7d2e1 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -28,6 +28,12 @@ stash@{0} and the counter on the right. An issue draft has no tools, as on the web. With photos, Commit spins while they upload, and no body at all will do. + A new commit can be scheduled instead, with the web's clock button among the tools: "Submit at" opens under the + field with the system's date and time picker, in the phone's timezone, within the 30 days devnull allows, and the + web's lines on what scheduling means and Scheduled commits →. The bar's check becomes Schedule commit, and waits for + a time too. As on the web, an issue can't be scheduled, so neither shows the other. devnull's app API schedules a + body alone, so the clock greys out while there are photos or a poll, and they grey out while it's on. + It's full height for the forms, and short for the receipts. It's always on the screen, shown or not, so iOS can slide it away and say when it's gone. @@ -49,7 +55,8 @@ @if ($issueCommand) @include('native.partials.sheet-bar-button', ['ref' => 'sheet-open-issue', 'icon' => \App\Icons\Lucide::Check, 'label' => 'Open issue', 'main' => true, 'loading' => $openingIssue, 'press' => $openingIssue || ! \App\Devnull\IssueCommand::fits($sheetBody) ? null : 'openIssueFromSheet']) @else - @include('native.partials.sheet-bar-button', ['ref' => 'sheet-commit', 'icon' => \App\Icons\Lucide::Check, 'label' => $replying ? 'Reply' : 'Commit', 'main' => true, 'press' => $canCommit ? 'commitFromSheet' : null]) + @php($schedulingThis = $scheduling && $sheetView === 'compose') + @include('native.partials.sheet-bar-button', ['ref' => 'sheet-commit', 'icon' => \App\Icons\Lucide::Check, 'label' => match (true) { $schedulingThis => 'Schedule commit', $replying => 'Reply', default => 'Commit' }, 'main' => true, 'loading' => $sendingSchedule, 'press' => $sendingSchedule || ! $canCommit || ($schedulingThis && ! $hasScheduledTime) ? null : 'commitFromSheet']) @endif @@ -96,6 +103,8 @@ class="w-full text-theme-ink" @if ($quoting) @include('native.partials.quoted-commit', ['quoted' => $sheetCommit, 'static' => true]) + @elseif ($scheduling) + @include('native.partials.schedule-picker') @elseif (! $replying && ! $issueCommand) @include('native.partials.issue-suggestions', [ 'suggestions' => $sheetTyped ? \App\Devnull\IssueCommand::suggested($sheetBody) : [], diff --git a/resources/views/native/partials/composer-tools.blade.php b/resources/views/native/partials/composer-tools.blade.php index 3be68680..a63ed152 100644 --- a/resources/views/native/partials/composer-tools.blade.php +++ b/resources/views/native/partials/composer-tools.blade.php @@ -1,6 +1,6 @@ {{-- The composer's tools, on the left of the toolbar pinned to the bottom of the sheet: photos from the library, a photo - from the camera, a poll, and the preview. Up to three photos, or a poll, + from the camera, a poll, the clock that schedules a new commit, and the preview. Up to three photos, or a poll, not both, as on the web, so each greys out while the other is there; a reply has no poll at all. --}} @php($photosFull = count($sheetImages) >= \App\Devnull\DraftImages::MaxImages) @@ -10,5 +10,8 @@ @unless ($sheetView === 'reply') @include('native.partials.composer-tool', ['ref' => 'sheet-poll', 'icon' => \App\Icons\Lucide::ChartBar, 'label' => $sheetImages === [] ? 'Add poll' : 'A commit can have images or a poll, not both', 'press' => 'addPoll', 'disabled' => $sheetImages !== [] || $sheetPoll !== null]) @endunless + @if ($sheetView === 'compose') + @include('native.partials.composer-tool', ['ref' => 'sheet-schedule', 'icon' => \App\Icons\Lucide::Clock3, 'label' => $scheduling ? "Don't schedule" : 'Schedule for later', 'press' => 'toggleSchedule', 'active' => $scheduling, 'disabled' => $sendingSchedule]) + @endif @include('native.partials.composer-tool', ['ref' => 'sheet-preview', 'icon' => \App\Icons\Lucide::Eye, 'label' => $previewing ? 'Edit' : 'Preview', 'press' => 'togglePreview', 'active' => $previewing]) diff --git a/resources/views/native/partials/schedule-picker.blade.php b/resources/views/native/partials/schedule-picker.blade.php new file mode 100644 index 00000000..ec3dee52 --- /dev/null +++ b/resources/views/native/partials/schedule-picker.blade.php @@ -0,0 +1,28 @@ +{{-- + The web's "Submit at", under a new commit's field once its clock button is on: the system's date and time picker + (a compact one on iOS, which opens the calendar and the time over the sheet), in the phone's timezone, from now to + the 30 days devnull allows. Under it, the timezone with its offset at the time picked, and the web's lines on what + scheduling means, then Scheduled commits →, which opens the list of them. +--}} +@php($scheduleTimezone = app(\App\Devnull\Timezone::class)->here()) +@php($scheduleNow = \Carbon\CarbonImmutable::now($scheduleTimezone)) +@php($pickedAt = $scheduledAt === '' ? null : \Carbon\CarbonImmutable::parse($scheduledAt, $scheduleTimezone)) + + Submit at + + {{ $scheduleTimezone }} ({{ \App\Devnull\Timezone::offset($scheduleTimezone, $pickedAt) }}). Within 30 days. Private until submitted; then submitted for review, not published automatically. + + Scheduled commits → + + diff --git a/resources/views/native/partials/timezone-sheet.blade.php b/resources/views/native/partials/timezone-sheet.blade.php new file mode 100644 index 00000000..f4769719 --- /dev/null +++ b/resources/views/native/partials/timezone-sheet.blade.php @@ -0,0 +1,47 @@ +{{-- + The web's timezone picker, as a sheet: a field to find one by name or city, then the timezones by how far they are + from UTC now, with that offset in mono and the city under each. Before anything is typed it lists the ones at the + phone's offset, and after, at most 50 matches, saying when there are more: all of them at once would be a frame + bigger than EDGE takes. The one picked has a check. Tapping one picks it and the sheet goes; the Account screen + saves it. No press-* feedback on the rows: it would keep the list from scrolling. +--}} + + + + Timezone + + + + + @forelse ($timezones['zones'] as $zone) + @if (! $loop->first) + + @endif + + + + @if ($zone['name'] === $chosenTimezone) + + @endif + + + + {{ $zone['offset'] }} + {{ $zone['name'] }} + + {{ $zone['city'] }} + + + + @empty + No timezone matches "{{ trim($timezoneQuery) }}". + @endforelse + @if ($timezones['more'] > 0) + {{ $timezones['more'] }} more. Keep typing to find yours. + @elseif (trim($timezoneQuery) === '' && $timezones['zones'] !== []) + Type a city or region to find another. + @endif + + + + diff --git a/resources/views/native/scheduled-commits.blade.php b/resources/views/native/scheduled-commits.blade.php new file mode 100644 index 00000000..06f07ac1 --- /dev/null +++ b/resources/views/native/scheduled-commits.blade.php @@ -0,0 +1,95 @@ +@use('App\Devnull\Display') +@use('App\Icons\Lucide') + +{{-- + Laid out like the web's Scheduled commits page: its lines on what the list is, and the timezone its times are in, + then a row each, split by hairlines. A row says where it's at and when it's due, then its body (its start, with + Show more, when it's long), its poll's options and how many images it has. One still waiting has the web's Commit + now and its Cancel, a bin, and its line on what happens next. One submitted says what became of it. No press-* + feedback on the rows: it would keep the screen from scrolling. +--}} + + + + @include('native.partials.error') + @include('native.partials.refresh-failed') + + + + Only you can see this list. At the scheduled time, each commit goes through the usual review process. Scheduling is not approval. + + + Times in {{ $timezone }} + + + + @if (! $loaded) + @if ($error === null) + @foreach (range(1, 2) as $skeleton) + + + + + + + @endforeach + @endif + @elseif ($scheduled === []) + + + No scheduled commits + Choose Schedule in the composer to submit later. + + @else + @foreach ($scheduled as $item) + + + + {{ $item->label($now) }} + {{ Display::scheduleTime($item->scheduledAt, $timezone) }} + + @if ($item->isWaiting()) + @php($when = Display::scheduleTime($item->scheduledAt, $timezone)) + Commit now + + + + + + @endif + + + @if ($item->content !== []) + + @include('native.partials.commit-body', ['commit' => $item, 'size' => 'text-[15px]']) + + @endif + + @if ($item->pollOptions) + + @foreach ($item->pollOptions as $option) + {{ $option }} + @endforeach + + @endif + + @if ($item->imageCount > 0) + {{ Display::count($item->imageCount, 'image', 'images') }} + @endif + + @if ($item->isWaiting()) + {{ $item->isDue($now) ? 'Waiting for the scheduler. If submission fails, it is retried automatically.' : 'Private until submitted. You can commit it now or cancel it before submission.' }} + @endif + + + @endforeach + + @if ($nextPage !== null) + + Show more + + @endif + @endif + + + diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index 5fdc6572..cededaeb 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -113,6 +113,59 @@ + {{-- + The web's Timezone section: the timezone picked, or null while it's unset and the phone's is used, + the web's "Show my local time on my profile" once there is one, then Choose timezone, which opens + the list, the web's "Use … from this browser" with the phone's, and Unset. Nothing changes on + devnull until Save timezone, in your colour. + --}} + + @include('native.partials.settings-heading', [ + 'title' => 'Timezone', + 'description' => "Tap a commit's time on its page to see it in your timezone. On the web, people who hover a time see it in yours as well as theirs. Leave it unset to use this phone's timezone without sharing it. Your local time only shows on your profile if you turn that on below.", + ]) + + + + timezone + @if ($chosenTimezone === null) + null + @else + {{ \App\Devnull\Timezone::offset($chosenTimezone) }} {{ $chosenTimezone }} + @endif + + @if ($phoneTimezone !== null) + + + this phone + {{ \App\Devnull\Timezone::offset($phoneTimezone) }} {{ $phoneTimezone }} + + @endif + + + {{-- The web's checkbox, as the system's switch, which only counts with a timezone. --}} + @if ($chosenTimezone !== null) + + Show my local time on my profile + + + @endif + + + Choose timezone + @if ($phoneTimezone !== null && $phoneTimezone !== $chosenTimezone) + Use {{ $phoneTimezone }} from this phone + @endif + @if ($chosenTimezone !== null) + Unset + @endif + Save timezone + @if ($timezoneError) + {{ $timezoneError }} + @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. @@ -277,4 +330,8 @@ + + {{-- After the page, so the rows it adds and takes away never change the ids of the page's own nodes, which would + scroll it back to the top. --}} + @include('native.partials.timezone-sheet') diff --git a/routes/mobile.php b/routes/mobile.php index f342f766..c60918d7 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -17,6 +17,7 @@ use App\NativeComponents\Profile; use App\NativeComponents\Remotes; use App\NativeComponents\Safety; +use App\NativeComponents\ScheduledCommits; use App\NativeComponents\Starred; use App\NativeComponents\ThisDevice; use Illuminate\Support\Facades\Route; @@ -36,8 +37,9 @@ Route::native('/profile/starred', Starred::class); // Commits, profiles, their followers, the people you mute and block, and - // Remotes threads open beneath the tab they were tapped in, so that tab's - // own navigation stack pushes them (see Concerns\OpensInTab). + // Remotes threads and your scheduled commits open beneath the tab they were + // tapped in, so that tab's own navigation stack pushes them (see + // Concerns\OpensInTab). foreach (TabsLayout::Tabs as $tab) { Route::native("{$tab}/commits/{id}", CommitDetail::class); Route::native("{$tab}/users/{username}", Profile::class); @@ -48,5 +50,6 @@ Route::native("{$tab}/orgs/{org}", Conversation::class); Route::native("{$tab}/orgs/{org}/members", OrgMembers::class); Route::native("{$tab}/orgs/{org}/members/add", AddOrgMembers::class); + Route::native("{$tab}/scheduled", ScheduledCommits::class); } }); diff --git a/tests/Feature/Devnull/ClientCacheTest.php b/tests/Feature/Devnull/ClientCacheTest.php index 51c0b5e2..dba1838d 100644 --- a/tests/Feature/Devnull/ClientCacheTest.php +++ b/tests/Feature/Devnull/ClientCacheTest.php @@ -1,10 +1,12 @@ ['POST /api/v1/users/ada/block', fn (Client $devnull) => $devnull->block('ada')], 'an answer without the user' => ['POST /api/v1/users/ada/mute', fn (Client $devnull) => $devnull->unmute('ada')], ]); + +it('keeps the first page of your scheduled commits, paged by number, and lets it go when you change one', function (Closure $change) { + serveApi(function (Request $request) { + return match ($request->method().' '.$request->url()) { + 'GET http://devnull.test/api/v1/scheduled-commits' => Http::response(scheduledPageJson([scheduledCommitJson(['id' => 's1'])], nextPage: 2)), + 'GET http://devnull.test/api/v1/scheduled-commits?page=2' => Http::response(scheduledPageJson([scheduledCommitJson(['id' => 's0'])])), + 'POST http://devnull.test/api/v1/scheduled-commits/s1/submission' => Http::response(['data' => scheduledCommitJson(['id' => 's1', 'body' => null, 'submitted_at' => '2026-10-19T15:30:00+00:00', 'status' => 'pending'])]), + 'DELETE http://devnull.test/api/v1/scheduled-commits/s1' => Http::response(null, 204), + default => Http::response(['message' => 'No fake.'], 404), + }; + }); + + $first = $this->devnull->scheduledCommits(); + + expect($first->nextCursor)->toBe('2') + ->and($this->devnull->scheduledCommits($first->nextCursor)->items[0]->id)->toBe('s0') + ->and(array_column(kept(Client::ScheduledCommitsPath)['data'], 'id'))->toBe(['s1']) + ->and(kept(Client::ScheduledCommitsPath, ['page' => '2']))->toBeNull(); + + $change($this->devnull); + + expect(kept(Client::ScheduledCommitsPath))->toBeNull(); +})->with([ + 'committing one now' => [fn (Client $devnull) => expect($devnull->submitScheduledCommit('s1')->status)->toBe('pending')], + 'canceling one' => [fn (Client $devnull) => $devnull->cancelScheduledCommit('s1')], +]); + +it('signs a commit to schedule as JSON, in UTC with its offset, and with its poll', function () { + $request = $this->devnull->signedScheduledCommit('Tabs or spaces?', CarbonImmutable::parse('2026-10-20 09:00', 'Europe/Lisbon'), poll: ['options' => ['Tabs', 'Spaces'], 'duration' => 4320]); + + expect($request->url)->toBe('http://devnull.test/api/v1/scheduled-commits') + ->and($request->method)->toBe('POST') + ->and($request->body)->toBe('{"body":"Tabs or spaces?","scheduled_at":"2026-10-20T08:00:00+00:00","poll":{"options":["Tabs","Spaces"],"duration":4320}}'); +}); + +it('signs a commit to schedule with photos as multipart, as a commit with photos', function () { + $photo = new DraftImage(testPhoto('png'), 'image/png', 40, 30, 'A cabin.'); + + $request = $this->devnull->signedScheduledCommit('', CarbonImmutable::parse('2026-10-20T08:00:00Z'), [$photo]); + + expect($request->url)->toBe('http://devnull.test/api/v1/scheduled-commits') + ->and($request->contentType)->toStartWith('multipart/form-data; boundary=devnull-') + ->and($request->body)->toContain('{"body":"","scheduled_at":"2026-10-20T08:00:00+00:00","image_descriptions":["A cabin."]}') + ->and($request->body)->toContain('name="images[]"; filename="image-1.png"'); +}); diff --git a/tests/Feature/Devnull/TimezoneTest.php b/tests/Feature/Devnull/TimezoneTest.php new file mode 100644 index 00000000..05915ba6 --- /dev/null +++ b/tests/Feature/Devnull/TimezoneTest.php @@ -0,0 +1,148 @@ + 'http://devnull.test']); +}); + +/** + * A localtime link in a scratch folder, pointing where $target says. + */ +function localtimeLinkingTo(string $target): string +{ + $directory = sys_get_temp_dir().'/devnull-localtime-'.bin2hex(random_bytes(4)); + mkdir($directory); + symlink($target, $directory.'/localtime'); + + return $directory.'/localtime'; +} + +it("reads the phone's timezone from where localtime links, through any links on the way", function (string $target, ?string $expected) { + expect((new Timezone(localtimeLinkingTo($target)))->phone())->toBe($expected); +})->with([ + 'macOS and the simulator' => ['/var/db/timezone/zoneinfo/Atlantic/Canary', 'Atlantic/Canary'], + 'with a region' => ['/usr/share/zoneinfo/America/Argentina/Buenos_Aires', 'America/Argentina/Buenos_Aires'], + 'not a timezone PHP knows' => ['/var/db/timezone/zoneinfo/Mars/Olympus_Mons', null], + 'nowhere useful' => ['/tmp/somewhere-else', null], +]); + +it('follows the link iOS puts in between, as phoneIn() does', function () { + phoneIn('Asia/Kathmandu'); + + expect(app(Timezone::class)->phone())->toBe('Asia/Kathmandu'); +}); + +it('has no phone timezone without a localtime link, as on Android', function () { + expect((new Timezone('/nonexistent'))->phone())->toBeNull(); +}); + +it("shows your times in the timezone you chose, or else the phone's, as the web falls back to the browser's", function () { + phoneIn('Europe/Lisbon'); + + expect(app(Timezone::class)->yours())->toBe('Europe/Lisbon') + ->and(app(Timezone::class)->here())->toBe('Europe/Lisbon'); + + app(Account::class)->rememberTimezone('Asia/Tokyo'); + + expect(app(Timezone::class)->yours())->toBe('Asia/Tokyo') + // A time to schedule is still picked where the phone is. + ->and(app(Timezone::class)->here())->toBe('Europe/Lisbon'); +}); + +it("falls back to the timezone you chose, then UTC, when the phone's can't be told", function () { + expect(app(Timezone::class)->here())->toBe('UTC') + ->and(app(Timezone::class)->yours())->toBe('UTC'); + + app(Account::class)->rememberTimezone('Asia/Tokyo'); + + expect(app(Timezone::class)->here())->toBe('Asia/Tokyo') + ->and(app(Timezone::class)->yours())->toBe('Asia/Tokyo'); +}); + +it('forgets the timezone you chose when you sign out', function () { + app(Account::class)->rememberTimezone('Asia/Tokyo'); + app(Account::class)->forget(); + + expect(app(Account::class)->timezone())->toBeNull(); +}); + +it("prints offsets and cities as the web's picker and tooltip do", function () { + $summer = CarbonImmutable::parse('2026-07-01 12:00:00', 'UTC'); + + expect(Timezone::offset('Europe/Lisbon', $summer))->toBe('GMT+01:00') + ->and(Timezone::offset('America/Sao_Paulo', $summer))->toBe('GMT-03:00') + ->and(Timezone::offset('Asia/Kathmandu', $summer))->toBe('GMT+05:45') + ->and(Timezone::offset('UTC', $summer))->toBe('GMT+00:00') + ->and(Timezone::city('America/Sao_Paulo'))->toBe('Sao Paulo') + ->and(Timezone::city('America/Argentina/Buenos_Aires'))->toBe('Buenos Aires') + ->and(Timezone::city('UTC'))->toBe('UTC'); +}); + +it("prints a commit's time in your timezone, and a scheduled one's where the phone is", function () { + phoneIn('Europe/Lisbon'); + app(Account::class)->rememberTimezone('Asia/Tokyo'); + $date = CarbonImmutable::parse('2026-10-14T20:13:05Z'); + + expect(Display::fullDateTime($date))->toBe('5:13 AM · Oct 15, 2026') + ->and(Display::scheduleTime($date))->toBe('Oct 14, 2026, 9:13 PM'); +}); + +it("counts the Log's days in the phone's timezone, as the web counts them in the browser's", function (?string $phone, string $firstOfYesterday) { + if ($phone !== null) { + phoneIn($phone); + } + + signIn(); + $this->travelTo(CarbonImmutable::parse('2026-10-15T12:00:00Z')); + + Http::fake(['devnull.test/*' => Http::response(pageJson([ + commitJson(['id' => 'c2', 'created_at' => '2026-10-15T11:00:00Z']), + // 01:00 on the 15th in Tokyo, but still the 14th in UTC. + commitJson(['id' => 'c1', 'created_at' => '2026-10-14T16:00:00Z']), + commitJson(['id' => 'c0', 'created_at' => '2026-10-14T10:00:00Z']), + ]))]); + + $json = (string) json_encode(Native::visit('/log')->tree()); + $yesterday = strpos($json, '"day-Yesterday"'); + + expect($yesterday)->toBeLessThan(strpos($json, '"commit-'.$firstOfYesterday.'"')) + ->and($yesterday)->toBeGreaterThan(strpos($json, '"commit-'.($firstOfYesterday === 'c1' ? 'c2' : 'c1').'"')); +})->with([ + 'in Tokyo' => ['Asia/Tokyo', 'c0'], + 'where the phone can\'t be told, in UTC' => [null, 'c1'], +]); + +it('picks up the timezone you chose on the web from your settings, when the Log opens', function () { + signIn(); + app(Account::class)->rememberTimezone('Asia/Tokyo'); + Http::fake([ + 'devnull.test/api/v1/me/settings' => Http::response(['data' => ['timezone' => 'Europe/Lisbon', 'show_local_time' => true]]), + 'devnull.test/*' => Http::response(pageJson([])), + ]); + + Native::visit('/log'); + + expect(app(Account::class)->timezone())->toBe('Europe/Lisbon'); +}); + +it('forgets the timezone once your settings say it is unset, and keeps it when they cannot be read', function (int $status, ?string $kept) { + signIn(); + app(Account::class)->rememberTimezone('Asia/Tokyo'); + Http::fake([ + 'devnull.test/api/v1/me/settings' => Http::response($status === 200 ? ['data' => ['timezone' => null, 'show_local_time' => false]] : ['message' => 'Server Error'], $status), + 'devnull.test/*' => Http::response(pageJson([])), + ]); + + Native::visit('/log'); + + expect(app(Account::class)->timezone())->toBe($kept); +})->with([ + 'unset' => [200, null], + 'unreadable' => [500, 'Asia/Tokyo'], +]); diff --git a/tests/Feature/Screens/AccountProfileTest.php b/tests/Feature/Screens/AccountProfileTest.php index af6c10d2..d91cd9de 100644 --- a/tests/Feature/Screens/AccountProfileTest.php +++ b/tests/Feature/Screens/AccountProfileTest.php @@ -174,9 +174,9 @@ function mutedMono(array $node): bool $screen = Native::visit('/profile/device')->tap('sync-github')->tap('sync-github'); - // Signed on the UI thread and sent in the background, once, while the button spins. + // Signed on the UI thread and sent in the background, once, while the button spins, after you and your settings. expect(nodeByRef($screen, 'sync-github')['props'])->toMatchArray(['label' => 'Sync from GitHub', 'loading' => true, 'disabled' => true]) - ->and(refreshesSent($screen))->toBe(2) + ->and(refreshesSent($screen))->toBe(3) ->and(count(Http::recorded()))->toBe(0); $screen->assertSet('syncing', true)->assertSee('Old Name'); diff --git a/tests/Feature/Screens/AccountTimezoneTest.php b/tests/Feature/Screens/AccountTimezoneTest.php new file mode 100644 index 00000000..c5bd28e1 --- /dev/null +++ b/tests/Feature/Screens/AccountTimezoneTest.php @@ -0,0 +1,241 @@ + 'http://devnull.test']); + signIn(); + $this->travelTo(CarbonImmutable::parse('2026-10-19 15:30:00', 'UTC')); +}); + +/** + * The `PUT /me/timezone` requests sent, as their bodies. + * + * @return list + */ +function timezoneSaves(): array +{ + return collect(Http::recorded()) + ->filter(fn (array $sent): bool => $sent[0]->method() === 'PUT' && str_ends_with($sent[0]->url(), Client::TimezonePath)) + ->map(fn (array $sent): string => $sent[0]->body()) + ->values() + ->all(); +} + +/** + * `GET /me` and `GET /me/settings` as devnull answers them, with the timezone and local time given, and saving one + * answered as devnull answers it: like `GET /me`, which doesn't carry the timezone. + * + * @param array{0: mixed, 1?: int}|null $save + */ +function serveSettings(?string $timezone = null, bool $showLocalTime = false, ?array $save = null): void +{ + serveApi([ + 'GET /api/v1/me' => [currentUserJson()], + 'GET /api/v1/me/settings' => [['data' => ['timezone' => $timezone, 'show_local_time' => $showLocalTime, 'translation_language' => null, 'automatic_translation' => true, 'translation_languages' => [], 'is_admin' => false]]], + 'PUT /api/v1/me/timezone' => $save ?? [currentUserJson()], + ]); +} + +/** + * The timezones listed in the sheet, top to bottom. + * + * @return list + */ +function timezoneRows(TestableComponent $screen): array +{ + return array_map( + fn (array $node): string => substr($node['ref'], strlen('timezone-')), + findNodes(nodeByRef($screen, 'timezone-list'), fn (array $node): bool => str_starts_with((string) ($node['ref'] ?? ''), 'timezone-') && ! in_array($node['ref'], ['timezone-list', 'timezone-none', 'timezone-more', 'timezone-hint'], true)), + ); +} + +it("has the web's Timezone section after Color, unset until you choose one, with the phone's beside it", function () { + phoneIn('Atlantic/Canary'); + serveSettings(); + + $screen = Native::visit('/profile/device') + ->assertSee('Timezone') + ->assertSee("Leave it unset to use this phone's timezone without sharing it. Your local time only shows on your profile if you turn that on below.") + ->assertSee('GMT+01:00 Atlantic/Canary') + ->assertSee('Use Atlantic/Canary from this phone') + ->assertDontSee('Show my local time on my profile'); + + expect(shownText($screen))->toContain('null') + ->and(nodeByRef($screen, 'save-timezone')['props']['disabled'] ?? false)->toBeTrue() + ->and(findNodes($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'unset-timezone'))->toBe([]) + ->and(array_column(findNodes($screen->tree(), fn (array $node): bool => in_array($node['ref'] ?? null, ['color', 'timezone', 'pasteis-mode'], true)), 'ref'))->toBe(['color', 'timezone', 'pasteis-mode']); +}); + +it('shows the timezone devnull has in your settings, and whether your profile shows your local time', function () { + serveSettings('Asia/Tokyo', showLocalTime: true); + + $screen = Native::visit('/profile/device') + ->assertSet('chosenTimezone', 'Asia/Tokyo') + ->assertSet('showLocalTime', true) + ->assertSee('GMT+09:00 Asia/Tokyo') + ->assertSee('Show my local time on my profile'); + + expect(nodeByRef($screen, 'show-local-time-switch')['props']['value'] ?? null)->toBeTrue() + ->and(app(Account::class)->timezone())->toBe('Asia/Tokyo'); +}); + +it("uses the phone's timezone, saves it, and remembers it for the times the app shows", function () { + phoneIn('Atlantic/Canary'); + serveSettings(); + + $screen = Native::visit('/profile/device') + ->tap('use-phone-timezone') + ->assertSet('chosenTimezone', 'Atlantic/Canary'); + + expect(nodeByRef($screen, 'save-timezone')['props']['disabled'] ?? false)->toBeFalse(); + + $screen->tap('save-timezone') + ->assertSet('savingTimezone', false) + ->assertSet('timezoneError', null) + ->assertSet('savedTimezone', 'Atlantic/Canary') + ->assertDontSee('Use Atlantic/Canary from this phone'); + + // The web's form sends whether to show your local time with it, which devnull would otherwise turn off. + expect(timezoneSaves())->toBe(['{"timezone":"Atlantic/Canary","show_local_time":false}']) + ->and(app(Account::class)->timezone())->toBe('Atlantic/Canary') + ->and(nodeByRef($screen, 'save-timezone')['props']['disabled'] ?? false)->toBeTrue(); +}); + +it('saves showing your local time on your profile, with the timezone', function () { + serveSettings('Asia/Tokyo'); + + $screen = Native::visit('/profile/device')->toggle('show-local-time-switch', true); + + expect(nodeByRef($screen, 'save-timezone')['props']['disabled'] ?? false)->toBeFalse(); + + $screen->tap('save-timezone')->assertSet('savedShowLocalTime', true); + + expect(timezoneSaves())->toBe(['{"timezone":"Asia/Tokyo","show_local_time":true}']); +}); + +it("lists the timezones at the phone's offset first, with the one picked on top", function () { + phoneIn('Europe/Lisbon'); + serveSettings('Asia/Tokyo'); + + $screen = Native::visit('/profile/device')->tap('choose-timezone')->assertSet('pickingTimezone', true); + $rows = timezoneRows($screen); + + expect($rows[0])->toBe('Asia/Tokyo') + ->and($rows)->toContain('Europe/Lisbon', 'Europe/London', 'Atlantic/Canary') + ->and($rows)->not->toContain('Europe/Paris') + ->and(findNode(nodeByRef($screen, 'timezone-Asia/Tokyo'), fn (array $node): bool => ($node['type'] ?? null) === 'icon'))->not->toBeNull(); + + $screen->assertSee('Type a city or region to find another.'); +}); + +it('finds a timezone by its name or city in the sheet, and picks it', function () { + serveSettings('Europe/Lisbon'); + + $screen = Native::visit('/profile/device') + ->assertSee('GMT+01:00 Europe/Lisbon') + ->tap('choose-timezone') + ->input('timezone-search', 'sao paulo'); + + expect(timezoneRows($screen))->toBe(['America/Sao_Paulo']); + + $screen->tap('timezone-America/Sao_Paulo') + ->assertSet('pickingTimezone', false) + ->assertSet('chosenTimezone', 'America/Sao_Paulo') + ->assertSee('GMT-03:00 America/Sao_Paulo') + ->tap('save-timezone'); + + expect(timezoneSaves())->toBe(['{"timezone":"America/Sao_Paulo","show_local_time":false}']) + ->and(app(Account::class)->timezone())->toBe('America/Sao_Paulo'); +}); + +it('lists at most 50 matches, by offset then name, and says how many more there are', function () { + serveSettings(); + + $screen = Native::visit('/profile/device')->tap('choose-timezone')->input('timezone-search', 'america'); + $all = array_values(array_filter(Timezone::all(), fn (string $name): bool => str_contains(strtolower($name), 'america'))); + $rows = timezoneRows($screen); + + expect($rows)->toHaveCount(ThisDevice::TimezonesShown) + ->and($rows[0])->toBe('America/Adak') + ->and(count($all))->toBeGreaterThan(ThisDevice::TimezonesShown); + + $screen->assertSee((count($all) - ThisDevice::TimezonesShown).' more. Keep typing to find yours.'); +}); + +it('says when no timezone matches', function () { + serveSettings(); + + Native::visit('/profile/device') + ->tap('choose-timezone') + ->input('timezone-search', 'Mars/Olympus') + ->assertSee('No timezone matches "Mars/Olympus".'); +}); + +it("unsets the timezone, and showing your local time with it, so the phone's is used again", function () { + app(Account::class)->rememberTimezone('Europe/Lisbon'); + serveSettings('Europe/Lisbon', showLocalTime: true); + + Native::visit('/profile/device') + ->tap('unset-timezone') + ->assertSet('chosenTimezone', null) + ->assertSet('showLocalTime', false) + ->assertDontSee('Show my local time on my profile') + ->tap('save-timezone'); + + expect(timezoneSaves())->toBe(['{"timezone":null,"show_local_time":false}']) + ->and(app(Account::class)->timezone())->toBeNull(); +}); + +it('keeps the timezone picked, and says why, when devnull does not take it', function () { + serveSettings(save: [['message' => 'The timezone field must be a valid timezone.', 'errors' => ['timezone' => ['The timezone field must be a valid timezone.']]], 422]); + + Native::visit('/profile/device') + ->call('chooseTimezone', 'Europe/Kyiv') + ->tap('save-timezone') + ->assertSet('chosenTimezone', 'Europe/Kyiv') + ->assertSee('The timezone field must be a valid timezone.'); + + expect(app(Account::class)->timezone())->toBeNull(); +}); + +it('keeps a timezone picked and not saved yet when your settings come in', function () { + $bridge = holdRefreshes(); + serveSettings('Asia/Tokyo'); + + $screen = Native::visit('/profile/device')->call('chooseTimezone', 'Europe/Lisbon'); + + finishRefreshes($screen) + ->assertSet('chosenTimezone', 'Europe/Lisbon') + ->assertSet('savedTimezone', 'Asia/Tokyo'); +}); + +it('ignores a timezone it does not know', function () { + serveSettings(); + + Native::visit('/profile/device')->call('chooseTimezone', 'Mars/Olympus_Mons')->assertSet('chosenTimezone', null); +}); + +it('keeps the list in the sheet while it slides away, and lets go of it once iOS says it is gone', function () { + phoneIn('Europe/Lisbon'); + serveSettings(); + + $screen = Native::visit('/profile/device') + ->tap('choose-timezone') + ->tap('timezone-Europe/Lisbon') + ->assertSet('pickingTimezone', false) + ->assertSet('listingTimezones', true); + + expect(timezoneRows($screen))->toContain('Europe/Lisbon'); + + $screen->dismissSheet('timezone-sheet')->assertSet('listingTimezones', false); + + expect(timezoneRows($screen))->toBe([]); +}); diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index 07003e83..28f34079 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -199,6 +199,19 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): expect($hash['text'] ?? $hash['props']['text'] ?? null)->toBe('c2'); }); +it('says when the commit was made, in your timezone, when its time is tapped, as the web does on hover', function (?string $chosen, string $toast) { + phoneIn('Europe/Lisbon'); + app(Account::class)->rememberTimezone($chosen); + fakeThread(['created_at' => '2026-10-14T20:13:05Z']); + + Native::visit('/log/commits/c2') + ->tap('focused-time') + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === $toast); +})->with([ + "the phone's" => [null, '9:13 PM · Oct 14, 2026 in Lisbon'], + 'the one you chose' => ['Asia/Tokyo', '5:13 AM · Oct 15, 2026 in Tokyo'], +]); + it('posts a reply, with no countdown while there is room', function () { fakeThread(extra: ['POST /api/v1/commits' => [['data' => commitJson(['id' => 'r9', 'parent_id' => 'c2', 'body' => 'A fresh reply'])], 201]]); diff --git a/tests/Feature/Screens/LogCacheTest.php b/tests/Feature/Screens/LogCacheTest.php index 05876b3b..1839334b 100644 --- a/tests/Feature/Screens/LogCacheTest.php +++ b/tests/Feature/Screens/LogCacheTest.php @@ -120,8 +120,8 @@ function shownCommitIds(TestableComponent $screen): array expect(shownCommitIds($screen))->toBe(['c3', 'c2', 'c1']) ->and(count(Http::recorded()))->toBe($requests) - // The log, you for the unread badge, and who to follow. - ->and(refreshesSent($screen))->toBe(3) + // The log, you for the unread badge, who to follow, and your settings for your timezone. + ->and(refreshesSent($screen))->toBe(4) // A new list key is how the log gets back to the top. ->and($screen->get('listKey'))->toBe($listKey + 1); }); diff --git a/tests/Feature/Screens/ScheduleCommitTest.php b/tests/Feature/Screens/ScheduleCommitTest.php new file mode 100644 index 00000000..90c5a1be --- /dev/null +++ b/tests/Feature/Screens/ScheduleCommitTest.php @@ -0,0 +1,241 @@ + 'http://devnull.test']); + $this->bridge = signIn(); + phoneIn('Europe/Lisbon'); + $this->travelTo(CarbonImmutable::parse('2026-10-19 15:30:00', 'UTC')); +}); + +afterEach(function () { + File::deleteDirectory(storage_path('app/draft-images')); +}); + +/** + * The Log, with its composer open in the sheet and a commit written in it, and devnull answering a schedule with $answer. + * + * @param array{0: mixed, 1?: int}|null $answer + */ +function composeToSchedule(?array $answer = null): TestableComponent +{ + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1'])])], + 'POST /api/v1/scheduled-commits' => $answer ?? [['data' => scheduledCommitJson(['body' => 'Shipping this tomorrow.', 'scheduled_at' => '2026-10-20T08:00:00Z'])], 201], + ]); + + return Native::visit('/log', platform: 'ios') + ->press('composeInSheet') + ->input('sheet-body', 'Shipping this tomorrow.'); +} + +function scheduleDisabled(TestableComponent $screen): bool +{ + return ! sheetButtonEnabled($screen, 'sheet-commit'); +} + +it("opens the web's Submit at under the field, with the system's picker in the phone's timezone", function () { + $screen = composeToSchedule() + ->assertMissingElement('date_picker') + ->assertSee('Commit') + ->tap('sheet-schedule') + ->assertSet('scheduling', true) + ->assertSee('Submit at') + ->assertSee('Europe/Lisbon (GMT+01:00). Within 30 days. Private until submitted; then submitted for review, not published automatically.') + ->assertSee('Scheduled commits →') + ->assertSee('Schedule commit'); + + expect(nodeByRef($screen, 'scheduled-at')['props'])->toMatchArray([ + 'mode' => 'datetime', + 'picker_style' => 'compact', + 'placeholder' => 'Pick a date and time', + 'timezone' => 'Europe/Lisbon', + 'min' => '2026-10-19T16:30', + 'max' => '2026-11-18T16:30', + ]) + // Schedule commit waits for a time. + ->and(scheduleDisabled($screen))->toBeTrue() + ->and(nodeByRef($screen, 'sheet-schedule')['props']['a11y_label'] ?? null)->toBe("Don't schedule"); +}); + +it('schedules the commit for the time picked, in UTC, and says so as the web does', function () { + $screen = composeToSchedule() + ->tap('sheet-schedule') + ->pickDateTime('scheduled-at', '2026-10-20T09:00'); + + expect(scheduleDisabled($screen))->toBeFalse() + ->and(nodeByRef($screen, 'schedule-help')['props']['text'] ?? null)->toStartWith('Europe/Lisbon (GMT+01:00).'); + + $screen->tap('sheet-commit') + ->assertSet('sheetOpen', false) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Commit scheduled. Only you can see it until it is submitted.'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && str_ends_with($request->url(), '/api/v1/scheduled-commits') + && $request->body() === '{"body":"Shipping this tomorrow.","scheduled_at":"2026-10-20T08:00:00+00:00"}'); + Http::assertNotSent(fn (Request $request): bool => str_ends_with($request->url(), '/api/v1/commits')); + + // The draft is spent, and nothing new shows on the Log until it's submitted. + expect(app(Stash::class)->get('0199e5c4-0000-7000-8000-000000000001', 'commit')->body)->toBe('') + ->and(array_map(fn (Commit $commit): string => $commit->id, $screen->instance()->commits))->toBe(['c1']); +}); + +it('says when the phone skips the time picked, as on the night the clocks go forward', function () { + $this->travelTo(CarbonImmutable::parse('2027-03-20 12:00:00', 'UTC')); + + $screen = composeToSchedule() + ->tap('sheet-schedule') + ->pickDateTime('scheduled-at', '2027-03-28T01:30') + ->assertSee('This time does not exist in your timezone. Choose another time.'); + + expect(scheduleDisabled($screen))->toBeTrue(); + + $screen->call('commitFromSheet'); + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); + + $screen->pickDateTime('scheduled-at', '2027-03-28T02:30') + ->assertDontSee('This time does not exist in your timezone.'); + + expect(scheduleDisabled($screen))->toBeFalse(); +}); + +it('keeps the draft and the time, and says why, when devnull does not schedule it', function () { + $screen = composeToSchedule([['message' => 'You can have up to 10 scheduled commits. Cancel one before scheduling another.', 'errors' => ['scheduled_at' => ['You can have up to 10 scheduled commits. Cancel one before scheduling another.']]], 422]) + ->tap('sheet-schedule') + ->pickDateTime('scheduled-at', '2026-10-20T09:00') + ->tap('sheet-commit') + ->assertSet('sheetOpen', true) + ->assertSet('scheduledAt', '2026-10-20T09:00') + ->assertSet('sheetBody', 'Shipping this tomorrow.') + ->assertSee('You can have up to 10 scheduled commits. Cancel one before scheduling another.') + ->assertNativeNotCalled('ToastKit.Show'); +}); + +it('commits now again once the clock is turned off', function () { + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1'])])], + 'POST /api/v1/commits' => [['data' => commitJson(['id' => 'c9', 'body' => 'Shipping this now.'])], 201], + ]); + + Native::visit('/log', platform: 'ios') + ->press('composeInSheet') + ->input('sheet-body', 'Shipping this now.') + ->tap('sheet-schedule') + ->pickDateTime('scheduled-at', '2026-10-20T09:00') + ->tap('sheet-schedule') + ->assertSet('scheduling', false) + ->assertSet('scheduledAt', '') + ->assertMissingElement('date_picker') + ->tap('sheet-commit'); + + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/api/v1/commits')); + Http::assertNotSent(fn (Request $request): bool => str_ends_with($request->url(), '/api/v1/scheduled-commits')); +}); + +it('starts unscheduled every time the composer opens', function () { + composeToSchedule() + ->tap('sheet-schedule') + ->pickDateTime('scheduled-at', '2026-10-20T09:00') + ->call('commitSheetClosed') + ->press('composeInSheet') + ->assertSet('scheduling', false) + ->assertSet('scheduledAt', ''); +}); + +it('opens your scheduled commits beneath the Log, from Submit at, once the sheet has gone', function () { + $screen = composeToSchedule() + ->tap('sheet-schedule') + ->tap('open-scheduled') + ->assertSet('sheetOpen', false) + ->assertNoNavigation(); + + $screen->dismissSheet('sheet')->assertNavigatedTo('/log/scheduled'); +}); + +it('opens nothing when the sheet is closed any other way', function () { + composeToSchedule() + ->tap('sheet-schedule') + ->dismissSheet('sheet') + ->assertNoNavigation(); +}); + +it('has no clock in a quote, which the web does not schedule', function () { + fakeApi(['GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])])])]]); + + $screen = Native::visit('/log', platform: 'ios')->call('quoteCommit', 'c1'); + + expect(findNodes($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-schedule'))->toBe([]); + + $screen->call('toggleSchedule')->assertSet('scheduling', false); +}); + +it('schedules a commit of one character, as devnull now takes one', function () { + $screen = composeToSchedule() + ->input('sheet-body', 'k') + ->tap('sheet-schedule') + ->pickDateTime('scheduled-at', '2026-10-20T09:00'); + + expect(scheduleDisabled($screen))->toBeFalse(); + + $screen->tap('sheet-commit'); + + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/api/v1/scheduled-commits') + && $request->body() === '{"body":"k","scheduled_at":"2026-10-20T08:00:00+00:00"}'); +}); + +it('keeps scheduling and a /feature or /bug draft apart, as the web does', function () { + // Scheduling, a command stays text. + $screen = composeToSchedule()->tap('sheet-schedule')->input('sheet-body', '/bug The scheduler'); + + expect($screen->instance()->sheetIssue)->toBeNull(); + + // An issue has no clock, and can't be scheduled. + $screen->tap('sheet-schedule')->input('sheet-body', '/bug The scheduler'); + + expect($screen->instance()->sheetIssue)->toBe('bug') + ->and(findNodes($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-schedule'))->toBe([]); + + $screen->call('toggleSchedule')->assertSet('scheduling', false); +}); + +it('schedules a poll with the commit, as the web does', function () { + composeToSchedule() + ->input('sheet-body', 'Tabs or spaces?') + ->tap('sheet-poll') + ->input('poll-option-0', 'Tabs') + ->input('poll-option-1', 'Spaces') + ->tap('sheet-schedule') + ->pickDateTime('scheduled-at', '2026-10-20T09:00') + ->tap('sheet-commit') + ->assertSet('sheetOpen', false); + + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/api/v1/scheduled-commits') + && $request->body() === '{"body":"Tabs or spaces?","scheduled_at":"2026-10-20T08:00:00+00:00","poll":{"options":["Tabs","Spaces"],"duration":1440}}'); +}); + +it('schedules photos with the commit as multipart, then lets the photos go', function () { + $screen = photosPicked(composeToSchedule(), [testPhoto('jpg')]); + $path = $screen->instance()->sheetImages[0]->path; + $contents = file_get_contents($path); + + $screen->tap('sheet-schedule') + ->pickDateTime('scheduled-at', '2026-10-20T09:00') + ->tap('sheet-commit') + ->assertSet('sheetOpen', false) + ->assertSet('sheetImages', []); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/scheduled-commits' && multipartParts($request) === [ + ['name' => 'payload', 'type' => 'application/json', 'contents' => '{"body":"Shipping this tomorrow.","scheduled_at":"2026-10-20T08:00:00+00:00"}'], + ['name' => 'images[]', 'type' => 'image/jpeg', 'contents' => $contents], + ]); + + expect(file_exists($path))->toBeFalse(); +}); diff --git a/tests/Feature/Screens/ScheduledCommitsTest.php b/tests/Feature/Screens/ScheduledCommitsTest.php new file mode 100644 index 00000000..6224869c --- /dev/null +++ b/tests/Feature/Screens/ScheduledCommitsTest.php @@ -0,0 +1,186 @@ + 'http://devnull.test']); + signIn(); + phoneIn('Europe/Lisbon'); + $this->travelTo(CarbonImmutable::parse('2026-10-19 15:30:00', 'UTC')); +}); + +/** + * One waiting tomorrow, one due a minute ago and still waiting for the scheduler, with a poll, and one that was + * submitted and approved. + * + * @return list> + */ +function threeScheduled(): array +{ + return [ + scheduledCommitJson(['id' => 's-due', 'body' => 'Poll time.', 'poll_options' => ['Tabs', 'Spaces'], 'scheduled_at' => '2026-10-19T15:29:00Z']), + scheduledCommitJson(['id' => 's-later', 'body' => 'Shipping this tomorrow.', 'image_count' => 2, 'scheduled_at' => '2026-10-20T08:00:00Z']), + scheduledCommitJson(['id' => 's-done', 'body' => null, 'scheduled_at' => '2026-10-18T09:00:00Z', 'submitted_at' => '2026-10-18T09:00:12Z', 'status' => 'approved']), + ]; +} + +function scheduledRows(TestableComponent $screen): array +{ + return array_values(array_filter( + array_map(fn (array $node): string => (string) $node['ref'], findNodes($screen->tree(), fn (array $node): bool => preg_match('/^scheduled-s-[a-z]+$/', (string) ($node['ref'] ?? '')) === 1)), + )); +} + +it("lists your scheduled commits as the web does, with their times in the phone's timezone", function () { + fakeApi(['GET /api/v1/scheduled-commits' => [scheduledPageJson(threeScheduled())]]); + + $screen = Native::visit('/log/scheduled') + ->assertNavTitle('Scheduled commits') + ->assertSee('Only you can see this list. At the scheduled time, each commit goes through the usual review process. Scheduling is not approval.') + ->assertSee('Times in Europe/Lisbon') + ->assertSee('Shipping this tomorrow.') + ->assertSee('2 images') + ->assertSee('Tabs') + ->assertSee('Spaces') + ->assertSee('Private until submitted. You can commit it now or cancel it before submission.') + ->assertSee('Waiting for the scheduler. If submission fails, it is retried automatically.'); + + expect(scheduledRows($screen))->toBe(['scheduled-s-due', 'scheduled-s-later', 'scheduled-s-done']) + ->and(nodeByRef($screen, 'scheduled-status-s-due')['props']['text'] ?? null)->toBe('Waiting for submission') + ->and(nodeByRef($screen, 'scheduled-status-s-later')['props']['text'] ?? null)->toBe('Scheduled') + ->and(nodeByRef($screen, 'scheduled-status-s-done')['props']['text'] ?? null)->toBe('Approved') + ->and(nodeByRef($screen, 'scheduled-at-s-later')['props']['text'] ?? null)->toBe('Oct 20, 2026, 9:00 AM') + // Only those still waiting can be committed now or canceled. + ->and(findNodes($screen->tree(), fn (array $node): bool => str_starts_with((string) ($node['ref'] ?? ''), 'cancel-')))->toHaveCount(2) + ->and(findNodes($screen->tree(), fn (array $node): bool => str_starts_with((string) ($node['ref'] ?? ''), 'commit-now-')))->toHaveCount(2) + ->and(findNodes($screen->tree(), fn (array $node): bool => in_array($node['ref'] ?? null, ['cancel-s-done', 'commit-now-s-done'], true)))->toBe([]); +}); + +it('says what became of each one submitted, as the web does', function (string $status, string $label) { + fakeApi(['GET /api/v1/scheduled-commits' => [scheduledPageJson([scheduledCommitJson(['id' => 's-done', 'body' => null, 'submitted_at' => '2026-10-18T09:00:12Z', 'status' => $status])])]]); + + $screen = Native::visit('/log/scheduled'); + + expect(nodeByRef($screen, 'scheduled-status-s-done')['props']['text'] ?? null)->toBe($label); +})->with([ + ['pending', 'Submitted for review'], + ['rejected', 'Rejected'], + ['deleted', 'Deleted'], +]); + +it("says there are none, in the web's words", function () { + fakeApi(['GET /api/v1/scheduled-commits' => [scheduledPageJson([])]]); + + Native::visit('/log/scheduled') + ->assertSee('No scheduled commits') + ->assertSee('Choose Schedule in the composer to submit later.'); +}); + +it('opens on the list as it was last time, and asks devnull again behind it', function () { + $bridge = holdRefreshes(); + keep(Client::ScheduledCommitsPath, scheduledPageJson([scheduledCommitJson(['id' => 's-later'])])); + serveApi(['GET /api/v1/scheduled-commits' => [scheduledPageJson([])]]); + + $screen = Native::visit('/log/scheduled'); + + expect(scheduledRows($screen))->toBe(['scheduled-s-later']); + + finishRefreshes($screen)->assertSee('No scheduled commits'); +}); + +it('cancels one still waiting, and says so as the web does', function () { + serveApi([ + 'GET /api/v1/scheduled-commits' => [scheduledPageJson(threeScheduled())], + 'DELETE /api/v1/scheduled-commits/s-later' => [null, 204], + ]); + + $screen = Native::visit('/log/scheduled') + ->tap('cancel-s-later') + ->assertSet('busy', null) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Scheduled commit canceled.'); + + expect(scheduledRows($screen))->toBe(['scheduled-s-due', 'scheduled-s-done']) + ->and(nodeByRef($screen, 'cancel-s-due')['props']['disabled'] ?? false)->toBeFalse(); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' && str_ends_with($request->url(), '/api/v1/scheduled-commits/s-later')); +}); + +it('commits one now, keeping its row where it was, and says it went for review as the web does', function () { + serveApi([ + 'GET /api/v1/scheduled-commits' => [scheduledPageJson(threeScheduled())], + 'POST /api/v1/scheduled-commits/s-later/submission' => [['data' => scheduledCommitJson(['id' => 's-later', 'body' => 'Shipping this tomorrow.', 'image_count' => 2, 'submitted_at' => '2026-10-19T15:30:01Z', 'status' => 'pending'])]], + ]); + + $screen = Native::visit('/log/scheduled') + ->tap('commit-now-s-later') + ->assertSet('busy', null) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Commit submitted. It goes through the usual review process.'); + + expect(scheduledRows($screen))->toBe(['scheduled-s-due', 'scheduled-s-later', 'scheduled-s-done']) + ->and(nodeByRef($screen, 'scheduled-status-s-later')['props']['text'] ?? null)->toBe('Submitted for review') + ->and(findNodes($screen->tree(), fn (array $node): bool => in_array($node['ref'] ?? null, ['commit-now-s-later', 'cancel-s-later'], true)))->toBe([]); + + $screen->assertSee('Shipping this tomorrow.')->assertSee('2 images'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && str_ends_with($request->url(), '/api/v1/scheduled-commits/s-later/submission')); + // The list asks devnull again next time instead of showing it as still waiting. + expect(kept(Client::ScheduledCommitsPath))->toBeNull(); +}); + +it('says so, as the web does, when devnull got there first, and shows what became of it', function (string $tap, string $route) { + serveApi([ + 'GET /api/v1/scheduled-commits' => [scheduledPageJson(threeScheduled())], + ]); + + $screen = Native::visit('/log/scheduled'); + + serveApi([ + 'GET /api/v1/scheduled-commits' => [scheduledPageJson([scheduledCommitJson(['id' => 's-due', 'body' => null, 'submitted_at' => '2026-10-19T15:30:02Z', 'status' => 'pending'])])], + $route => [['message' => 'This commit was already submitted or canceled. Refresh to see its status.'], 409], + ]); + + $screen->tap($tap) + ->assertSet('busy', null) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'This commit was already submitted or canceled. Refresh to see its status.'); + + expect(nodeByRef($screen, 'scheduled-status-s-due')['props']['text'] ?? null)->toBe('Submitted for review'); +})->with([ + 'canceling it' => ['cancel-s-due', 'DELETE /api/v1/scheduled-commits/s-due'], + 'committing it now' => ['commit-now-s-due', 'POST /api/v1/scheduled-commits/s-due/submission'], +]); + +it('does one thing at a time, and nothing to one already submitted', function () { + fakeApi(['GET /api/v1/scheduled-commits' => [scheduledPageJson(threeScheduled())]]); + + $screen = Native::visit('/log/scheduled') + ->set('busy', 's-due') + ->call('commitNow', 's-later') + ->call('cancel', 's-later') + ->set('busy', null) + ->call('commitNow', 's-done') + ->call('cancel', 's-done'); + + expect(nodeByRef($screen, 'commit-now-s-later')['props']['disabled'] ?? false)->toBeFalse(); + + Http::assertNotSent(fn (Request $request): bool => $request->method() !== 'GET'); +}); + +it('shows the next page under the first', function () { + serveApi(function (Request $request) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return Http::response(($query['page'] ?? null) === '2' + ? scheduledPageJson([scheduledCommitJson(['id' => 's-old', 'body' => null, 'submitted_at' => '2026-10-01T09:00:00Z', 'status' => 'approved'])]) + : scheduledPageJson([scheduledCommitJson(['id' => 's-later'])], nextPage: 2)); + }); + + $screen = Native::visit('/log/scheduled')->tap('scheduled-more'); + + expect(scheduledRows($screen))->toBe(['scheduled-s-later', 'scheduled-s-old']) + ->and(findNodes($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'scheduled-more'))->toBe([]); +}); diff --git a/tests/Feature/Screens/ThisDeviceAppleTest.php b/tests/Feature/Screens/ThisDeviceAppleTest.php index 9b439167..e24f47fc 100644 --- a/tests/Feature/Screens/ThisDeviceAppleTest.php +++ b/tests/Feature/Screens/ThisDeviceAppleTest.php @@ -53,7 +53,8 @@ function meWithApple(bool $linked): array ->and(nodeByRef($screen, 'link-apple')['props'])->toMatchArray(['label' => 'Sign in with Apple', 'variant' => 'accent', 'leading_icon' => 'apple.logo']) ->and(nodeByRef($screen, 'link-apple')['layout'])->toEqual(['width' => 'fill']); - Http::assertSentCount(1); + // You, and your settings for your timezone. + Http::assertSentCount(2); Http::assertSent(fn (Request $request): bool => $request->method() === 'GET' && $request->url() === 'http://devnull.test/api/v1/me'); }); @@ -152,7 +153,8 @@ function meWithApple(bool $linked): array ->emitNative(SignInFailed::class, ['provider' => 'apple', 'error' => 'Something broke.', 'errorCode' => 'SIGN_IN_FAILED']) ->assertSee('fatal: Sign in with Apple failed. Something broke.'); - Http::assertSentCount(1); + // Only you and your settings, when the screen opened. + Http::assertSentCount(2); }); it('rejects an answer it never asked for', function () { @@ -163,7 +165,8 @@ function meWithApple(bool $linked): array ->assertSet('error', AppleSignIn::NotStartedHere) ->assertSet('appleLinked', false); - Http::assertSentCount(1); + // Only you and your settings, when the screen opened. + Http::assertSentCount(2); }); it('unlinks the Apple ID only after a confirm', function () { diff --git a/tests/Pest.php b/tests/Pest.php index 60086ee1..0a85b258 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -6,6 +6,7 @@ use App\Devnull\DeviceKey; use App\Devnull\ResponseCache; use App\Devnull\SshKey; +use App\Devnull\Timezone; use App\NativeComponents\Log; use App\NativeComponents\Notifications; use App\NativeComponents\Profile; @@ -1018,3 +1019,58 @@ function sentTo(string $method, string $path): array ->values() ->all(); } + +/** + * A commit you scheduled, as devnull lists it: still waiting, until a test says otherwise. Its body's segments + * follow its body, as the server works them out. + * + * @param array $overrides + * @return array + */ +function scheduledCommitJson(array $overrides = []): array +{ + $body = array_key_exists('body', $overrides) ? $overrides['body'] : 'Shipping this tomorrow.'; + + return [ + 'id' => '0199e5c4-3333-7000-8000-00000000da7e', + 'body' => $body, + 'body_segments' => $body === null || $body === '' ? [] : [['type' => 'text', 'text' => $body]], + 'preview_segments' => null, + 'poll_options' => null, + 'image_count' => 0, + 'scheduled_at' => '2026-10-20T08:00:00Z', + 'submitted_at' => null, + 'status' => 'scheduled', + ...$overrides, + ]; +} + +/** + * A page of scheduled commits, which devnull pages by number. + * + * @param list> $items + * @return array + */ +function scheduledPageJson(array $items, ?int $nextPage = null): array +{ + $path = 'http://devnull.test/api/v1/scheduled-commits'; + + return [ + 'data' => $items, + 'links' => ['first' => $path.'?page=1', 'last' => null, 'prev' => null, 'next' => $nextPage === null ? null : $path.'?page='.$nextPage], + 'meta' => ['current_page' => $nextPage === null ? 1 : $nextPage - 1, 'path' => $path, 'per_page' => 20], + ]; +} + +/** + * Puts the phone in a timezone, as iOS does: /etc/localtime links to the zone, through one more link. + */ +function phoneIn(string $timezone): void +{ + $directory = sys_get_temp_dir().'/devnull-localtime-'.bin2hex(random_bytes(4)); + mkdir($directory); + symlink('/var/db/timezone/tz/2026a.1.0/zoneinfo/'.$timezone, $directory.'/zone'); + symlink($directory.'/zone', $directory.'/localtime'); + + app()->instance(Timezone::class, new Timezone(localtime: $directory.'/localtime')); +} diff --git a/tests/TestCase.php b/tests/TestCase.php index c7bf4adb..80975918 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,6 +2,7 @@ namespace Tests; +use App\Devnull\Timezone; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Facades\File; @@ -20,12 +21,16 @@ abstract class TestCase extends BaseTestCase * Background refreshes run inline, so a screen has its fresh answer by its * first frame. A test that wants to see the frame before it calls * holdRefreshes() (see Pest.php). + * + * The phone's timezone is unknown, so times show in UTC whatever this + * machine's is, until a test puts the phone somewhere (see phoneIn()). */ protected function setUp(): void { parent::setUp(); AsyncTask::fake(); + $this->app->instance(Timezone::class, new Timezone(localtime: '/nonexistent')); } protected function tearDown(): void From 3dd98970887028bf00df1738157459ac4e863ee7 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Wed, 21 Oct 2026 14:38:01 +0100 Subject: [PATCH 2/3] Ask NativePHP for the phone's timezone before reading localtime NativePHP/mobile-air#566 adds the phone's timezone to Device.GetInfo on iOS and Android. Timezone::phone() now reads it from there first and only follows /etc/localtime when the device info doesn't carry it, as with builds from before that change. Android gets a timezone this way, since it has no /etc/localtime. Device.GetInfo does more than read the zone (on Android it reads memory info and the WebView version), and the notifications page asks for the phone's timezone twice a row. So Timezone is now bound once and keeps the answer for a minute before asking again. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Timezone.php | 74 ++++++++++++++++++++------ app/Providers/AppServiceProvider.php | 2 + tests/Feature/Devnull/TimezoneTest.php | 56 ++++++++++++++++++- 3 files changed, 116 insertions(+), 16 deletions(-) diff --git a/app/Devnull/Timezone.php b/app/Devnull/Timezone.php index 798b718d..7ab75821 100644 --- a/app/Devnull/Timezone.php +++ b/app/Devnull/Timezone.php @@ -5,6 +5,7 @@ use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use DateTimeZone; +use Native\Mobile\Facades\Device; /** * Which timezone times are shown in, as the web picks them. @@ -14,33 +15,44 @@ * a time to schedule a commit in your browser's. Here the phone stands in for * the browser. * - * NativePHP doesn't tell PHP the phone's timezone, so it's read the way the C + * The phone's timezone comes from NativePHP's device info. Builds from before + * it carried the timezone don't send it, so then it's read the way the C * library reads it: from where /etc/localtime links to, which on iOS ends in - * zoneinfo/ and the zone's name. When that can't be read, as on Android, the - * phone's timezone is unknown and the one you chose, or UTC, stands in for it. + * zoneinfo/ and the zone's name. When neither tells, as on Android with those + * builds, the phone's timezone is unknown and the one you chose, or UTC, + * stands in for it. + * + * It's bound once (see AppServiceProvider) and keeps the phone's timezone for + * a minute, since a list asks for it once a row and NativePHP's device info + * does more than read the zone. */ -final readonly class Timezone +final class Timezone { - public function __construct(private string $localtime = '/etc/localtime') {} + /** + * How long the phone's timezone is kept before it's asked for again, so a + * phone that lands somewhere new shows it within a minute. + */ + public const int PhoneSeconds = 60; + + /** + * @var array{name: ?string, at: int}|null + */ + private ?array $phone = null; + + public function __construct(private readonly string $localtime = '/etc/localtime') {} /** * The phone's timezone, like `Europe/Lisbon`, or null when it can't be told. */ public function phone(): ?string { - $path = $this->localtime; - - for ($hops = 0; $hops < 4 && is_link($path); $hops++) { - $target = (string) readlink($path); + $now = CarbonImmutable::now()->getTimestamp(); - if (preg_match('#/zoneinfo/(.+)$#', $target, $matches) === 1) { - return self::isKnown($matches[1]) ? $matches[1] : null; - } - - $path = str_starts_with($target, '/') ? $target : dirname($path).'/'.$target; + if ($this->phone === null || $now - $this->phone['at'] >= self::PhoneSeconds) { + $this->phone = ['name' => $this->fromDeviceInfo() ?? $this->fromLocaltime(), 'at' => $now]; } - return null; + return $this->phone['name']; } /** @@ -106,4 +118,36 @@ public static function city(string $name): string return str_replace('_', ' ', $city); } + + /** + * The timezone NativePHP's device info says the phone is in, or null off + * a phone and on builds whose device info doesn't carry it. + */ + private function fromDeviceInfo(): ?string + { + $info = json_decode((string) Device::getInfo(), true); + $name = is_array($info) ? ($info['timezone'] ?? null) : null; + + return self::isKnown($name) ? $name : null; + } + + /** + * The timezone /etc/localtime links to, through any links on the way. + */ + private function fromLocaltime(): ?string + { + $path = $this->localtime; + + for ($hops = 0; $hops < 4 && is_link($path); $hops++) { + $target = (string) readlink($path); + + if (preg_match('#/zoneinfo/(.+)$#', $target, $matches) === 1) { + return self::isKnown($matches[1]) ? $matches[1] : null; + } + + $path = str_starts_with($target, '/') ? $target : dirname($path).'/'.$target; + } + + return null; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 43e84419..70c92b45 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,7 @@ use App\Devnull\KeyStoreChoice; use App\Devnull\ResponseCache; use App\Devnull\SignIn; +use App\Devnull\Timezone; use Carbon\CarbonImmutable; use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Facades\Date; @@ -36,6 +37,7 @@ public function register(): void $this->app->singleton(SignIn::class); $this->app->singleton(AppleSignIn::class); $this->app->singleton(ImageCache::class); + $this->app->singleton(Timezone::class); } /** diff --git a/tests/Feature/Devnull/TimezoneTest.php b/tests/Feature/Devnull/TimezoneTest.php index 05915ba6..64c829b8 100644 --- a/tests/Feature/Devnull/TimezoneTest.php +++ b/tests/Feature/Devnull/TimezoneTest.php @@ -38,10 +38,64 @@ function localtimeLinkingTo(string $target): string expect(app(Timezone::class)->phone())->toBe('Asia/Kathmandu'); }); -it('has no phone timezone without a localtime link, as on Android', function () { +it("asks NativePHP for the phone's timezone before reading localtime", function () { + Native::fakeBridge()->respondTo('Device.GetInfo', ['info' => json_encode(['platform' => 'ios', 'timezone' => 'Asia/Kathmandu'])]); + + expect((new Timezone(localtimeLinkingTo('/var/db/timezone/zoneinfo/Atlantic/Canary')))->phone())->toBe('Asia/Kathmandu'); +}); + +it("gets the phone's timezone from NativePHP where there's no localtime, as on Android", function () { + Native::fakeBridge()->respondTo('Device.GetInfo', ['info' => json_encode(['platform' => 'android', 'timezone' => 'America/Sao_Paulo'])]); + + expect((new Timezone('/nonexistent'))->phone())->toBe('America/Sao_Paulo'); +}); + +it("reads localtime when NativePHP's device info doesn't say, as older builds send it", function (?array $info) { + $bridge = Native::fakeBridge(); + + if ($info !== null) { + $bridge->respondTo('Device.GetInfo', ['info' => json_encode($info)]); + } + + expect((new Timezone(localtimeLinkingTo('/var/db/timezone/zoneinfo/Atlantic/Canary')))->phone())->toBe('Atlantic/Canary'); +})->with([ + 'no timezone' => [['platform' => 'ios', 'language' => 'en-US']], + 'not a timezone PHP knows' => [['timezone' => 'Mars/Olympus_Mons']], + 'no answer' => [null], +]); + +it('has no phone timezone when neither NativePHP nor localtime tells, as on Android with older builds', function () { + Native::fakeBridge(); + expect((new Timezone('/nonexistent'))->phone())->toBeNull(); }); +it("keeps the phone's timezone for a minute, so a list doesn't ask NativePHP once a row", function () { + $this->travelTo(CarbonImmutable::parse('2026-10-15T12:00:00Z')); + $bridge = Native::fakeBridge()->respondTo('Device.GetInfo', ['info' => json_encode(['timezone' => 'Europe/Lisbon'])]); + $timezone = new Timezone('/nonexistent'); + + expect($timezone->phone())->toBe('Europe/Lisbon') + ->and($timezone->phone())->toBe('Europe/Lisbon'); + + // The phone lands in Tokyo. + $bridge->respondTo('Device.GetInfo', ['info' => json_encode(['timezone' => 'Asia/Tokyo'])]); + $this->travel(Timezone::PhoneSeconds - 1)->seconds(); + + expect($timezone->phone())->toBe('Europe/Lisbon'); + + $this->travel(1)->seconds(); + + expect($timezone->phone())->toBe('Asia/Tokyo') + ->and($bridge->callsTo('Device.GetInfo'))->toHaveCount(2); +}); + +it('is the same Timezone everywhere, so what it keeps is shared', function () { + app()->forgetInstance(Timezone::class); + + expect(app(Timezone::class))->toBe(app(Timezone::class)); +}); + it("shows your times in the timezone you chose, or else the phone's, as the web falls back to the browser's", function () { phoneIn('Europe/Lisbon'); From 7f05dc236b380c0509073ef58f6bb596c4ebcec1 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 29 Oct 2026 12:23:26 +0000 Subject: [PATCH 3/3] Open scheduled commits from your profile A calendar button beside Edit on GitHub on your own profile opens Scheduled commits beneath the Profile tab, so they can be found without opening the composer first. Co-Authored-By: Claude Opus 5.5 --- app/NativeComponents/Profile.php | 8 ++++++++ .../views/native/partials/profile-header.blade.php | 11 ++++++++--- tests/Feature/Screens/ProfileTest.php | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/NativeComponents/Profile.php b/app/NativeComponents/Profile.php index 4f26ef14..6ac051e0 100644 --- a/app/NativeComponents/Profile.php +++ b/app/NativeComponents/Profile.php @@ -541,6 +541,14 @@ public function editOnGitHub(): void Browser::inApp('https://github.com/settings/profile'); } + /** + * Your scheduled commits, beneath this tab, from the calendar beside Edit on GitHub. + */ + public function openScheduledCommits(): void + { + $this->openInTab('/scheduled'); + } + /** * The website on their GitHub profile: a page on dev/null the app has a * screen for here in the app, anything else in the browser. Taps name the diff --git a/resources/views/native/partials/profile-header.blade.php b/resources/views/native/partials/profile-header.blade.php index 806819ac..9ce5d609 100644 --- a/resources/views/native/partials/profile-header.blade.php +++ b/resources/views/native/partials/profile-header.blade.php @@ -1,7 +1,8 @@ {{-- 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 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. + lane, Follow (or, on your own, Scheduled commits and 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. --}} @@ -32,7 +33,11 @@ @if ($sketched) {{-- Follow waits for devnull to say whether you already do. --}} @elseif ($isMe) - Edit on GitHub + {{-- Your scheduled commits, which only you see, beside Edit on GitHub. --}} + + + Edit on GitHub + @else {{-- The web's Remote, beside Follow, when you can push to them. --}} diff --git a/tests/Feature/Screens/ProfileTest.php b/tests/Feature/Screens/ProfileTest.php index d50fb772..ca6b090e 100644 --- a/tests/Feature/Screens/ProfileTest.php +++ b/tests/Feature/Screens/ProfileTest.php @@ -406,6 +406,20 @@ function adaCommitJson(array $overrides = []): array ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://github.com/settings/profile'); }); +it('opens your scheduled commits beneath the Profile tab from the calendar beside Edit on GitHub', function () { + fakeApi([ + 'GET /api/v1/users/local_dev' => [profileJson(user: ['id' => userJson()['id'], 'handle' => 'local_dev'])], + 'GET /api/v1/users/local_dev/commits' => [pageJson([])], + 'GET /api/v1/users/ada' => [profileJson()], + 'GET /api/v1/users/ada/commits' => [pageJson([])], + ]); + + expect(nodeByRef(Native::visit('/profile'), 'profile-scheduled')['props']['a11y_label'] ?? null)->toBe('Scheduled commits'); + + Native::visit('/profile')->tap('profile-scheduled')->assertNavigatedTo('/profile/scheduled'); + Native::visit('/profile/users/ada')->assertMissingElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'profile-scheduled'); +}); + it('leads from your own profile to your account with the Settings2 icon', function () { fakeApi([ 'GET /api/v1/users/local_dev' => [profileJson(user: ['handle' => 'local_dev'])],