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..7ab7582 --- /dev/null +++ b/app/Devnull/Timezone.php @@ -0,0 +1,153 @@ +getTimestamp(); + + if ($this->phone === null || $now - $this->phone['at'] >= self::PhoneSeconds) { + $this->phone = ['name' => $this->fromDeviceInfo() ?? $this->fromLocaltime(), 'at' => $now]; + } + + return $this->phone['name']; + } + + /** + * 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); + } + + /** + * 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/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/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/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..850aad0 --- /dev/null +++ b/tests/Feature/Devnull/TimezoneTest.php @@ -0,0 +1,185 @@ + '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("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'); + + 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