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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ For now the app tracks unreleased branches, which Composer pulls straight from G
- button variants named after a theme colour ([#137](https://github.com/NativePHP/mobile-ui/pull/137)),
- a sticky header for lists ([#138](https://github.com/NativePHP/mobile-ui/pull/138)),
- a pager header that scrolls with the page showing ([#139](https://github.com/NativePHP/mobile-ui/pull/139)).
- `nativephp/mobile-camera` on `fix/present-over-sheets`, the plugin's `main` plus presenting the camera and the photo library from the topmost view controller, so they open over the composer sheet ([NativePHP/mobile-camera#17](https://github.com/NativePHP/mobile-camera/pull/17)). `main` also has the HEIC to JPEG conversion and the persistent storage fix that 1.0.4 lacks.

Once those are released, the app goes back to tagged versions and the combined branches can be deleted.

Expand Down
86 changes: 79 additions & 7 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Devnull\Data\CommitThread;
use App\Devnull\Data\Connection;
use App\Devnull\Data\CurrentUser;
use App\Devnull\Data\DraftImage;
use App\Devnull\Data\Notification;
use App\Devnull\Data\Page;
use App\Devnull\Data\Profile;
Expand Down Expand Up @@ -66,6 +67,11 @@
*/
public const string PasteisModePath = 'me/pasteis-mode';

/**
* Where a draft is shown as the commit it would become, as the web's composer previews it.
*/
public const string PreviewPath = 'commits/preview';

/**
* Your Remotes inbox, and where Remotes are switched on and off and their settings saved, as on the web.
*/
Expand All @@ -87,6 +93,14 @@

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

/**
* Where a vote on a commit's poll goes. devnull answers with the commit, whose poll then shows its results.
*/
public static function pollVotesPath(string $id): string
{
return 'commits/'.rawurlencode($id).'/poll/votes';
}

/**
* Where a Remotes thread lives: with a person, by their username, or an org, by its id. Its messages are pushed
* to the same path, and its read, typing and stash live beside it.
Expand Down Expand Up @@ -198,17 +212,57 @@ public function replies(string $id, ?string $cursor = null): Page
/**
* Commits, as a reply to $parentId or quoting $quotedCommitId, as the web's
* composer, reply box and Quote do. devnull takes one or the other, not both.
* A new commit or a quote may start a poll (see PollDraft::payload()).
*
* @param array{options: list<string>, duration: int}|null $poll
*/
public function createCommit(string $body, ?string $parentId = null, ?string $quotedCommitId = null): Commit
public function createCommit(string $body, ?string $parentId = null, ?string $quotedCommitId = null, ?array $poll = null): Commit
{
$payload = array_filter(['body' => $body, 'parent_id' => $parentId, 'quoted_commit_id' => $quotedCommitId], fn (?string $value): bool => $value !== null);

$commit = $this->data($this->send('POST', 'commits', payload: $payload));
$commit = $this->data($this->send('POST', 'commits', payload: self::commitPayload($body, $parentId, $quotedCommitId, poll: $poll)));
$this->cache->commitAdded($commit);

return Commit::fromArray($commit);
}

/**
* 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
* upload. devnull takes them as multipart, as from the `devnull commit`
* script: the JSON body goes in a payload part beside the images, and the
* SHA-256 of each image is signed on a line before it, so the signature
* covers them too. Descriptions go along when any photo has one.
*
* @param list<DraftImage> $images
*/
public function signedCommitWithImages(string $body, array $images, ?string $parentId = null, ?string $quotedCommitId = null): SignedRequest
{
$target = $this->target('commits', []);
$descriptions = array_map(fn (DraftImage $image): ?string => $image->isDescribed() ? trim($image->description) : null, $images);
$payload = self::body(self::commitPayload($body, $parentId, $quotedCommitId, descriptions: array_filter($descriptions) === [] ? [] : $descriptions));
$contents = array_map(fn (DraftImage $image): string => (string) file_get_contents($image->path), $images);
$boundary = 'devnull-'.bin2hex(random_bytes(16));

$part = fn (string $name, string $filename, string $type, string $bytes): string => "--{$boundary}\r\n"
."Content-Disposition: form-data; name=\"{$name}\"; filename=\"{$filename}\"\r\n"
."Content-Type: {$type}\r\n\r\n{$bytes}\r\n";

$body = $part('payload', 'payload.json', 'application/json', $payload);

foreach ($images as $index => $image) {
$body .= $part('images[]', 'image-'.($index + 1).'.'.substr($image->mimeType, 6), $image->mimeType, $contents[$index]);
}

$hashes = implode(',', array_map(fn (string $bytes): string => hash('sha256', $bytes), $contents));

return new SignedRequest(
$this->origin().$target,
$this->sign('POST', $target, $hashes."\n".$payload, RequestSigner::ImagesNamespace),
'POST',
$body."--{$boundary}--\r\n",
"multipart/form-data; boundary={$boundary}",
);
}

public function deleteCommit(string $id): void
{
$this->send('DELETE', 'commits/'.rawurlencode($id));
Expand Down Expand Up @@ -497,7 +551,7 @@ public static function sendSigned(SignedRequest $request): Response

$pending = $request->body === ''
? $pending->withRequestMiddleware(fn (RequestInterface $request): RequestInterface => $request->withoutHeader('Content-Type'))
: $pending->withBody($request->body, 'application/json');
: $pending->withBody($request->body, $request->contentType);

return $pending->send($request->method, $request->url);
}
Expand Down Expand Up @@ -569,6 +623,24 @@ private function send(string $method, string $path, array $query = [], ?array $p
return $response;
}

/**
* What a commit sends, leaving out what it doesn't have.
*
* @param list<string|null> $descriptions
* @param array{options: list<string>, duration: int}|null $poll
* @return array<string, mixed>
*/
private static function commitPayload(string $body, ?string $parentId, ?string $quotedCommitId, array $descriptions = [], ?array $poll = null): array
{
return array_filter([
'body' => $body,
'parent_id' => $parentId,
'quoted_commit_id' => $quotedCommitId,
'image_descriptions' => $descriptions,
'poll' => $poll,
], fn (mixed $value): bool => $value !== null && $value !== []);
}

/**
* Whether a successful response is devnull's API answering, and not a page
* served in its place, such as the HTML that dev-null.sh answers every URL
Expand Down Expand Up @@ -601,10 +673,10 @@ private static function body(?array $payload): string
/**
* @return array{X-Devnull-Timestamp: string, X-Devnull-Nonce: string, X-SSH-Signature: string}
*/
private function sign(string $method, string $target, string $body): array
private function sign(string $method, string $target, string $body, string $namespace = RequestSigner::Namespace): array
{
return (new RequestSigner($this->deviceKey->current()))
->headers($method, $target, $body, CarbonImmutable::now()->getTimestamp(), RequestSigner::nonce());
->headers($method, $target, $body, CarbonImmutable::now()->getTimestamp(), RequestSigner::nonce(), $namespace);
}

/**
Expand Down
24 changes: 24 additions & 0 deletions app/Devnull/Data/Commit.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ public function withPinned(bool $isPinned): self
return new self(...[...get_object_vars($this), 'isPinned' => $isPinned]);
}

/**
* This commit with the poll a fresher copy of it has, as after you voted.
*/
public function withPoll(?Poll $poll): self
{
return new self(...[...get_object_vars($this), 'poll' => $poll]);
}

/**
* This commit with the star and pastéis state of a fresher copy of
* it, keeping everything else, such as the parent a list loaded with it.
Expand Down Expand Up @@ -294,6 +302,22 @@ public function isApproved(): bool
return $this->status === 'approved';
}

/**
* Whether $viewer may vote on the commit's poll, as the web decides it: someone else's published commit, whose
* poll is open and hasn't had their vote yet.
*/
public function canBeVotedOnBy(?User $viewer): bool
{
return $viewer !== null
&& $this->poll !== null
&& $this->isApproved()
&& $this->author->id !== $viewer->id
&& $this->poll->closesAt !== null
&& ! $this->poll->hasVoted
&& ! $this->poll->showsResults()
&& ! $this->poll->hasExpired();
}

/**
* The author's lane, or Rail Gray for a deleted commit, which has no author.
*/
Expand Down
57 changes: 57 additions & 0 deletions app/Devnull/Data/Draft.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace App\Devnull\Data;

use Carbon\CarbonImmutable;

/**
* A commit you're writing in the composer sheet: what it needs before it can
* be committed, and the commit it would become, for the preview.
*/
final class Draft
{
/**
* Whether Commit may be pressed, as the web decides it: a body that fits
* (see Commit::fits()), or with images none at all, and a poll whose every
* option has something in it.
*
* @param list<DraftImage> $images
* @param list<string>|null $poll
*/
public static function canCommit(string $body, array $images = [], ?array $poll = null): bool
{
return (Commit::fits($body) || ($images !== [] && trim($body) === ''))
&& ($poll === null || PollDraft::isComplete($poll));
}

/**
* The draft as the commit it would become, by you, with what devnull sent
* back for its body when it's for this draft: the body as it would be
* kept, its code and mentions. Until then it's the body as written.
*
* @param array<string, mixed>|null $preview devnull's `POST /commits/preview` for the draft, with the body it was for under `draft`.
* @param list<DraftImage> $images
* @param list<string>|null $poll
*/
public static function asCommit(User $author, string $body, ?array $preview = null, array $images = [], ?array $poll = null, ?Commit $quoted = null): Commit
{
$body = trim($body);
$shown = ($preview['draft'] ?? null) === $body ? $preview : ['body' => $body];

$commit = Commit::fromArray([
...$shown,
'id' => 'draft',
'status' => 'approved',
'created_at' => CarbonImmutable::now()->toIso8601ZuluString(),
]);

return new Commit(...[
...get_object_vars($commit),
'author' => $author,
'images' => array_map(fn (DraftImage $image, int $index): CommitImage => $image->asCommitImage($index), $images, array_keys($images)),
'poll' => $poll === null ? null : PollDraft::preview($poll),
'quotedCommitId' => $quoted?->id,
'quotedCommit' => $quoted,
]);
}
}
33 changes: 33 additions & 0 deletions app/Devnull/Data/DraftImage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace App\Devnull\Data;

use App\Devnull\DraftImages;

/**
* An image attached to a commit you're writing. It's kept in the app's own
* storage (see DraftImages) until the commit is sent or the image is taken
* off, with the description you gave it for people who can't see it.
*/
final readonly class DraftImage
{
public function __construct(public string $path, public string $mimeType, public int $width, public int $height, public string $description = '') {}

public function withDescription(string $description): self
{
return new self(...[...get_object_vars($this), 'description' => mb_substr($description, 0, DraftImages::MaxDescriptionLength)]);
}

public function isDescribed(): bool
{
return trim($this->description) !== '';
}

/**
* The image as a commit shows it, drawn from the file on this phone, for the preview.
*/
public function asCommitImage(int $index): CommitImage
{
return new CommitImage('draft-'.$index, $this->path, $this->width, $this->height, $this->isDescribed() ? trim($this->description) : null);
}
}
80 changes: 80 additions & 0 deletions app/Devnull/Data/PollDraft.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

namespace App\Devnull\Data;

/**
* The poll in a new commit or a quote you're writing, as the web's PollEditor
* keeps it: two to four options of up to 50 characters, and how long it runs.
*/
final class PollDraft
{
public const int MinOptions = 2;

public const int MaxOptions = 4;

public const int MaxOptionLength = 50;

/**
* The web's poll lengths, in the minutes devnull takes.
*
* @var array<string, int>
*/
public const array Lengths = ['1 hour' => 60, '1 day' => 1440, '3 days' => 4320, '7 days' => 10080];

public const string DefaultLength = '1 day';

/**
* A new poll's options: two empty ones, as the web starts it.
*
* @return list<string>
*/
public static function start(): array
{
return array_fill(0, self::MinOptions, '');
}

/**
* Whether every option has something in it, which the web waits for before it commits.
*
* @param list<string> $options
*/
public static function isComplete(array $options): bool
{
return count($options) >= self::MinOptions && array_all($options, fn (string $option): bool => trim($option) !== '');
}

/**
* The poll as devnull takes it with a commit.
*
* @param list<string> $options
* @return array{options: list<string>, duration: int}
*/
public static function payload(array $options, string $length): array
{
return [
'options' => array_map(trim(...), $options),
'duration' => self::Lengths[$length] ?? self::Lengths[self::DefaultLength],
];
}

/**
* The poll as it reads once committed, for the preview: its options, with
* no votes. Its clock starts once the commit is published.
*
* @param list<string> $options
*/
public static function preview(array $options): Poll
{
$labels = array_values(array_filter(array_map(trim(...), $options), fn (string $option): bool => $option !== ''));

return new Poll('draft', array_map(fn (string $label, int $index): PollOption => new PollOption('draft-'.$index, $label), $labels, array_keys($labels)));
}

/**
* The line under the preview's options: "Open for 1 day once published".
*/
public static function runs(string $length): string
{
return 'Open for '.(array_key_exists($length, self::Lengths) ? $length : self::DefaultLength).' once published';
}
}
Loading
Loading