From b2aae2e2438776de60f332d98da916cfe6cf04f9 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 19:21:42 +0100 Subject: [PATCH 1/2] 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. Scheduled commits are listed on their own screen, reached from "Scheduled commits →" as on the web, where one still waiting can be edited or canceled. Account gets the web's Timezone section between Color and Pastéis mode. 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. Needs the timezone, scheduling and scheduled-commit API PRs on devnull. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Account.php | 28 +- app/Devnull/Client.php | 64 ++++ app/Devnull/Data/CurrentUser.php | 10 +- app/Devnull/Data/Page.php | 6 +- app/Devnull/Data/ScheduledCommit.php | 126 +++++++ app/Devnull/Display.php | 34 +- app/Devnull/ErrorCode.php | 2 + app/Devnull/ResponseCache.php | 5 +- app/Devnull/Timeline.php | 3 +- app/Devnull/Timezone.php | 109 ++++++ app/Icons/Lucide.php | 2 + .../Concerns/AttachesToCommits.php | 6 +- .../Concerns/DraftsIssues.php | 2 +- .../Concerns/OpensCommitSheet.php | 136 +++++++- .../Concerns/RefreshesInBackground.php | 3 +- .../Concerns/ShowsCommits.php | 21 +- app/NativeComponents/Onboarding.php | 1 + app/NativeComponents/ScheduledCommits.php | 329 ++++++++++++++++++ app/NativeComponents/ThisDevice.php | 157 ++++++++- 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 | 14 +- .../native/partials/schedule-picker.blade.php | 28 ++ .../partials/scheduled-edit-sheet.blade.php | 60 ++++ .../native/partials/timezone-sheet.blade.php | 47 +++ .../views/native/scheduled-commits.blade.php | 95 +++++ resources/views/native/this-device.blade.php | 48 +++ routes/mobile.php | 7 +- tests/Feature/Devnull/ClientCacheTest.php | 39 +++ tests/Feature/Devnull/TimezoneTest.php | 131 +++++++ tests/Feature/Screens/AccountTimezoneTest.php | 203 +++++++++++ tests/Feature/Screens/CommitDetailTest.php | 13 + tests/Feature/Screens/ScheduleCommitTest.php | 221 ++++++++++++ .../Feature/Screens/ScheduledCommitsTest.php | 223 ++++++++++++ tests/Pest.php | 57 +++ tests/TestCase.php | 5 + 37 files changed, 2219 insertions(+), 41 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/scheduled-edit-sheet.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 17ff5f5..c5eecad 100644 --- a/app/Devnull/Account.php +++ b/app/Devnull/Account.php @@ -30,6 +30,8 @@ final class Account */ private const string RemotesOff = 'off'; + private const string TimezoneKey = 'devnull.timezone'; + public function user(): ?User { $attributes = Cache::get(self::CacheKey); @@ -153,8 +155,29 @@ public function rememberUnreadRemotes(int $count): void } /** - * Signs this phone out: who it was, the badges, its pastéis and Remotes, every answer kept for a quick start, - * and your unsent drafts. + * 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 kept for a + * quick start, and your unsent drafts. */ public function forget(): void { @@ -166,5 +189,6 @@ public function forget(): void Cache::forget(self::PasteisKey); Cache::forget(self::RemotesKey); Cache::forget(self::ProfileKey); + Cache::forget(self::TimezoneKey); } } diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index c4da605..8a85d9d 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -10,9 +10,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; @@ -91,6 +93,17 @@ */ public const string IssuesPath = 'issues'; + /** + * Where the signed-in account's timezone is saved, or unset. + */ + public const string TimezonePath = 'me/timezone'; + + /** + * 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) {} /** @@ -263,6 +276,57 @@ public function signedCommitWithImages(string $body, array $images, ?string $par ); } + /** + * Schedules a commit to be submitted at $at, as the web's Schedule commit + * does. Only you see it until then. Your list of them starts again. + */ + public function scheduleCommit(string $body, CarbonInterface $at): ScheduledCommit + { + $scheduled = $this->data($this->send('POST', self::ScheduledCommitsPath, payload: ['body' => $body, 'scheduled_at' => self::scheduleTime($at)])); + $this->cache->forget(self::ScheduledCommitsPath); + + return ScheduledCommit::fromArray($scheduled); + } + + /** + * 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(...)); + } + + /** + * A new body and time for a scheduled commit that's still waiting. + */ + public function updateScheduledCommit(string $id, string $body, CarbonInterface $at): ScheduledCommit + { + $scheduled = $this->data($this->send('PUT', self::ScheduledCommitsPath.'/'.rawurlencode($id), payload: ['body' => $body, 'scheduled_at' => self::scheduleTime($at)])); + $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/CurrentUser.php b/app/Devnull/Data/CurrentUser.php index c082edb..a6ade3e 100644 --- a/app/Devnull/Data/CurrentUser.php +++ b/app/Devnull/Data/CurrentUser.php @@ -3,6 +3,7 @@ namespace App\Devnull\Data; use App\Devnull\Lane; +use App\Devnull\Timezone; use Carbon\CarbonImmutable; /** @@ -14,7 +15,8 @@ * Sponsors page, and when your profile was last synced from GitHub (null when * it only was when you signed in). It has whether pastéis mode is on, how * many pastéis you can still give today, and how many others have given you, - * and your Remotes settings, or null while you have Remotes off. + * and your Remotes settings, or null while you have Remotes off, and the timezone you chose, or null while you + * haven't. */ final readonly class CurrentUser { @@ -30,6 +32,7 @@ public function __construct( public Pasteis $pasteisToday = new Pasteis, public int $pasteisReceived = 0, public ?Remotes $remotes = null, + public ?string $timezone = null, ) {} /** @@ -59,15 +62,16 @@ public static function fromArray(array $json): self pasteisReceived: is_int($data['pasteis_received'] ?? null) ? max(0, $data['pasteis_received']) : 0, // Only devnull saying null means they're off: a devnull too old to say has them on, as everyone starts. remotes: array_key_exists('remotes', $data) ? Remotes::fromArray($data['remotes']) : new Remotes, + timezone: Timezone::isKnown($data['timezone'] ?? null) ? $data['timezone'] : null, ); } /** * You as this phone remembers you, until devnull says the rest. */ - public static function remembered(User $user): self + public static function remembered(User $user, ?string $timezone = null): self { - return self::fromArray(['data' => $user->toArray()]); + return self::fromArray(['data' => [...$user->toArray(), 'timezone' => $timezone]]); } /** diff --git a/app/Devnull/Data/Page.php b/app/Devnull/Data/Page.php index c751529..41dde16 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 0000000..03f4ea6 --- /dev/null +++ b/app/Devnull/Data/ScheduledCommit.php @@ -0,0 +1,126 @@ + '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, + public ?string $commitId, + ) {} + + /** + * @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; + $commitId = $data['commit_id'] ?? 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', + commitId: is_string($commitId) && $commitId !== '' ? $commitId : null, + ); + } + + /** + * 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], + }; + } + + /** + * 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 2c01ad2..8ce994f 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/ErrorCode.php b/app/Devnull/ErrorCode.php index f67ba27..33e5648 100644 --- a/app/Devnull/ErrorCode.php +++ b/app/Devnull/ErrorCode.php @@ -26,6 +26,7 @@ enum ErrorCode: string case GitHubSyncFailed = 'github_sync_failed'; case GitHubIssueFailed = 'github_issue_failed'; case PasteisExhausted = 'pasteis_exhausted'; + case ScheduledCommitSubmitted = 'scheduled_commit_submitted'; /** * What the app says for it, or null where devnull's own message says it @@ -48,6 +49,7 @@ public function message(): ?string self::CommitUnavailable => "fatal: this commit isn't available.", self::GitHubSyncFailed => 'Could not sync from GitHub. Try again in a minute.', self::PasteisExhausted => Pasteis::Exhausted, + self::ScheduledCommitSubmitted => 'This commit was already submitted or canceled. Refresh to see its status.', self::RateLimited, self::OwnCommit, self::GitHubIssueFailed => null, }; } diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index 1adcd54..8e1acb1 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -50,6 +50,7 @@ final class ResponseCache '#^remotes$#', '#^remotes/@[^/]+/messages$#', '#^remotes/orgs/[^/]+/messages$#', + '#^scheduled-commits$#', ]; /** @@ -105,8 +106,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 b1d83b4..3566b0a 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; /** @@ -35,7 +34,7 @@ final class Timeline */ public static function feed(array $commits, ?string $firstSeenId = null, ?CarbonInterface $now = null): array { - $now ??= CarbonImmutable::now(); + $now ??= Display::now(); $rows = []; $previous = null; $previousDay = null; diff --git a/app/Devnull/Timezone.php b/app/Devnull/Timezone.php new file mode 100644 index 0000000..798b718 --- /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 e209092..0ad4418 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -40,6 +40,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; @@ -152,6 +153,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/AttachesToCommits.php b/app/NativeComponents/Concerns/AttachesToCommits.php index 044bb25..855beed 100644 --- a/app/NativeComponents/Concerns/AttachesToCommits.php +++ b/app/NativeComponents/Concerns/AttachesToCommits.php @@ -164,11 +164,11 @@ public function photoDescribed(int $index, string $description = ''): void /** * The web's poll button: two empty options, open for a day. A commit has photos or a poll, not both, and a - * reply never has one. + * reply never has one. Nor does a commit being scheduled, which devnull's app API takes as a body alone. */ public function addPoll(): void { - if ($this->isComposing() && $this->sheetView !== 'reply' && $this->sheetImages === [] && $this->sheetPoll === null) { + if ($this->isComposing() && $this->sheetView !== 'reply' && ! $this->scheduling && $this->sheetImages === [] && $this->sheetPoll === null) { $this->sheetPoll = PollDraft::start(); $this->sheetPollLength = PollDraft::DefaultLength; } @@ -300,7 +300,7 @@ protected function forgetAttachments(): void private function canAttachPhotos(): bool { - return $this->isComposing() && $this->sheetPoll === null && ! $this->sheetPosting && count($this->sheetImages) < DraftImages::MaxImages; + return $this->isComposing() && ! $this->scheduling && $this->sheetPoll === null && ! $this->sheetPosting && count($this->sheetImages) < DraftImages::MaxImages; } /** diff --git a/app/NativeComponents/Concerns/DraftsIssues.php b/app/NativeComponents/Concerns/DraftsIssues.php index c4fb1cb..863f634 100644 --- a/app/NativeComponents/Concerns/DraftsIssues.php +++ b/app/NativeComponents/Concerns/DraftsIssues.php @@ -185,7 +185,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 397ade4..b6ccfd8 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -5,9 +5,13 @@ use App\Devnull\Client; use App\Devnull\Data\Commit; use App\Devnull\Data\Draft; +use App\Devnull\Data\ScheduledCommit; use App\Devnull\ReportReason; use App\Devnull\Stash; +use App\Devnull\Timezone; +use Carbon\CarbonImmutable; use Closure; +use Native\Mobile\Facades\Dialog; /** * The sheet under a commit (see partials/commit-sheet). Report commit, in the @@ -20,6 +24,11 @@ * it. Once devnull has the commit the sheet goes, and a screen that lists * your new commits puts it in place (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 type, as on the web (see Stash): a * quote per commit quoted, and a new commit under the web's composer draft. * Opened again, it starts from the draft, marked as restored. It's forgotten @@ -43,6 +52,16 @@ trait OpensCommitSheet use AttachesToCommits; use DraftsIssues; + /** + * 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. */ @@ -83,6 +102,27 @@ 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; + /** * Report commit, in the More menu of someone else's published commit. */ @@ -173,6 +213,42 @@ 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. devnull's app API schedules a body alone, so not with photos or a + * poll, and those wait while it's on (see AttachesToCommits). + */ + public function toggleSchedule(): void + { + if ($this->sheetView !== 'compose' || $this->sheetIssue !== null || $this->sheetImages !== [] || $this->sheetPoll !== 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. An empty field forgets it, and isn't restored any more. */ @@ -203,7 +279,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 once devnull has it, or keeps the draft with why it didn't take it. + * its poll, or schedules it. The sheet goes once devnull has it, or keeps the draft with why it didn't take it. */ public function commitFromSheet(): void { @@ -216,6 +292,12 @@ public function commitFromSheet(): void return; } + if ($this->scheduling && $this->sheetView === 'compose') { + $this->scheduleFromSheet($body, $stashName); + + return; + } + $sent = function (Commit $commit) use ($stashName): void { app(Stash::class)->put($this->me()?->id, $stashName, ''); $this->sheetOpen = false; @@ -241,6 +323,21 @@ public function commitFromSheet(): void } } + /** + * 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. @@ -256,9 +353,14 @@ public function hideCommitSheet(): void public function commitSheetClosed(): void { $view = $this->sheetView; + $opensScheduledCommits = $this->opensScheduledCommits; $this->forgetCommitSheet(); $this->commitSheetGone($view); + + if ($opensScheduledCommits) { + $this->openInTab('/scheduled'); + } } /** @@ -291,6 +393,34 @@ 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. The sheet goes once devnull has it, and says so as the web does. + */ + private function scheduleFromSheet(string $body, string $stashName): void + { + $at = $this->scheduledTime(); + + if ($at === null) { + return; + } + + $this->sheetPosting = true; + + try { + $scheduled = $this->attemptInSheet(fn (Client $devnull): ScheduledCommit => $devnull->scheduleCommit($body, $at)); + } finally { + $this->sheetPosting = false; + } + + if ($scheduled === null) { + return; + } + + app(Stash::class)->put($this->me()?->id, $stashName, ''); + $this->sheetOpen = false; + Dialog::toast(self::Scheduled); + } + private function forgetCommitSheet(): void { $this->sheetOpen = false; @@ -303,6 +433,10 @@ private function forgetCommitSheet(): void $this->sheetError = null; $this->forgetAttachments(); $this->forgetIssueDraft(); + $this->scheduling = false; + $this->scheduledAt = ''; + $this->hasScheduledTime = false; + $this->opensScheduledCommits = false; } /** diff --git a/app/NativeComponents/Concerns/RefreshesInBackground.php b/app/NativeComponents/Concerns/RefreshesInBackground.php index 807a5df..598ef0b 100644 --- a/app/NativeComponents/Concerns/RefreshesInBackground.php +++ b/app/NativeComponents/Concerns/RefreshesInBackground.php @@ -140,7 +140,7 @@ protected function refresh(string $path, array $query, Closure $apply, bool $aga /** * Asks devnull how many notifications are unread, for the badge on the * Notifications tab, and picks up any change to you while it's there, - * your Remotes and their unread count among them. + * your Remotes and their unread count among them, and your timezone. * The badge is a nicety, so a failure here is never put on the screen. */ protected function refreshUnreadNotifications(): void @@ -152,6 +152,7 @@ protected function refreshUnreadNotifications(): void app(Account::class)->remember($current->user); app(Account::class)->rememberPasteis($current->pasteis()); app(Account::class)->rememberRemotes($current->remotes); + app(Account::class)->rememberTimezone($current->timezone); } app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount); diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index c13f821..f0ae29a 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -7,9 +7,11 @@ use App\Devnull\Data\Commit; 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\Timezone; use Closure; use Native\Mobile\Attributes\On; use Native\Mobile\Events\Alert\ButtonPressed; @@ -22,9 +24,9 @@ * Reply, and the More menu's Save commit, Share link, Pin to profile, Report * commit and Delete commit (Quote and Report open a sheet, see * OpensCommitSheet), plus opening its link preview or images in the in-app - * browser, "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. + * browser, "Show more" on a 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. @@ -109,6 +111,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) { + Dialog::toast(Display::fullDateTime($createdAt).' in '.Timezone::city(app(Timezone::class)->yours())); + } + } + /** * Stars or unstars straight away, then takes the count devnull sends back, * or puts the star back if it says no. Like the web, any visible commit diff --git a/app/NativeComponents/Onboarding.php b/app/NativeComponents/Onboarding.php index 0b585aa..a1b8313 100644 --- a/app/NativeComponents/Onboarding.php +++ b/app/NativeComponents/Onboarding.php @@ -258,6 +258,7 @@ private function signInWithExistingKey(): void app(Account::class)->rememberPasteis($current->pasteis()); app(Account::class)->rememberRemotes($current->remotes); $this->keepProfile($current->user); + app(Account::class)->rememberTimezone($current->timezone); $this->replace('/log'); } diff --git a/app/NativeComponents/ScheduledCommits.php b/app/NativeComponents/ScheduledCommits.php new file mode 100644 index 0000000..a3fc0b1 --- /dev/null +++ b/app/NativeComponents/ScheduledCommits.php @@ -0,0 +1,329 @@ + + */ + 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 canceled, while devnull is asked. + */ + public ?string $canceling = null; + + /** + * Long bodies opened in place with "Show more", by id. + * + * @var list + */ + public array $unfolded = []; + + /** + * The one the edit sheet is for, while it's up, with the body and the + * time (the picker's wall clock in the phone's timezone) being edited. + */ + public ?string $editing = null; + + /** + * Whether the edit sheet is up. It keeps what it's editing while it slides away, until iOS says it's gone. + */ + public bool $editOpen = false; + + public string $editBody = ''; + + public string $editAt = ''; + + /** + * Why devnull didn't take the edit, shown in the sheet. + */ + public ?string $editError = null; + + 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 Cancel: it goes from the list once devnull has canceled it. + * When it was submitted or canceled already, the web's toast says so, and + * the list asks again to show what became of it. + */ + public function cancel(string $id): void + { + if ($this->canceling !== null || $this->find($id)?->isWaiting() !== true) { + return; + } + + $this->canceling = $id; + + $canceled = $this->attempt(function (Client $devnull) use ($id): bool { + try { + $devnull->cancelScheduledCommit($id); + } catch (ApiException $exception) { + return $exception->is(ErrorCode::ScheduledCommitSubmitted) ? false : throw $exception; + } + + return true; + }); + + $this->canceling = null; + + if ($canceled === false) { + Dialog::toast((string) ErrorCode::ScheduledCommitSubmitted->message()); + $this->retry(); + } + + if ($canceled === true) { + $this->scheduled = array_values(array_filter($this->scheduled, fn (ScheduledCommit $scheduled): bool => $scheduled->id !== $id)); + Dialog::toast(self::Canceled); + } + } + + /** + * Opens the edit sheet on one still waiting, with its body and its time. + */ + public function edit(string $id): void + { + $scheduled = $this->find($id); + + if ($scheduled === null || ! $scheduled->isWaiting()) { + return; + } + + $this->editing = $id; + $this->editOpen = true; + $this->editBody = $scheduled->body ?? ''; + $this->editAt = $scheduled->scheduledAt->setTimezone(app(Timezone::class)->here())->format('Y-m-d\TH:i'); + $this->editError = null; + } + + /** + * Saves the body and the time. The sheet goes once devnull has them, and + * the list shows them; otherwise it says why and keeps them. + */ + public function saveEdit(): void + { + $id = $this->editing; + $at = $this->editTime(); + $body = trim($this->editBody); + + if ($id === null || $at === null) { + return; + } + + $screenError = $this->error; + $saved = $this->attempt(fn (Client $devnull): ScheduledCommit => $devnull->updateScheduledCommit($id, $body, $at)); + + if ($saved === null) { + $this->editError = $this->error; + $this->error = $screenError; + + return; + } + + $this->scheduled = array_map(fn (ScheduledCommit $scheduled): ScheduledCommit => $scheduled->id === $saved->id ? $saved : $scheduled, $this->scheduled); + $this->editOpen = false; + } + + /** + * iOS says the edit sheet has gone, saved or swiped away. What wasn't saved is dropped. + */ + public function editSheetClosed(): void + { + $this->editOpen = false; + $this->editing = null; + $this->editBody = ''; + $this->editAt = ''; + $this->editError = null; + } + + /** + * A time picked in the edit sheet: the web says when the phone's timezone skips it. + */ + public function updatedEditAt(): void + { + $this->editError = $this->editTime() === null ? self::SkippedTime : null; + } + + 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 commit on + * dev/null here in the app, anything else in the browser, as in a feed. + */ + public function openLink(string $id, int $index): void + { + $link = $this->find($id)?->links()[$index] ?? null; + + if ($link?->commit !== null) { + $this->openCommit($link->commit); + + return; + } + + if ($link !== null && in_array(parse_url($link->href, PHP_URL_SCHEME), ['http', 'https'], true)) { + Browser::inApp($link->href); + } + } + + /** + * The commit one became, once it was submitted. + */ + public function openCommit(string $id): void + { + $this->openInTab('/commits/'.$id); + } + + /** + * 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 + { + $editing = $this->editing === null ? null : $this->find($this->editing); + + return view('native.scheduled-commits', [ + 'myColour' => $this->myColour(), + 'timezone' => app(Timezone::class)->here(), + 'now' => CarbonImmutable::now(), + 'editingCommit' => $editing, + 'canSave' => $editing !== null && $this->editTime() !== null + && ($editing->imageCount > 0 && trim($this->editBody) === '' || Commit::fits($this->editBody)), + ]); + } + + /** + * @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; + } + + /** + * The time picked in the edit sheet, or null when the phone's timezone skips it. + */ + private function editTime(): ?CarbonImmutable + { + if ($this->editAt === '') { + return null; + } + + $at = CarbonImmutable::createFromFormat('!Y-m-d\TH:i', $this->editAt, app(Timezone::class)->here()); + + return $at instanceof CarbonImmutable && $at->format('Y-m-d\TH:i') === $this->editAt ? $at : null; + } +} diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php index 6b7a8b4..beba82c 100644 --- a/app/NativeComponents/ThisDevice.php +++ b/app/NativeComponents/ThisDevice.php @@ -13,10 +13,12 @@ use App\Devnull\Hsl; use App\Devnull\Lane; use App\Devnull\ResponseCache; +use App\Devnull\Timezone; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\SignsInWithApple; 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; @@ -28,9 +30,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 @@ -102,6 +104,25 @@ 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 = ''; + + public bool $savingTimezone = false; + + public ?string $timezoneError = null; + public bool $savingPasteisMode = false; public ?string $pasteisModeError = null; @@ -137,8 +158,9 @@ public function mount(): void $this->showMe($kept); $this->appleLinked = CurrentUser::fromArray($kept)->appleLinked; } elseif (($me = $this->me()) !== null) { - $this->current = CurrentUser::remembered($me); + $this->current = CurrentUser::remembered($me, app(Account::class)->timezone()); $this->chosenLane = $this->current->preferredLane; + $this->chosenTimezone = $this->current->timezone; } try { @@ -294,6 +316,78 @@ public function saveLane(): void }); } + /** + * 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. + */ + public function unsetTimezone(): void + { + $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. 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->chosenTimezone === $this->current->timezone) { + return; + } + + $this->savingTimezone = true; + $this->timezoneError = null; + + $this->writeInBackground('PUT', Client::TimezonePath, ['timezone' => $this->chosenTimezone], function (array $json): void { + $this->savingTimezone = false; + $this->takeMe($json); + }, function (string $message): void { + $this->savingTimezone = false; + $this->timezoneError = $message; + }); + } + /** * The people you mute and block, as the web's Safety settings list them. */ @@ -568,6 +662,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->chosenTimezone !== $current->timezone, + 'timezones' => $this->listingTimezones ? $this->timezones() : ['zones' => [], 'more' => 0], 'pasteisMode' => $current?->pasteisMode ?? false, 'remotes' => $current?->remotes, 'remote' => self::remote(), @@ -636,6 +733,49 @@ private function laneOptions(CurrentUser $current): array return $options; } + /** + * 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. */ @@ -651,8 +791,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. @@ -671,6 +811,10 @@ private function showMe(array $json): ?CurrentUser $this->chosenLane = $current->preferredLane; } + if ($this->current === null || $this->chosenTimezone === $this->current->timezone) { + $this->chosenTimezone = $current->timezone; + } + if ($this->savingPasteisMode && $this->current !== null) { $current = $current->withPasteisMode($this->current->pasteisMode); } @@ -698,6 +842,7 @@ private function takeMe(array $json): void app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount); app(Account::class)->rememberPasteis($current->pasteis()); app(Account::class)->rememberRemotes($current->remotes); + app(Account::class)->rememberTimezone($current->timezone); app(ResponseCache::class)->meChanged($json); } diff --git a/resources/icons/clock-3.svg b/resources/icons/clock-3.svg new file mode 100644 index 0000000..7572241 --- /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 7b7a015..25175ce 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 list builds and measures every node of a row as it scrolls in, so the content is one column: the rail's 64pt @@ -34,7 +34,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 00ce8fb..3e77ff4 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -26,6 +26,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. --}} @@ -43,7 +49,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, 'loading' => $sheetPosting, 'press' => $sheetPosting || ! $canCommit ? null : 'commitFromSheet']) + @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' => $sheetPosting, 'press' => $sheetPosting || ! $canCommit || ($schedulingThis && ! $hasScheduledTime) ? null : 'commitFromSheet']) @endif @@ -89,6 +96,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 99d230e..48f3fa7 100644 --- a/resources/views/native/partials/composer-tools.blade.php +++ b/resources/views/native/partials/composer-tools.blade.php @@ -1,14 +1,18 @@ {{-- 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, - not both, as on the web, so each greys out while the other is there; a reply has no poll at all. + 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. devnull's app API + schedules a body alone, so the clock and the photos and poll grey out while the other is in use. --}} @php($photosFull = count($sheetImages) >= \App\Devnull\DraftImages::MaxImages) - @include('native.partials.composer-tool', ['ref' => 'sheet-photos', 'icon' => \App\Icons\Lucide::ImagePlus, 'label' => 'Attach up to 3 photos', 'press' => 'pickPhotos', 'disabled' => $sheetPoll !== null || $photosFull || $sheetPosting]) - @include('native.partials.composer-tool', ['ref' => 'sheet-camera', 'icon' => \App\Icons\Lucide::Camera, 'label' => 'Take a photo', 'press' => 'takePhoto', 'disabled' => $sheetPoll !== null || $photosFull || $sheetPosting]) + @include('native.partials.composer-tool', ['ref' => 'sheet-photos', 'icon' => \App\Icons\Lucide::ImagePlus, 'label' => 'Attach up to 3 photos', 'press' => 'pickPhotos', 'disabled' => $sheetPoll !== null || $photosFull || $scheduling || $sheetPosting]) + @include('native.partials.composer-tool', ['ref' => 'sheet-camera', 'icon' => \App\Icons\Lucide::Camera, 'label' => 'Take a photo', 'press' => 'takePhoto', 'disabled' => $sheetPoll !== null || $photosFull || $scheduling || $sheetPosting]) @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 || $sheetPosting]) + @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 || $scheduling || $sheetPosting]) @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' => $sheetImages !== [] || $sheetPoll !== null || $sheetPosting]) + @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 0000000..ec3dee5 --- /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/scheduled-edit-sheet.blade.php b/resources/views/native/partials/scheduled-edit-sheet.blade.php new file mode 100644 index 0000000..ff25094 --- /dev/null +++ b/resources/views/native/partials/scheduled-edit-sheet.blade.php @@ -0,0 +1,60 @@ +{{-- + Edit, on a scheduled commit still waiting: its body in the composer's field, and its time in the system's date and + time picker, in the phone's timezone, within the 30 days devnull allows. Its poll and images stay as they are. Save + waits for a body long enough to make (or any, with images) and a time the timezone doesn't skip. If devnull says + no, why shows here and the edit stays. It's always on the screen, shown or not, so iOS can slide it away and say + when it's gone. +--}} + + @if ($editingCommit) + @php($editTimezone = $timezone) + @php($editNow = \Carbon\CarbonImmutable::now($editTimezone)) + + + Edit scheduled commit + + + + @if ($editingCommit->pollOptions || $editingCommit->imageCount > 0) + {{ $editingCommit->pollOptions ? 'Its poll stays as it is.' : ($editingCommit->imageCount === 1 ? 'Its image stays as it is.' : 'Its images stay as they are.') }} + @endif + + + Submit at + + {{ $editTimezone }} ({{ \App\Devnull\Timezone::offset($editTimezone, $editAt === '' ? null : \Carbon\CarbonImmutable::parse($editAt, $editTimezone)) }}). Within 30 days. + + + @if ($editError) + {{ $editError }} + @endif + + + @include('native.partials.body-counter', ['body' => $editBody, 'ref' => 'edit-budget']) + Save + + + + @endif + 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 0000000..f476971 --- /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 0000000..9cf4556 --- /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 Edit and Cancel, + and the web's line on what happens next. One submitted says what became of it, and opens its commit while it's + there. 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()) + Edit + {{ $canceling === $item->id ? 'Canceling…' : 'Cancel' }} + @elseif ($item->commitId !== null) + Commit + @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 cancel before submission.' }} + @endif + + + @endforeach + + @if ($nextPage !== null) + + Show more + + @endif + @endif + + + + {{-- After the list, so the edit sheet's nodes never change the ids of the list's, which would scroll it back to the top. --}} + @include('native.partials.scheduled-edit-sheet') + diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index 35944ac..dbdaf72 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -113,6 +113,50 @@ + {{-- + The web's Timezone section: the timezone picked, or null while it's unset and the phone's is used, + 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.", + ]) + + + + 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 + + + + 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. @@ -264,4 +308,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 f4a63de..2ddf46f 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -14,6 +14,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; @@ -33,8 +34,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); @@ -42,5 +44,6 @@ Route::native("{$tab}/safety", Safety::class); Route::native("{$tab}/conversations/{username}", Conversation::class); Route::native("{$tab}/orgs/{org}", Conversation::class); + Route::native("{$tab}/scheduled", ScheduledCommits::class); } }); diff --git a/tests/Feature/Devnull/ClientCacheTest.php b/tests/Feature/Devnull/ClientCacheTest.php index fb33dea..7b1596e 100644 --- a/tests/Feature/Devnull/ClientCacheTest.php +++ b/tests/Feature/Devnull/ClientCacheTest.php @@ -5,6 +5,7 @@ use App\Devnull\FetchInBackground; use App\Devnull\RequestSigner; use App\Devnull\ResponseCache; +use Carbon\CarbonImmutable; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; @@ -378,3 +379,41 @@ 'a block' => ['POST /api/v1/users/ada/block', fn (Client $devnull) => $devnull->block('ada')], 'an answer without the user' => ['POST /api/v1/users/ada/mute', fn (Client $devnull) => $devnull->unmute('ada')], ]); + +it('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' => Http::response(['data' => scheduledCommitJson(['id' => 's2'])], 201), + 'PUT http://devnull.test/api/v1/scheduled-commits/s1' => Http::response(['data' => scheduledCommitJson(['id' => 's1'])]), + '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([ + 'scheduling one' => [fn (Client $devnull) => $devnull->scheduleCommit('Shipping later.', now()->addDay())], + 'editing one' => [fn (Client $devnull) => $devnull->updateScheduledCommit('s1', 'Shipping later.', now()->addDay())], + 'canceling one' => [fn (Client $devnull) => $devnull->cancelScheduledCommit('s1')], +]); + +it('sends the time to schedule for in UTC, with its offset, as devnull takes it', function () { + serveApi(['POST /api/v1/scheduled-commits' => [['data' => scheduledCommitJson(['scheduled_at' => '2026-10-20T08:00:00Z'])], 201]]); + + $scheduled = $this->devnull->scheduleCommit('Shipping this tomorrow.', CarbonImmutable::parse('2026-10-20 09:00', 'Europe/Lisbon')); + + expect(Http::recorded()[0][0]->body())->toBe('{"body":"Shipping this tomorrow.","scheduled_at":"2026-10-20T08:00:00+00:00"}') + ->and($scheduled->scheduledAt->toIso8601ZuluString())->toBe('2026-10-20T08:00:00Z') + ->and($scheduled->isWaiting())->toBeTrue(); +}); diff --git a/tests/Feature/Devnull/TimezoneTest.php b/tests/Feature/Devnull/TimezoneTest.php new file mode 100644 index 0000000..4ad2317 --- /dev/null +++ b/tests/Feature/Devnull/TimezoneTest.php @@ -0,0 +1,131 @@ + '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 your timezone with the refresh of you that every tab makes', function () { + signIn(); + Http::fake([ + 'devnull.test/api/v1/me' => Http::response(currentUserJson(['timezone' => 'Asia/Tokyo'])), + 'devnull.test/*' => Http::response(pageJson([])), + ]); + + Native::visit('/log'); + + expect(app(Account::class)->timezone())->toBe('Asia/Tokyo'); +}); diff --git a/tests/Feature/Screens/AccountTimezoneTest.php b/tests/Feature/Screens/AccountTimezoneTest.php new file mode 100644 index 0000000..bb47b60 --- /dev/null +++ b/tests/Feature/Screens/AccountTimezoneTest.php @@ -0,0 +1,203 @@ + '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(); +} + +/** + * 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'); + serveApi(['GET /api/v1/me' => [currentUserJson(['timezone' => null])]]); + + $screen = Native::visit('/profile/device') + ->assertSee('Timezone') + ->assertSee("Leave it unset to use this phone's timezone without sharing it.") + ->assertSee('GMT+01:00 Atlantic/Canary') + ->assertSee('Use Atlantic/Canary from this phone'); + + 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("uses the phone's timezone, saves it, and remembers it for the times the app shows", function () { + phoneIn('Atlantic/Canary'); + serveApi([ + 'GET /api/v1/me' => [currentUserJson(['timezone' => null])], + 'PUT /api/v1/me/timezone' => [currentUserJson(['timezone' => 'Atlantic/Canary'])], + ]); + + $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) + ->assertDontSee('Use Atlantic/Canary from this phone'); + + expect(timezoneSaves())->toBe(['{"timezone":"Atlantic/Canary"}']) + ->and(app(Account::class)->timezone())->toBe('Atlantic/Canary') + ->and(nodeByRef($screen, 'save-timezone')['props']['disabled'] ?? false)->toBeTrue(); +}); + +it("lists the timezones at the phone's offset first, with the one picked on top", function () { + phoneIn('Europe/Lisbon'); + serveApi(['GET /api/v1/me' => [currentUserJson(['timezone' => '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 () { + serveApi([ + 'GET /api/v1/me' => [currentUserJson(['timezone' => 'Europe/Lisbon'])], + 'PUT /api/v1/me/timezone' => [currentUserJson(['timezone' => 'America/Sao_Paulo'])], + ]); + + $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"}']) + ->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 () { + serveApi(['GET /api/v1/me' => [currentUserJson(['timezone' => null])]]); + + $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 () { + serveApi(['GET /api/v1/me' => [currentUserJson(['timezone' => null])]]); + + Native::visit('/profile/device') + ->tap('choose-timezone') + ->input('timezone-search', 'Mars/Olympus') + ->assertSee('No timezone matches "Mars/Olympus".'); +}); + +it('unsets the timezone, so the phone\'s is used again', function () { + app(Account::class)->rememberTimezone('Europe/Lisbon'); + serveApi([ + 'GET /api/v1/me' => [currentUserJson(['timezone' => 'Europe/Lisbon'])], + 'PUT /api/v1/me/timezone' => [currentUserJson(['timezone' => null])], + ]); + + Native::visit('/profile/device') + ->tap('unset-timezone') + ->assertSet('chosenTimezone', null) + ->tap('save-timezone'); + + expect(timezoneSaves())->toBe(['{"timezone":null}']) + ->and(app(Account::class)->timezone())->toBeNull(); +}); + +it('keeps the timezone picked, and says why, when devnull does not take it', function () { + serveApi([ + 'GET /api/v1/me' => [currentUserJson(['timezone' => null])], + 'PUT /api/v1/me/timezone' => [['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('picks up a timezone chosen on the web with any refresh of you', function () { + serveApi(['GET /api/v1/me' => [currentUserJson(['timezone' => 'Asia/Tokyo'])]]); + + Native::visit('/profile/device')->assertSet('chosenTimezone', 'Asia/Tokyo'); + + expect(app(Account::class)->timezone())->toBe('Asia/Tokyo'); +}); + +it('ignores a timezone it does not know', function () { + 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'); + serveApi(['GET /api/v1/me' => [currentUserJson(['timezone' => null])]]); + + $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 0e8b35a..19b79fe 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -155,6 +155,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('Dialog.Toast', 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/ScheduleCommitTest.php b/tests/Feature/Screens/ScheduleCommitTest.php new file mode 100644 index 0000000..9799f9b --- /dev/null +++ b/tests/Feature/Screens/ScheduleCommitTest.php @@ -0,0 +1,221 @@ + 'http://devnull.test']); + $this->bridge = signIn(); + phoneIn('Europe/Lisbon'); + $this->travelTo(CarbonImmutable::parse('2026-10-19 15:30:00', 'UTC')); +}); + +/** + * 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('Dialog.Toast', 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'))->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('Dialog.Toast'); +}); + +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 body alone, as devnull's app API takes one, so photos and a poll wait while it's on", function () { + $screen = composeToSchedule()->tap('sheet-schedule'); + + expect(nodeByRef($screen, 'sheet-photos')['type'])->toBe('column') + ->and(nodeByRef($screen, 'sheet-camera')['type'])->toBe('column') + ->and(nodeByRef($screen, 'sheet-poll')['type'])->toBe('column'); + + $screen->call('addPoll')->assertSet('sheetPoll', null) + ->call('pickPhotos') + ->assertNativeNotCalled('Camera.PickMedia'); + + // With a poll, the clock waits instead. + $screen->tap('sheet-schedule')->tap('sheet-poll'); + + expect(nodeByRef($screen, 'sheet-schedule')['type'])->toBe('column'); + + $screen->call('toggleSchedule')->assertSet('scheduling', false); +}); diff --git a/tests/Feature/Screens/ScheduledCommitsTest.php b/tests/Feature/Screens/ScheduledCommitsTest.php new file mode 100644 index 0000000..5c2bfe9 --- /dev/null +++ b/tests/Feature/Screens/ScheduledCommitsTest.php @@ -0,0 +1,223 @@ + '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', 'commit_id' => 'c7']), + ]; +} + +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 cancel 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 edited or canceled. A submitted one opens its commit. + ->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 => ($node['ref'] ?? null) === 'cancel-s-done'))->toBe([]); + + $screen->tap('open-commit-s-done')->assertNavigatedTo('/log/commits/c7'); +}); + +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('canceling', null) + ->assertNativeCalled('Dialog.Toast', 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('says so, as the web does, when it was submitted before it could be canceled, and shows what became of it', function () { + serveApi([ + 'GET /api/v1/scheduled-commits' => [scheduledPageJson(threeScheduled())], + 'DELETE /api/v1/scheduled-commits/s-due' => [['message' => 'This commit was already submitted or canceled. Refresh to see its status.', 'code' => 'scheduled_commit_submitted'], 409], + ]); + + $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', 'commit_id' => 'c8'])])], + 'DELETE /api/v1/scheduled-commits/s-due' => [['message' => 'This commit was already submitted or canceled. Refresh to see its status.', 'code' => 'scheduled_commit_submitted'], 409], + ]); + + $screen->tap('cancel-s-due') + ->assertNativeCalled('Dialog.Toast', 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'); +}); + +it("edits one still waiting: its body and its time, in the phone's timezone", function () { + serveApi([ + 'GET /api/v1/scheduled-commits' => [scheduledPageJson(threeScheduled())], + 'PUT /api/v1/scheduled-commits/s-later' => [['data' => scheduledCommitJson(['id' => 's-later', 'body' => 'Shipping this on Wednesday.', 'image_count' => 2, 'scheduled_at' => '2026-10-21T13:15:00Z'])]], + ]); + + $screen = Native::visit('/log/scheduled') + ->tap('edit-s-later') + ->assertSet('editOpen', true) + ->assertSet('editBody', 'Shipping this tomorrow.') + ->assertSet('editAt', '2026-10-20T09:00') + ->assertSee('Edit scheduled commit') + ->assertSee('Its images stay as they are.'); + + expect(nodeByRef($screen, 'edit-at')['props'])->toMatchArray(['mode' => 'datetime', 'timezone' => 'Europe/Lisbon', 'value' => '2026-10-20T09:00']); + + $screen->input('edit-body', 'Shipping this on Wednesday.') + ->pickDateTime('edit-at', '2026-10-21T14:15') + ->tap('save-edit') + ->assertSet('editOpen', false) + ->assertSet('editError', null); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'PUT' + && str_ends_with($request->url(), '/api/v1/scheduled-commits/s-later') + && $request->body() === '{"body":"Shipping this on Wednesday.","scheduled_at":"2026-10-21T13:15:00+00:00"}'); + + expect(nodeByRef($screen, 'scheduled-at-s-later')['props']['text'] ?? null)->toBe('Oct 21, 2026, 2:15 PM'); +}); + +it('keeps the edit, and says why, when devnull does not take it', function () { + serveApi([ + 'GET /api/v1/scheduled-commits' => [scheduledPageJson(threeScheduled())], + 'PUT /api/v1/scheduled-commits/s-later' => [['message' => 'The scheduled at field must be a date after now.', 'errors' => ['scheduled_at' => ['The scheduled at field must be a date after now.']]], 422], + ]); + + Native::visit('/log/scheduled') + ->tap('edit-s-later') + ->pickDateTime('edit-at', '2026-10-19T16:31') + ->tap('save-edit') + ->assertSet('editOpen', true) + ->assertSet('editAt', '2026-10-19T16:31') + ->assertSee('The scheduled at field must be a date after now.') + ->assertDontSee('fatal'); +}); + +it('waits for a body and a time the phone has, before it saves', function () { + fakeApi(['GET /api/v1/scheduled-commits' => [scheduledPageJson([scheduledCommitJson(['id' => 's-later', 'scheduled_at' => '2027-03-27T12:00:00Z'])])]]); + + $screen = Native::visit('/log/scheduled')->tap('edit-s-later')->input('edit-body', ' '); + + expect(nodeByRef($screen, 'save-edit')['props']['disabled'] ?? false)->toBeTrue(); + + // One character is a commit now, on devnull as on the web. + $screen->input('edit-body', 'k'); + expect(nodeByRef($screen, 'save-edit')['props']['disabled'] ?? false)->toBeFalse(); + + $screen->pickDateTime('edit-at', '2027-03-28T01:30') + ->assertSee('This time does not exist in your timezone. Choose another time.'); + + expect(nodeByRef($screen, 'save-edit')['props']['disabled'] ?? false)->toBeTrue(); + + $screen->call('saveEdit'); + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'PUT'); +}); + +it('drops an edit that was swiped away', function () { + fakeApi(['GET /api/v1/scheduled-commits' => [scheduledPageJson(threeScheduled())]]); + + Native::visit('/log/scheduled') + ->tap('edit-s-later') + ->input('edit-body', 'Never mind.') + ->dismissSheet('edit-sheet') + ->assertSet('editOpen', false) + ->assertSet('editing', null) + ->assertSet('editBody', ''); +}); + +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/Pest.php b/tests/Pest.php index 9ea9bd6..b55b374 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; @@ -951,3 +952,59 @@ 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', + 'commit_id' => null, + ...$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 12049cc..24cba31 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,6 +2,7 @@ namespace Tests; +use App\Devnull\Timezone; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Facades\File; use Native\Mobile\AsyncTask; @@ -13,12 +14,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 503326eaacc1c7f8212616d2035c6cd63f6f5c00 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Wed, 21 Oct 2026 14:38:01 +0100 Subject: [PATCH 2/2] 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 798b718..7ab7582 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 43e8441..70c92b4 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 4ad2317..850aad0 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');