Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions app/Devnull/Account.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
{
Expand All @@ -166,5 +189,6 @@ public function forget(): void
Cache::forget(self::PasteisKey);
Cache::forget(self::RemotesKey);
Cache::forget(self::ProfileKey);
Cache::forget(self::TimezoneKey);
}
}
64 changes: 64 additions & 0 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {}

/**
Expand Down Expand Up @@ -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<ScheduledCommit>
*/
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));
Expand Down
10 changes: 7 additions & 3 deletions app/Devnull/Data/CurrentUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Devnull\Data;

use App\Devnull\Lane;
use App\Devnull\Timezone;
use Carbon\CarbonImmutable;

/**
Expand All @@ -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
{
Expand All @@ -30,6 +32,7 @@ public function __construct(
public Pasteis $pasteisToday = new Pasteis,
public int $pasteisReceived = 0,
public ?Remotes $remotes = null,
public ?string $timezone = null,
) {}

/**
Expand Down Expand Up @@ -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]]);
}

/**
Expand Down
6 changes: 4 additions & 2 deletions app/Devnull/Data/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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;
}
}
126 changes: 126 additions & 0 deletions app/Devnull/Data/ScheduledCommit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

namespace App\Devnull\Data;

use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;

/**
* A commit you scheduled, as devnull lists it for the web's Scheduled commits
* page, which only you see. Until it's submitted it has its body, the options
* of its poll, and how many images it carries. Once submitted, those are its
* commit's, which it names while that commit is there, and its status is that
* commit's: pending review, approved, rejected, or deleted.
*/
final readonly class ScheduledCommit
{
/**
* What the web calls each status once it's been submitted.
*/
public const array Labels = [
'pending' => 'Submitted for review',
'approved' => 'Approved',
'rejected' => 'Rejected',
'deleted' => 'Deleted',
];

/**
* @param list<BodyBlock> $content
* @param list<BodyBlock>|null $previewContent
* @param list<string>|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<string, mixed> $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<BodyLink>
*/
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;
}
}
34 changes: 30 additions & 4 deletions app/Devnull/Display.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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();
}
Expand All @@ -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');
}

/**
Expand Down
Loading
Loading