Skip to content
Draft
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
1 change: 1 addition & 0 deletions app/Devnull/Account.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ public function forget(): void
{
app(ResponseCache::class)->flush();
app(Stash::class)->forget($this->user()?->id);
app(Outbox::class)->forgetAll($this->user()?->id);

Cache::forget(self::CacheKey);
Cache::forget(self::UnreadKey);
Expand Down
11 changes: 11 additions & 0 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,17 @@ public function createCommit(string $body, ?string $parentId = null, ?string $qu
return Commit::fromArray($commit);
}

/**
* Signs a commit without sending it, for the outbox to send from the
* background, as createCommit() would send it (see Outbox).
*
* @param array{options: list<string>, duration: int}|null $poll
*/
public function signedCommit(string $body, ?string $parentId = null, ?string $quotedCommitId = null, ?array $poll = null): SignedRequest
{
return $this->signedWrite('POST', 'commits', self::commitPayload($body, $parentId, $quotedCommitId, poll: $poll));
}

/**
* Signs a commit with images without sending it, for the background to
* send while the sheet says it's under way, since photos take a while to
Expand Down
14 changes: 14 additions & 0 deletions app/Devnull/Data/Commit.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@

public const int PreviewLines = 8;

/**
* The status of a commit you made that devnull doesn't have yet, which
* stands in for it on the screen (see PendingWrite::stagedCommit()).
*/
public const string Staged = 'staged';

/**
* @param list<Mention> $mentions
* @param list<CommitImage> $images
Expand Down Expand Up @@ -297,6 +303,14 @@ public function isPending(): bool
return $this->status === 'pending';
}

/**
* Whether this is a commit still on its way to devnull, standing in for the one it will become.
*/
public function isStaged(): bool
{
return $this->status === self::Staged;
}

public function isApproved(): bool
{
return $this->status === 'approved';
Expand Down
31 changes: 31 additions & 0 deletions app/Devnull/FetchInBackground.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Devnull;

use App\Devnull\Exceptions\ApiException;
use Illuminate\Http\Client\ConnectionException;
use Native\Mobile\AsyncTask;

Expand Down Expand Up @@ -32,6 +33,36 @@ final class FetchInBackground extends AsyncTask
*/
public const int ImagesWithin = 5;

/**
* What went wrong with a write this task sent, as the API would have
* thrown it on the UI thread, or null when devnull took it. A phone that
* couldn't reach devnull reads as not connecting, and an answer that
* isn't devnull's, such as a captive portal's page, as unexpected.
*/
public static function failureOf(mixed $result): ?ApiException
{
$result = is_array($result) ? $result : [];
$status = (int) ($result['status'] ?? 0);
$successful = $status >= 200 && $status < 300;

return match (true) {
$successful && ($result['from_devnull'] ?? false) === true => null,
$status === 0 => ApiException::couldNotConnect((string) config('devnull.url')),
$successful => ApiException::unexpectedAnswer((string) config('devnull.url'), $status),
default => ApiException::fromParts($status, $result['json'] ?? null, (string) ($result['retry_after'] ?? '')),
};
}

/**
* The JSON devnull answered a write with, or an empty array for a write it answered with nothing.
*
* @return array<string, mixed>
*/
public static function jsonOf(mixed $result): array
{
return is_array($result) && is_array($result['json'] ?? null) ? $result['json'] : [];
}

/**
* @param array<string, string> $headers
* @return array{status: int, json: mixed, retry_after: string, from_devnull: bool}
Expand Down
216 changes: 216 additions & 0 deletions app/Devnull/Outbox.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
<?php

namespace App\Devnull;

use App\Models\PendingWrite;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Native\Mobile\Support\AsyncTaskRegistry;

/**
* What you did and devnull hasn't answered for yet, kept in the app's own
* database so a tap never waits on devnull: your stars, commits, replies and
* Remotes messages are written here first, the screen shows them at once, and
* they go from the background straight away (see Concerns\SyncsOutbox).
*
* Each one stays here until devnull answers. Once it has, a star or a commit
* goes, and the screen takes devnull's answer. If devnull turns it down, a
* star goes and comes back off the commit, a commit stays as failed for you to
* edit, and a message stays in its thread as rejected for you to retry.
*
* The app can be closed while one is on its way, or the phone can kill it.
* The next time the app opens, what was still on its way is a leftover (see
* leftovers()): stars and messages go again, since devnull can't do them
* twice, and commits are marked failed, since devnull might have one already
* and has nothing to know it by.
*
* It's kept per signed-in user, and signing out empties it (see Account).
*/
final class Outbox
{
/**
* The event devnull's answer to a write comes back as, to whichever screen
* is showing by then, rather than only to the screen it was made on.
*/
public const string Answered = 'devnull.outbox-answered';

/**
* How long a write may take before it's given up on, in seconds, and a
* commit with photos, which takes longer to upload over a slow connection.
*/
public const int Timeout = 30;

public const int UploadTimeout = 120;

/**
* What a commit says when devnull may have it already but never said so:
* its background task gave up waiting, or it was left over from when the
* app closed.
*/
public const string NoAnswer = "devnull didn't answer in time, so this commit may not have gone. Check before you send it again.";

public const string ClosedBeforeAnswer = 'The app closed before devnull answered, so this commit may not have gone. Check before you send it again.';

/**
* Writes something down to go to devnull, under a client id of its own,
* or the one a Remotes message is already staged under.
*
* @param array<string, mixed> $payload
*/
public function add(string $kind, ?string $target, array $payload, ?string $clientId = null): ?PendingWrite
{
$userId = $this->userId();

if ($userId === null) {
return null;
}

return PendingWrite::query()->create([
'user_id' => $userId,
'kind' => $kind,
'target' => $target,
'client_id' => $clientId ?? (string) Str::uuid(),
'payload' => $payload,
'status' => PendingWrite::Queued,
]);
}

/**
* It went, as the background task with this id.
*/
public function sending(PendingWrite $write, string $taskId): void
{
$write->forceFill([
'status' => PendingWrite::Sending,
'task_id' => $taskId,
'error' => null,
'attempts' => $write->attempts + 1,
])->save();
}

/**
* devnull turned it down, in its own words.
*/
public function failed(PendingWrite $write, string $error): void
{
$write->forceFill(['status' => PendingWrite::Failed, 'task_id' => null, 'error' => $error])->save();
}

public function forget(PendingWrite $write): void
{
$write->delete();
}

/**
* The write a background task was sending.
*/
public function forTask(string $taskId): ?PendingWrite
{
return $taskId === '' ? null : $this->mine()->where('task_id', $taskId)->first();
}

public function find(int $id): ?PendingWrite
{
return $this->mine()->whereKey($id)->first();
}

public function findByClientId(string $clientId): ?PendingWrite
{
return $this->mine()->where('client_id', $clientId)->first();
}

/**
* What's still waiting to go, or on its way, of a kind, about $target
* when given, oldest first. Failed writes aren't, unless asked for too.
*
* @return Collection<int, PendingWrite>
*/
public function pending(string $kind, ?string $target = null, bool $withFailed = false): Collection
{
return $this->mine()
->where('kind', $kind)
->when($target !== null, fn ($query) => $query->where('target', $target))
->whereIn('status', $withFailed ? [PendingWrite::Queued, PendingWrite::Sending, PendingWrite::Failed] : [PendingWrite::Queued, PendingWrite::Sending])
->orderBy('id')
->get();
}

/**
* Commits on their way that reply to $parentId, or with null, new commits and quotes.
*
* @return Collection<int, PendingWrite>
*/
public function commitsUnder(?string $parentId): Collection
{
return $this->pending(PendingWrite::Commit)->filter(fn (PendingWrite $write): bool => $write->parentId() === $parentId)->values();
}

/**
* Whether a star or an unstar of the commit other than $write is still waiting or on its way, which has the last word.
*/
public function starsAfter(PendingWrite $write): bool
{
return $this->mine()
->whereIn('kind', [PendingWrite::Star, PendingWrite::Unstar])
->where('target', $write->target)
->where('id', '>', $write->id)
->whereIn('status', [PendingWrite::Queued, PendingWrite::Sending])
->exists();
}

/**
* The commit devnull last turned down that was written under the draft $stashName, for its composer to have back.
*/
public function failedDraft(string $stashName): ?PendingWrite
{
return $this->mine()
->where('kind', PendingWrite::Commit)
->where('status', PendingWrite::Failed)
->get()
->filter(fn (PendingWrite $write): bool => $write->stashName() === $stashName)
->last();
}

/**
* What was waiting or on its way when the app last closed: anything not
* sent from this run of the app, since a background task doesn't outlive
* the app, and one still on its way is known to it (see AsyncTaskRegistry).
*
* @return Collection<int, PendingWrite>
*/
public function leftovers(): Collection
{
return $this->mine()
->whereIn('status', [PendingWrite::Queued, PendingWrite::Sending])
->orderBy('id')
->get()
->filter(fn (PendingWrite $write): bool => $write->task_id === null || AsyncTaskRegistry::scope($write->task_id) === null)
->values();
}

/**
* Empties the outbox of a user signing out.
*/
public function forgetAll(?string $userId): void
{
if ($userId !== null) {
PendingWrite::query()->where('user_id', $userId)->delete();
}
}

/**
* @return Builder<PendingWrite>
*/
private function mine()
{
return PendingWrite::query()->where('user_id', $this->userId() ?? '');
}

private function userId(): ?string
{
$id = app(Account::class)->user()?->id;

return $id === null || $id === '' ? null : $id;
}
}
59 changes: 57 additions & 2 deletions app/Devnull/ResponseCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,63 @@ public function commitStarred(array $commit): void
? self::prepend($json, $commit)
: [...$json, 'data' => self::without($json['data'] ?? [], $id)]);

$unstarred = array_diff($this->unstarredIds(), [$id]);
$this->store()->forever("{$userId} unstarred", array_slice($commit['starred'] ? $unstarred : [$id, ...$unstarred], 0, self::RememberedUnstars));
$this->rememberUnstarred($id, ! $commit['starred']);
}

