Skip to content
Merged
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 @@ -32,6 +32,8 @@ final class Account
*/
private const string RemotesOff = 'off';

private const string TimezoneKey = 'devnull.timezone';

public function user(): ?User
{
$attributes = Cache::get(self::CacheKey);
Expand Down Expand Up @@ -168,8 +170,29 @@ public function rememberSkipsPinWarning(): void
}

/**
* Signs this phone out: who it was, the badges, its pastéis and Remotes, every answer, image and voice note kept
* for a quick start, and your unsent drafts and recordings.
* The timezone you chose on devnull, as it last said, or null while you haven't chosen one.
*/
public function timezone(): ?string
{
$timezone = Cache::get(self::TimezoneKey);

return Timezone::isKnown($timezone) ? $timezone : null;
}

public function rememberTimezone(?string $timezone): void
{
if ($timezone === null) {
Cache::forget(self::TimezoneKey);

return;
}

Cache::forever(self::TimezoneKey, $timezone);
}

/**
* Signs this phone out: who it was, the badges, its pastéis and Remotes, your timezone, every answer, image and
* voice note kept for a quick start, and your unsent drafts and recordings.
*/
public function forget(): void
{
Expand All @@ -185,5 +208,6 @@ public function forget(): void
Cache::forget(self::RemotesKey);
Cache::forget(self::ProfileKey);
Cache::forget(self::PinWarningKey);
Cache::forget(self::TimezoneKey);
}
}
74 changes: 74 additions & 0 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
use App\Devnull\Data\Notification;
use App\Devnull\Data\Page;
use App\Devnull\Data\Profile;
use App\Devnull\Data\ScheduledCommit;
use App\Devnull\Data\User;
use App\Devnull\Exceptions\ApiException;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
Expand Down Expand Up @@ -103,6 +105,20 @@
*/
public const int RepliesPerPage = 5;

/**
* Where the signed-in account's timezone is saved, or unset, with whether their profile shows their local time.
* devnull sends both with the rest of what only the user sees in the web's settings.
*/
public const string TimezonePath = 'me/timezone';

public const string SettingsPath = 'me/settings';

/**
* The commits the signed-in account scheduled, as the web's Scheduled
* commits page lists them.
*/
public const string ScheduledCommitsPath = 'scheduled-commits';

public function __construct(private DeviceKey $deviceKey, private string $baseUrl, private ResponseCache $cache) {}

/**
Expand Down Expand Up @@ -287,6 +303,64 @@ public function signedMessage(string $threadPath, string $body, string $clientId
};
}

/**
* Signs a commit to be submitted at $at, as the web's Schedule commit
* does, for the background to send. Only you see it until then. With
* photos it goes as multipart, as a commit with photos does, and a poll
* goes in its JSON.
*
* @param list<DraftImage> $images
* @param array<string, mixed>|null $poll
*/
public function signedScheduledCommit(string $body, CarbonInterface $at, array $images = [], ?array $poll = null): SignedRequest
{
$payload = array_filter(['body' => $body, 'scheduled_at' => self::scheduleTime($at), 'poll' => $poll], fn (mixed $value): bool => $value !== null);

return $images === []
? $this->signedWrite('POST', self::ScheduledCommitsPath, $payload)
: $this->signedWithImages(self::ScheduledCommitsPath, $payload, $images);
}

/**
* Your scheduled commits, the ones waiting first, 20 a page. They're
* paged by number, which a Page carries as its cursor.
*
* @return Page<ScheduledCommit>
*/
public function scheduledCommits(?string $page = null): Page
{
return Page::fromArray($this->get(self::ScheduledCommitsPath, ['page' => $page]), ScheduledCommit::fromArray(...));
}

/**
* The web's Commit now: one still waiting goes for review now instead of
* at its time. devnull answers with it as the list now shows it.
*/
public function submitScheduledCommit(string $id): ScheduledCommit
{
$scheduled = $this->data($this->send('POST', self::ScheduledCommitsPath.'/'.rawurlencode($id).'/submission'));
$this->cache->forget(self::ScheduledCommitsPath);

return ScheduledCommit::fromArray($scheduled);
}

