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
4 changes: 3 additions & 1 deletion app/Devnull/Account.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,13 @@ public function rememberPasteis(?Pasteis $pasteis): void
}

/**
* Signs this phone out: who it was, the badge, its pastéis, and every answer kept for a quick start.
* Signs this phone out: who it was, the badge, its pastéis, every answer kept for a quick start, and your unsent
* drafts.
*/
public function forget(): void
{
app(ResponseCache::class)->flush();
app(Stash::class)->forget($this->user()?->id);

Cache::forget(self::CacheKey);
Cache::forget(self::UnreadKey);
Expand Down
10 changes: 8 additions & 2 deletions app/Devnull/Display.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ 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).
*/
public const int ReplyBarStashLabelHeight = 28;

/**
* The strip under the home indicator on Face ID iPhones. EDGE doesn't
* hand pushed screens the real inset (it's 0 inside a tab), so this errs
Expand Down Expand Up @@ -206,9 +211,9 @@ public static function elbowDashes(float $from, float $to): array
* 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.
* leaves a little more room. A restored draft adds the row that marks it.
*/
public static function replyBarClearance(string $draft): int
public static function replyBarClearance(string $draft, bool $restored = false): int
{
$lines = 0;

Expand All @@ -218,6 +223,7 @@ public static function replyBarClearance(string $draft): int

return self::ReplyBarHeight
+ (min(self::ReplyBarMaxLines, $lines) - 1) * self::ReplyBarLineHeight
+ ($restored ? self::ReplyBarStashLabelHeight : 0)
+ self::HomeIndicatorHeight;
}

Expand Down
88 changes: 88 additions & 0 deletions app/Devnull/Stash.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace App\Devnull;

use Illuminate\Support\Facades\Cache;

/**
* What you wrote and didn't send, per user, as the web's useStash keeps it.
*
* Drafts go by the web's names: 'commit' for the Log's composer, which its
* sheet shares, '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
* Account::forget).
*/
final class Stash
{
/**
* How many drafts are kept per user. The one written longest ago goes first.
*/
public const int MaxDrafts = 50;

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

/**
* Keeps $body under $name. An empty body forgets the draft.
*/
public function put(?string $userId, string $name, string $body): void
{
if ($userId === null) {
return;
}

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

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

unset($drafts[$name]);

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

$drafts === []
? Cache::forget($this->key($userId))
: Cache::forever($this->key($userId), $drafts);
}

/**
* The names of every draft kept, for the Quote and Reply buttons that mark one.
*
* @return list<string>
*/
public function names(?string $userId): array
{
return array_map(strval(...), array_keys($this->drafts($userId)));
}

public function forget(?string $userId): void
{
if ($userId !== null) {
Cache::forget($this->key($userId));
}
}

/**
* @return array<string, string>
*/
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 !== '') : [];
}

private function key(string $userId): string
{
return "devnull.stash.{$userId}";
}
}
43 changes: 42 additions & 1 deletion app/NativeComponents/CommitDetail.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Devnull\Data\CommitThread;
use App\Devnull\Data\Page;
use App\Devnull\Display;
use App\Devnull\Stash;
use App\Devnull\Timeline;
use App\Icons\Lucide;
use App\NativeComponents\Concerns\OpensInTab;
Expand All @@ -30,6 +31,10 @@
*
* It opens on the commit as it was last time, or opened from a row, on what
* the row knew of it, then asks devnull for the rest behind it and fills it in.
*
* The reply is stashed as you type, per commit, as on the web (see Stash), so
* leaving the page keeps it, and it's in the reply bar again, marked as
* restored, when you come back to the commit.
*/
class CommitDetail extends NativeComponent
{
Expand Down Expand Up @@ -71,6 +76,11 @@ class CommitDetail extends NativeComponent

public string $reply = '';

/**
* Whether the reply was stashed when the page opened, which the bar marks until you empty it or drop it.
*/
public bool $replyRestored = false;

/**
* Whether the ancestors show above the commit, rather than "↑ N earlier commits".
*/
Expand All @@ -95,6 +105,8 @@ class CommitDetail extends NativeComponent
public function mount(): void
{
$this->focusReply = $this->data('reply', false) === true;
$this->reply = app(Stash::class)->get($this->me()?->id, $this->replyStashName());
$this->replyRestored = $this->reply !== '';
$this->showKept();
$this->revalidate();
}
Expand Down Expand Up @@ -229,10 +241,32 @@ public function sendReply(): void
}

$this->reply = '';
$this->updatedReply();
$this->replies = [...$this->replies, $reply];
$this->repliesShown = true;
}

/**
* Stashes the reply as it changes. An empty field forgets it, and isn't restored any more.
*/
public function updatedReply(): void
{
app(Stash::class)->put($this->me()?->id, $this->replyStashName(), $this->reply);

if ($this->reply === '') {
$this->replyRestored = false;
}
}

/**
* The web's drop, beside a restored reply: it's thrown away, and the field starts again empty.
*/
public function dropReplyStash(): void
{
$this->reply = '';
$this->updatedReply();
}