/**
* Remembers a commit as unstarred on this phone, or forgets it was once it's starred again.
*/
private function rememberUnstarred(string $id, bool $unstarred): void
{
$userId = $this->userId();

if ($userId === null) {
return;
}

$others = array_diff($this->unstarredIds(), [$id]);
$this->store()->forever("{$userId} unstarred", array_slice($unstarred ? [$id, ...$others] : $others, 0, self::RememberedUnstars));
}

/**
* You starred or unstarred a commit, before devnull has said so, or it
* turned that down and the star goes back. Every copy of the commit flips
* its star and moves its count to match, as the screen does (see
* Commit::withStar()), and your starred commits take it on top or let it
* go, as for devnull's answer. A commit starred with no copy kept anywhere
* but $copy, as one taken off your starred commits had before, waits for
* devnull's answer to go on top of them.
*
* @param array<string, mixed>|null $copy
*/
public function starChanged(string $id, bool $starred, ?array $copy = null): void
{
$kept = $this->findCommit($id) ?? $copy;
$change = fn (array $commit): array => ($commit['starred'] ?? false) === $starred ? $commit : [
...$commit,
'starred' => $starred,
'stars_count' => max(0, (int) ($commit['stars_count'] ?? 0) + ($starred ? 1 : -1)),
];

$this->patchCommit($id, $change);

if ($starred && $kept === null) {
$this->rememberUnstarred($id, false);

return;
}

$this->commitStarred($change([...($kept ?? []), 'id' => $id]));
}

/**
* Any kept copy of a commit, as devnull last sent it.
*
* @return array<string, mixed>|null
*/
public function keptCommit(string $id): ?array
{
return $this->findCommit($id);
}

/**
Expand Down
Loading
Loading