/**
* Cancels a scheduled commit that's still waiting, as the web's Cancel does.
*/
public function cancelScheduledCommit(string $id): void
{
$this->send('DELETE', self::ScheduledCommitsPath.'/'.rawurlencode($id));
$this->cache->forget(self::ScheduledCommitsPath);
}

/**
* A time as devnull takes one to schedule for: ISO 8601 with its offset, in UTC.
*/
public static function scheduleTime(CarbonInterface $at): string
{
return CarbonImmutable::instance($at)->utc()->format('Y-m-d\TH:i:sP');
}

public function deleteCommit(string $id): void
{
$this->send('DELETE', 'commits/'.rawurlencode($id));
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;
}
}
132 changes: 132 additions & 0 deletions app/Devnull/Data/ScheduledCommit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?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, 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,
) {}

/**
* @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;

return new self(
id: (string) ($data['id'] ?? ''),
body: $body,
content: $body === null ? [] : CommitBody::blocks($body, $segments),
previewContent: $body === null || $previewSegments === null ? null : CommitBody::blocks($body, $previewSegments),
pollOptions: $options,
imageCount: max(0, (int) ($data['image_count'] ?? 0)),
scheduledAt: Commit::date($data['scheduled_at'] ?? null) ?? CarbonImmutable::now(),
submittedAt: Commit::date($data['submitted_at'] ?? null),
status: is_string($status) && ($status === 'scheduled' || array_key_exists($status, self::Labels)) ? $status : 'scheduled',
);
}

/**
* Whether it's still waiting to be submitted, so it can be edited or canceled.
*/
public function isWaiting(): bool
{
return $this->status === 'scheduled';
}

/**
* Whether it's due and waiting for the scheduler, which submits due commits every minute.
*/
public function isDue(?CarbonInterface $now = null): bool
{
return $this->isWaiting() && $this->scheduledAt->lessThanOrEqualTo($now ?? CarbonImmutable::now());
}

/**
* What the web's list says of it.
*/
public function label(?CarbonInterface $now = null): string
{
return match (true) {
$this->isDue($now) => 'Waiting for submission',
$this->isWaiting() => 'Scheduled',
default => self::Labels[$this->status],
};
}

/**
* This one as devnull says it is now it's been committed early, keeping
* what was sent, as the web's list keeps showing it.
*/
public function submittedAs(self $now): self
{
return new self(...[...get_object_vars($this), 'submittedAt' => $now->submittedAt, 'status' => $now->status]);
}

/**
* The links in its body, in order, which its rows open by their place among them, as a commit's do.
*
* @return list<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
5 changes: 3 additions & 2 deletions app/Devnull/ResponseCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ final class ResponseCache
'#^remotes$#',
'#^remotes/@[^/]+/messages$#',
'#^remotes/orgs/[^/]+/messages$#',
'#^scheduled-commits$#',
];

/**
Expand Down Expand Up @@ -111,8 +112,8 @@ final class ResponseCache
*/
public static function caches(string $path, array $query = []): bool
{
// A cursor's page, and a Remotes thread's messages before or after one, aren't the first page.
if (array_intersect_key(self::normalise($query), array_flip(['cursor', 'before', 'after'])) !== []) {
// A cursor's page, a numbered page, and a Remotes thread's messages before or after one, aren't the first page.
if (array_intersect_key(self::normalise($query), array_flip(['cursor', 'page', 'before', 'after'])) !== []) {
return false;
}

Expand Down
3 changes: 1 addition & 2 deletions app/Devnull/Timeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use App\Devnull\Data\Commit;
use App\Devnull\Data\TimelineRow;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;

/**
Expand Down Expand Up @@ -37,7 +36,7 @@ final class Timeline
*/
public static function feed(array $commits, ?string $firstSeenId = null, ?CarbonInterface $now = null, ?int $whoToFollowAfter = null): array
{
$now ??= CarbonImmutable::now();
$now ??= $commits === [] ? null : Display::now();
$rows = [];
$previous = null;
$previousDay = null;
Expand Down
Loading
Loading