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
26 changes: 26 additions & 0 deletions app/Devnull/Data/DraftImage.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,32 @@
{
public function __construct(public string $path, public string $mimeType, public int $width, public int $height, public string $description = '') {}

/**
* The image as the stash and the outbox keep it, or null when its file has gone from the phone.
*/
public static function fromArray(mixed $image): ?self
{
if (! is_array($image) || ! is_string($image['path'] ?? null) || ! is_file($image['path'])) {
return null;
}

return new self($image['path'], (string) ($image['mime_type'] ?? ''), (int) ($image['width'] ?? 0), (int) ($image['height'] ?? 0), (string) ($image['description'] ?? ''));
}

/**
* @return array{path: string, mime_type: string, width: int, height: int, description: string}
*/
public function toArray(): array
{
return [
'path' => $this->path,
'mime_type' => $this->mimeType,
'width' => $this->width,
'height' => $this->height,
'description' => $this->description,
];
}

public function withDescription(string $description): self
{
return new self(...[...get_object_vars($this), 'description' => mb_substr($description, 0, DraftImages::MaxDescriptionLength)]);
Expand Down
64 changes: 64 additions & 0 deletions app/Devnull/Data/StashedDraft.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace App\Devnull\Data;

/**
* A draft as the stash keeps it (see App\Devnull\Stash): what you wrote, its
* photos with their descriptions, and its poll with how long it runs, as the
* composer had them when you left it.
*/
final readonly class StashedDraft
{
/**
* @param list<DraftImage> $images
* @param list<string>|null $poll The poll's options, or null with no poll.
*/
public function __construct(
public string $body = '',
public array $images = [],
public ?array $poll = null,
public string $pollLength = PollDraft::DefaultLength,
) {}

/**
* The draft as the stash keeps it, without photos that have gone from the phone. A string is a draft kept before
* photos and polls were.
*/
public static function fromArray(mixed $draft): self
{
if (is_string($draft)) {
return new self($draft);
}

$draft = is_array($draft) ? $draft : [];
$poll = is_array($draft['poll'] ?? null) ? $draft['poll'] : null;

return new self(
body: is_string($draft['body'] ?? null) ? $draft['body'] : '',
images: array_values(array_filter(array_map(DraftImage::fromArray(...), is_array($draft['images'] ?? null) ? $draft['images'] : []))),
poll: is_array($poll['options'] ?? null) ? array_values(array_filter($poll['options'], is_string(...))) : null,
pollLength: is_string($poll['length'] ?? null) && array_key_exists($poll['length'], PollDraft::Lengths) ? $poll['length'] : PollDraft::DefaultLength,
);
}

/**
* @return array{body: string, images: list<array{path: string, mime_type: string, width: int, height: int, description: string}>, poll: array{options: list<string>, length: string}|null}
*/
public function toArray(): array
{
return [
'body' => $this->body,
'images' => array_map(fn (DraftImage $image): array => $image->toArray(), $this->images),
'poll' => $this->poll === null ? null : ['options' => $this->poll, 'length' => $this->pollLength],
];
}

/**
* Whether there's nothing in it to keep: no body, no photos, and no poll with anything written in it. An empty
* poll still comes back with the body it was added to.
*/
public function isEmpty(): bool
{
return $this->body === '' && $this->images === [] && ($this->poll === null || array_all($this->poll, fn (string $option): bool => trim($option) === ''));
}
}
9 changes: 5 additions & 4 deletions app/Devnull/Display.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ final class Display
public const int ReplyBarCharactersPerLine = 34;

/**
* The row under the reply bar's field that marks a restored draft (see partials/stash-label).
* The row under the reply bar's field that marks a restored draft (see partials/stash-label) and counts its photos.
*/
public const int ReplyBarStashLabelHeight = 28;

Expand Down Expand Up @@ -179,9 +179,10 @@ public static function maskedKey(string $openSsh): string
* indicator, with no inset for either, so the page pads its own end: the
* bar as the draft grows to its five lines, and the indicator below it.
* With the keyboard up the bar sits on the keyboard instead, which only
* leaves a little more room. A restored draft adds the row that marks it.
* leaves a little more room. A restored draft, or one with photos, adds
* the row under the field ($draftRow).
*/
public static function replyBarClearance(string $draft, bool $restored = false): int
public static function replyBarClearance(string $draft, bool $draftRow = false): int
{
$lines = 0;

Expand All @@ -191,7 +192,7 @@ public static function replyBarClearance(string $draft, bool $restored = false):

return self::ReplyBarHeight
+ (min(self::ReplyBarMaxLines, $lines) - 1) * self::ReplyBarLineHeight
+ ($restored ? self::ReplyBarStashLabelHeight : 0)
+ ($draftRow ? self::ReplyBarStashLabelHeight : 0)
+ (CommitBody::hasOversizedUrl($draft) ? 3 * self::ReplyBarLineHeight : 0)
+ self::HomeIndicatorHeight;
}
Expand Down
25 changes: 22 additions & 3 deletions app/Devnull/DraftImages.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Devnull\Data\DraftImage;
use App\Devnull\Exceptions\InvalidDraftImage;
use App\Models\PendingWrite;

/**
* The photos attached to the commits you're writing, taken with the camera or
Expand All @@ -16,7 +17,8 @@
*
* The camera and the library hand over copies made for the app, so a photo is
* moved here, and one that won't do is deleted, so none are left lying about.
* Photos left behind by a draft that was never sent go after a day.
* A photo stays as long as a stashed draft or a commit in the outbox holds
* it. One nothing holds any more goes after a day.
*/
final class DraftImages
{
Expand Down Expand Up @@ -45,7 +47,7 @@ final class DraftImages
public const string CameraDenied = "devnull can't use the camera or your photos. Allow it in Settings.";

/**
* How long a photo of a draft that was never sent is kept.
* How long a photo nothing holds any more is kept.
*/
private const int KeptForSeconds = 86_400;

Expand Down Expand Up @@ -125,10 +127,27 @@ private function directory(): string

private function prune(): void
{
$held = array_flip($this->held());

foreach (glob($this->directory().'/*') ?: [] as $file) {
if (filemtime($file) < time() - self::KeptForSeconds) {
if (! isset($held[$file]) && filemtime($file) < time() - self::KeptForSeconds) {
@unlink($file);
}
}
}

/**
* The paths of the photos your stashed drafts and the commits in the outbox hold.
*
* @return list<string>
*/
private function held(): array
{
$images = [
...app(Stash::class)->images(app(Account::class)->user()?->id),
...app(Outbox::class)->pending(PendingWrite::Commit, withFailed: true)->flatMap(fn (PendingWrite $write): array => $write->images())->all(),
];

return array_map(fn (DraftImage $image): string => $image->path, $images);
}
}
64 changes: 50 additions & 14 deletions app/Devnull/Stash.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@

namespace App\Devnull;

use App\Devnull\Data\DraftImage;
use App\Devnull\Data\StashedDraft;
use Illuminate\Support\Facades\Cache;

/**
* What you wrote and didn't send, per user, as the web's useStash keeps it.
* What you wrote and didn't send, per user, as the web's useStash keeps it:
* the text, and the photos and poll that go with it.
*
* Drafts go by the web's names: 'commit' for a new commit, from the compose
* sheet, 'quote-{id}' for a quote of a commit and 'reply-{id}' for a
* reply to one. A screen keeps a draft as its field changes, and forgets it
* once it's sent or the field is emptied. Signing out forgets them all (see
* reply to one. A screen keeps a draft as it changes, and forgets it once
* it's sent or there's nothing left in it. Signing out forgets them all (see
* Account::forget).
*
* A stashed draft's photos stay on the phone for as long as it's kept (see
* DraftImages). They're deleted when the draft is pushed out by newer ones
* or you sign out, and otherwise by whoever takes them off it.
*/
final class Stash
{
Expand All @@ -21,37 +28,41 @@ final class Stash
public const int MaxDrafts = 50;

/**
* The draft kept under $name, or '' when there's none.
* The draft kept under $name, or an empty one when there's none.
*/
public function get(?string $userId, string $name): string
public function get(?string $userId, string $name): StashedDraft
{
return $this->drafts($userId)[$name] ?? '';
return $this->drafts($userId)[$name] ?? new StashedDraft;
}

/**
* Keeps $body under $name. An empty body forgets the draft.
* Keeps $draft under $name. An empty draft forgets it. The photos of a draft it replaces aren't deleted: they've
* been taken off, or gone with the commit.
*/
public function put(?string $userId, string $name, string $body): void
public function put(?string $userId, string $name, StashedDraft $draft): void
{
if ($userId === null) {
return;
}

$drafts = $this->drafts($userId);

if ($body === '' && ! array_key_exists($name, $drafts)) {
if ($draft->isEmpty() && ! array_key_exists($name, $drafts)) {
return;
}

unset($drafts[$name]);

if ($body !== '') {
$drafts = array_slice([...$drafts, $name => $body], -self::MaxDrafts, preserve_keys: true);
if (! $draft->isEmpty()) {
$drafts = [...$drafts, $name => $draft];

app(DraftImages::class)->discard(self::imagesOf(array_slice($drafts, 0, -self::MaxDrafts)));
$drafts = array_slice($drafts, -self::MaxDrafts, preserve_keys: true);
}

$drafts === []
? Cache::forget($this->key($userId))
: Cache::forever($this->key($userId), $drafts);
: Cache::forever($this->key($userId), array_map(fn (StashedDraft $draft): array => $draft->toArray(), $drafts));
}

/**
Expand All @@ -64,21 +75,46 @@ public function names(?string $userId): array
return array_map(strval(...), array_keys($this->drafts($userId)));
}

/**
* Every photo the drafts hold, which DraftImages keeps for them.
*
* @return list<DraftImage>
*/
public function images(?string $userId): array
{
return self::imagesOf($this->drafts($userId));
}

/**
* Forgets every draft, and deletes their photos.
*/
public function forget(?string $userId): void
{
if ($userId !== null) {
app(DraftImages::class)->discard($this->images($userId));
Cache::forget($this->key($userId));
}
}

/**
* @return array<string, string>
* @return array<string, StashedDraft>
*/
private function drafts(?string $userId): array
{
$drafts = $userId === null ? null : Cache::get($this->key($userId));

return is_array($drafts) ? array_filter($drafts, fn (mixed $body): bool => is_string($body) && $body !== '') : [];
return is_array($drafts)
? array_filter(array_map(StashedDraft::fromArray(...), $drafts), fn (StashedDraft $draft): bool => ! $draft->isEmpty())
: [];
}

/**
* @param array<string, StashedDraft> $drafts
* @return list<DraftImage>
*/
private static function imagesOf(array $drafts): array
{
return array_merge(...array_values(array_map(fn (StashedDraft $draft): array => $draft->images, $drafts)));
}

private function key(string $userId): string
Expand Down
20 changes: 4 additions & 16 deletions app/Models/PendingWrite.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,21 +148,15 @@ public function composer(): string
}

/**
* The commit's photos still on the phone. Photos of a draft are deleted after a day (see DraftImages).
* The commit's photos still on the phone. Photos nothing holds any more are deleted after a day (see DraftImages).
*
* @return list<DraftImage>
*/
public function images(): array
{
$images = [];
$images = is_array($this->payload['images'] ?? null) ? $this->payload['images'] : [];

foreach (is_array($this->payload['images'] ?? null) ? $this->payload['images'] : [] as $image) {
if (is_array($image) && is_string($image['path'] ?? null) && is_file($image['path'])) {
$images[] = new DraftImage($image['path'], (string) ($image['mime_type'] ?? ''), (int) ($image['width'] ?? 0), (int) ($image['height'] ?? 0), (string) ($image['description'] ?? ''));
}
}

return $images;
return array_values(array_filter(array_map(DraftImage::fromArray(...), $images)));
}

/**
Expand All @@ -171,13 +165,7 @@ public function images(): array
*/
public static function imagePayload(array $images): array
{
return array_map(fn (DraftImage $image): array => [
'path' => $image->path,
'mime_type' => $image->mimeType,
'width' => $image->width,
'height' => $image->height,
'description' => $image->description,
], $images);
return array_map(fn (DraftImage $image): array => $image->toArray(), $images);
}

/**
Expand Down
Loading
Loading