/**
* The commit this page is about takes you back where you came from. Any
* other of yours leaves the page as it would coming back to it.
Expand Down Expand Up @@ -315,6 +349,11 @@ private function takeReplies(array $json): void
$this->repliesLoaded = true;
}

private function replyStashName(): string
{
return 'reply-'.$this->id;
}

private function threadPath(): string
{
return 'commits/'.rawurlencode($this->id);
Expand All @@ -337,10 +376,12 @@ public function isOwn(): bool
public function render(): View
{
$earlier = $this->earlierShown ? [] : $this->ancestors;
$viewer = $this->me();

return view('native.commit-detail', [
'myColour' => $this->myColour(),
'viewer' => $this->me(),
'viewer' => $viewer,
'stashed' => app(Stash::class)->names($viewer?->id),
'pasteis' => app(Account::class)->pasteis(),
'rows' => $this->commit === null ? [] : Timeline::thread(
[...($this->earlierShown ? $this->ancestors : []), $this->commit, ...$this->series],
Expand Down
85 changes: 81 additions & 4 deletions app/NativeComponents/Concerns/OpensCommitSheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Devnull\Client;
use App\Devnull\Data\Commit;
use App\Devnull\ReportReason;
use App\Devnull\Stash;
use Closure;

/**
Expand All @@ -18,9 +19,13 @@
* it. Once devnull has the commit the sheet goes, and a screen that lists
* your new commits puts it in place (see showCommitted()).
*
* 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 as the Log's composer's draft,
* which the sheet shares, as the web's dialog does. Opened again, it starts
* from the draft, marked as restored. It's forgotten once it's sent.
*
* The sheet keeps its commit while it slides away, so it doesn't empty on the
* way down, and forgets it and any draft once iOS says it's gone, however it
* was closed.
* way down, and lets go of it once iOS says it's gone, however it was closed.
*/
trait OpensCommitSheet
{
Expand Down Expand Up @@ -52,6 +57,11 @@ trait OpensCommitSheet
*/
public string $sheetBody = '';

/**
* Whether the sheet opened on a stashed draft, which it marks until you empty it or drop it.
*/
public bool $sheetRestored = false;

/**
* Why devnull didn't take the report or the commit, shown in the sheet.
*/
Expand Down Expand Up @@ -113,6 +123,7 @@ public function quoteCommit(string $id): void
$this->sheetCommit = $commit;
$this->sheetView = 'quote';
$this->sheetOpen = true;
$this->restoreSheetDraft();
}

/**
Expand All @@ -123,6 +134,34 @@ public function composeInSheet(): void
$this->forgetCommitSheet();
$this->sheetView = 'compose';
$this->sheetOpen = true;
$this->restoreSheetDraft();
}

/**
* Stashes the draft as it changes. An empty field forgets it, and isn't restored any more.
*/
public function updatedSheetBody(): void
{
$name = $this->sheetStashName();

if (! $this->sheetOpen || $name === null) {
return;
}

app(Stash::class)->put($this->me()?->id, $name, $this->sheetBody);

if ($this->sheetBody === '') {
$this->sheetRestored = false;
}
}

/**
* The web's drop, beside a restored draft: it's thrown away, and the field starts again empty.
*/
public function dropSheetStash(): void
{
$this->sheetBody = '';
$this->updatedSheetBody();
}

/**
Expand All @@ -132,9 +171,10 @@ public function composeInSheet(): void
public function commitFromSheet(): void
{
$quoted = $this->sheetView === 'quote' ? $this->sheetCommit : null;
$stashName = $this->sheetStashName();
$body = trim($this->sheetBody);

if (($quoted === null && $this->sheetView !== 'compose') || mb_strlen($body) < Commit::MinLength || mb_strlen($body) > Commit::MaxLength) {
if ($stashName === null || mb_strlen($body) < Commit::MinLength || mb_strlen($body) > Commit::MaxLength) {
return;
}

Expand All @@ -144,6 +184,7 @@ public function commitFromSheet(): void
return;
}

app(Stash::class)->put($this->me()?->id, $stashName, '');
$this->sheetOpen = false;
$this->showCommitted($commit);
}
Expand All @@ -157,11 +198,26 @@ public function hideCommitSheet(): void
}

/**
* iOS says the sheet has gone, swiped away or closed from here. What was drafted in it goes with it.
* iOS says the sheet has gone, swiped away or closed from here. What was drafted in it stays stashed.
*/
public function commitSheetClosed(): void
{
$composed = $this->sheetView === 'compose';

$this->forgetCommitSheet();

if ($composed) {
$this->composeSheetClosed();
}
}

/**
* The sheet's new commit has gone, sent or stashed. The Log's composer shares its draft, so it takes it as the
* sheet left it.
*/
protected function composeSheetClosed(): void
{
//
}

/**
Expand Down Expand Up @@ -194,9 +250,30 @@ private function forgetCommitSheet(): void
$this->reportReason = '';
$this->reportDetails = '';
$this->sheetBody = '';
$this->sheetRestored = false;
$this->sheetError = null;
}

/**
* The draft the sheet stashes, by the web's name for it, or null when it isn't a composer.
*/
private function sheetStashName(): ?string
{
return match (true) {
$this->sheetView === 'compose' => 'commit',
$this->sheetView === 'quote' && $this->sheetCommit !== null => 'quote-'.$this->sheetCommit->id,
default => null,
};
}

private function restoreSheetDraft(): void
{
$name = $this->sheetStashName();

$this->sheetBody = $name === null ? '' : app(Stash::class)->get($this->me()?->id, $name);
$this->sheetRestored = $this->sheetBody !== '';
}

/**
* Like attempt(), but a failure shows in the sheet rather than where the screen prints its errors.
*
Expand Down
Loading
Loading