From 4d759659cbcac966ff382780ec3a0c4e821f739e Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 19 Oct 2026 19:43:32 +0100 Subject: [PATCH 1/2] Attach photos, start polls and preview from the composer sheet, and vote on polls The composer sheet gets the web composer's tools on a row under the field: photos from the library, a photo from the camera, a poll, and a preview. Photos come through the official camera plugin, up to three, each checked the way devnull checks them and moved into the app's storage until the commit goes. Each can have a description. A commit with photos is signed as multipart with the image hashes, the way the API asks, and sent from the background, so Commit spins while it uploads. A new commit or a quote can have a poll instead, with two to four options and a length, as on the web. The preview shows the draft as the commit it would become, and fills in the body devnull sends back for it. A commit page's reply bar has no room for these, so it gets an expand button that opens the reply in the same sheet, and the draft goes back to the bar if the sheet closes unsent. Polls in lists can now be voted on: pick an option, then Vote. The camera plugin is pinned to a branch until NativePHP/mobile-camera#17 is released, since its pickers did nothing when opened from a sheet. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + app/Devnull/Client.php | 86 ++++- app/Devnull/Data/Commit.php | 24 ++ app/Devnull/Data/Draft.php | 60 +++ app/Devnull/Data/DraftImage.php | 33 ++ app/Devnull/Data/PollDraft.php | 80 ++++ app/Devnull/DraftImages.php | 134 +++++++ app/Devnull/Exceptions/InvalidDraftImage.php | 10 + app/Devnull/FetchInBackground.php | 4 +- app/Devnull/RequestSigner.php | 8 +- app/Devnull/ResponseCache.php | 16 + app/Devnull/SignedRequest.php | 5 +- app/Icons/Lucide.php | 12 + app/NativeComponents/CommitDetail.php | 30 ++ .../Concerns/AttachesToCommits.php | 341 ++++++++++++++++++ .../Concerns/OpensCommitSheet.php | 72 +++- .../Concerns/ShowsCommits.php | 7 +- .../Concerns/VotesOnPolls.php | 75 ++++ .../Concerns/WritesInBackground.php | 20 +- app/Providers/NativeServiceProvider.php | 2 + composer.json | 5 + composer.lock | 73 +++- config/nativephp.php | 5 +- resources/icons/camera.svg | 1 + resources/icons/chart-bar.svg | 1 + resources/icons/eye.svg | 1 + resources/icons/image-plus.svg | 1 + resources/icons/maximize-2.svg | 1 + resources/icons/plus.svg | 1 + .../views/native/commit-detail.blade.php | 6 +- .../native/partials/commit-poll.blade.php | 31 +- .../native/partials/commit-sheet.blade.php | 62 +++- .../native/partials/composer-photos.blade.php | 52 +++ .../partials/composer-preview.blade.php | 27 ++ .../native/partials/composer-tool.blade.php | 17 + .../native/partials/composer-tools.blade.php | 14 + .../native/partials/poll-editor.blade.php | 50 +++ .../native/partials/quoted-commit.blade.php | 6 +- tests/Feature/Devnull/ClientTest.php | 56 +++ tests/Feature/Devnull/DraftImagesTest.php | 79 ++++ tests/Feature/Screens/ComposerPhotosTest.php | 277 ++++++++++++++ tests/Feature/Screens/ComposerPollTest.php | 132 +++++++ tests/Feature/Screens/ComposerPreviewTest.php | 108 ++++++ tests/Feature/Screens/PollVoteTest.php | 111 ++++++ tests/Pest.php | 23 ++ 45 files changed, 2102 insertions(+), 58 deletions(-) create mode 100644 app/Devnull/Data/Draft.php create mode 100644 app/Devnull/Data/DraftImage.php create mode 100644 app/Devnull/Data/PollDraft.php create mode 100644 app/Devnull/DraftImages.php create mode 100644 app/Devnull/Exceptions/InvalidDraftImage.php create mode 100644 app/NativeComponents/Concerns/AttachesToCommits.php create mode 100644 app/NativeComponents/Concerns/VotesOnPolls.php create mode 100644 resources/icons/camera.svg create mode 100644 resources/icons/chart-bar.svg create mode 100644 resources/icons/eye.svg create mode 100644 resources/icons/image-plus.svg create mode 100644 resources/icons/maximize-2.svg create mode 100644 resources/icons/plus.svg create mode 100644 resources/views/native/partials/composer-photos.blade.php create mode 100644 resources/views/native/partials/composer-preview.blade.php create mode 100644 resources/views/native/partials/composer-tool.blade.php create mode 100644 resources/views/native/partials/composer-tools.blade.php create mode 100644 resources/views/native/partials/poll-editor.blade.php create mode 100644 tests/Feature/Devnull/DraftImagesTest.php create mode 100644 tests/Feature/Screens/ComposerPhotosTest.php create mode 100644 tests/Feature/Screens/ComposerPollTest.php create mode 100644 tests/Feature/Screens/ComposerPreviewTest.php create mode 100644 tests/Feature/Screens/PollVoteTest.php diff --git a/README.md b/README.md index 79bfe9a..502f786 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,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. diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 19313d0..22ac966 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -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; @@ -66,8 +67,21 @@ */ 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'; + 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 pastel is given to a commit, one per request. */ @@ -162,17 +176,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, 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 $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)); @@ -395,7 +449,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); } @@ -462,6 +516,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 $descriptions + * @param array{options: list, duration: int}|null $poll + * @return array + */ + 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 !== []); + } + /** * A request's JSON body, or nothing for a request without one. * @@ -475,10 +547,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); } /** diff --git a/app/Devnull/Data/Commit.php b/app/Devnull/Data/Commit.php index ece9692..525d5e3 100644 --- a/app/Devnull/Data/Commit.php +++ b/app/Devnull/Data/Commit.php @@ -156,6 +156,14 @@ public function withSaved(bool $isSaved): self return new self(...[...get_object_vars($this), 'isSaved' => $isSaved]); } + /** + * 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, save and pastéis state of a fresher copy of * it, keeping everything else, such as the parent a list loaded with it. @@ -260,6 +268,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. */ diff --git a/app/Devnull/Data/Draft.php b/app/Devnull/Data/Draft.php new file mode 100644 index 0000000..73d97e5 --- /dev/null +++ b/app/Devnull/Data/Draft.php @@ -0,0 +1,60 @@ + $images + * @param list|null $poll + */ + public static function canCommit(string $body, array $images = [], ?array $poll = null): bool + { + $length = mb_strlen(trim($body)); + + return $length <= Commit::MaxLength + && ($images !== [] || $length >= Commit::MinLength) + && ($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|null $preview devnull's `POST /commits/preview` for the draft, with the body it was for under `draft`. + * @param list $images + * @param list|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, + ]); + } +} diff --git a/app/Devnull/Data/DraftImage.php b/app/Devnull/Data/DraftImage.php new file mode 100644 index 0000000..211903d --- /dev/null +++ b/app/Devnull/Data/DraftImage.php @@ -0,0 +1,33 @@ + 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); + } +} diff --git a/app/Devnull/Data/PollDraft.php b/app/Devnull/Data/PollDraft.php new file mode 100644 index 0000000..a0bd162 --- /dev/null +++ b/app/Devnull/Data/PollDraft.php @@ -0,0 +1,80 @@ + + */ + 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 + */ + 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 $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 $options + * @return array{options: list, 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 $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'; + } +} diff --git a/app/Devnull/DraftImages.php b/app/Devnull/DraftImages.php new file mode 100644 index 0000000..426dd67 --- /dev/null +++ b/app/Devnull/DraftImages.php @@ -0,0 +1,134 @@ + + */ + private const array Types = [ + IMAGETYPE_JPEG => 'image/jpeg', + IMAGETYPE_PNG => 'image/png', + IMAGETYPE_WEBP => 'image/webp', + ]; + + /** + * Moves a photo the camera took or the library handed over into the app's + * storage, as an image for a draft. + * + * @throws InvalidDraftImage + */ + public function keep(string $path): DraftImage + { + $size = is_file($path) ? filesize($path) : false; + $info = $size === false ? false : @getimagesize($path); + + try { + if ($size === false || $info === false) { + throw new InvalidDraftImage(self::Unreadable); + } + + if (! isset(self::Types[$info[2]]) || $size > self::MaxBytes) { + throw new InvalidDraftImage(self::Unsupported); + } + + if ($info[0] > self::MaxDimension || $info[1] > self::MaxDimension) { + throw new InvalidDraftImage(self::TooLarge); + } + } catch (InvalidDraftImage $exception) { + @unlink($path); + + throw $exception; + } + + $this->prune(); + + $kept = $this->directory().'/'.bin2hex(random_bytes(16)).image_type_to_extension($info[2]); + + if (! @rename($path, $kept) && ! (@copy($path, $kept) && @unlink($path))) { + throw new InvalidDraftImage(self::Unreadable); + } + + return new DraftImage($kept, self::Types[$info[2]], $info[0], $info[1]); + } + + /** + * Deletes the photos of a draft that was sent, or that you took off it. + * + * @param list $images + */ + public function discard(array $images): void + { + foreach ($images as $image) { + if (str_starts_with($image->path, $this->directory().'/')) { + @unlink($image->path); + } + } + } + + private function directory(): string + { + $directory = storage_path('app/draft-images'); + + if (! is_dir($directory)) { + mkdir($directory, 0700, true); + } + + return $directory; + } + + private function prune(): void + { + foreach (glob($this->directory().'/*') ?: [] as $file) { + if (filemtime($file) < time() - self::KeptForSeconds) { + @unlink($file); + } + } + } +} diff --git a/app/Devnull/Exceptions/InvalidDraftImage.php b/app/Devnull/Exceptions/InvalidDraftImage.php new file mode 100644 index 0000000..f97b7ca --- /dev/null +++ b/app/Devnull/Exceptions/InvalidDraftImage.php @@ -0,0 +1,10 @@ + $headers * @return array{status: int, json: mixed, retry_after: string} */ - public function handle(string $url, array $headers, string $method = 'GET', string $body = ''): array + public function handle(string $url, array $headers, string $method = 'GET', string $body = '', string $contentType = 'application/json'): array { try { - $response = Client::sendSigned(new SignedRequest($url, $headers, $method, $body)); + $response = Client::sendSigned(new SignedRequest($url, $headers, $method, $body, $contentType)); } catch (ConnectionException) { return ['status' => 0, 'json' => null, 'retry_after' => '']; } diff --git a/app/Devnull/RequestSigner.php b/app/Devnull/RequestSigner.php index 9788274..224185f 100644 --- a/app/Devnull/RequestSigner.php +++ b/app/Devnull/RequestSigner.php @@ -7,11 +7,15 @@ * * The signed message is the timestamp, nonce, uppercase method, request * target (path plus query exactly as sent) and raw body, joined by "\n". + * A request with images signs their SHA-256s on a line before the body, under + * a namespace of its own (see Client::signedCommitWithImages()). */ final readonly class RequestSigner { public const string Namespace = 'devnull-api'; + public const string ImagesNamespace = 'devnull-api-images'; + public function __construct(private SshKey $key) {} public static function message(string $timestamp, string $nonce, string $method, string $target, string $body): string @@ -27,12 +31,12 @@ public static function nonce(): string /** * @return array{X-Devnull-Timestamp: string, X-Devnull-Nonce: string, X-SSH-Signature: string} */ - public function headers(string $method, string $target, string $body, int $timestamp, string $nonce): array + public function headers(string $method, string $target, string $body, int $timestamp, string $nonce, string $namespace = self::Namespace): array { return [ 'X-Devnull-Timestamp' => (string) $timestamp, 'X-Devnull-Nonce' => $nonce, - 'X-SSH-Signature' => $this->key->sign(self::message((string) $timestamp, $nonce, $method, $target, $body), self::Namespace), + 'X-SSH-Signature' => $this->key->sign(self::message((string) $timestamp, $nonce, $method, $target, $body), $namespace), ]; } } diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index 63702ef..32d2cff 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -247,6 +247,22 @@ public function commitReacted(array $commit): void $this->patchCommit($id, fn (array $kept): array => [...$kept, ...$reactions]); } + /** + * You voted on a commit's poll: every copy of the commit takes the poll devnull sent back, with its results. + * + * @param array $commit + */ + public function pollVoted(array $commit): void + { + $id = (string) ($commit['id'] ?? ''); + + if ($id === '' || ! is_array($commit['poll'] ?? null)) { + return; + } + + $this->patchCommit($id, fn (array $kept): array => [...$kept, 'poll' => $commit['poll']]); + } + /** * You saved or unsaved a commit. A commit you saved is the last you saved, * so it goes on top of your saved commits; one you unsaved leaves them, diff --git a/app/Devnull/SignedRequest.php b/app/Devnull/SignedRequest.php index 8c247d3..2ded882 100644 --- a/app/Devnull/SignedRequest.php +++ b/app/Devnull/SignedRequest.php @@ -5,7 +5,8 @@ /** * A request signed with this device's key, ready to send from anywhere: the * method and full URL as signed, the headers carrying the signature, and the - * exact body that was signed, empty for a GET. The signature is for this one + * exact body that was signed, empty for a GET, with its type: JSON, or + * multipart for a commit with images. The signature is for this one * request, with its own nonce and a timestamp devnull only accepts for a few * minutes, and it's never kept. */ @@ -14,5 +15,5 @@ /** * @param array $headers */ - public function __construct(public string $url, public array $headers, public string $method = 'GET', public string $body = '') {} + public function __construct(public string $url, public array $headers, public string $method = 'GET', public string $body = '', public string $contentType = 'application/json') {} } diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index 83ec0df..e3d3ccd 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -55,6 +55,12 @@ enum Lucide case VolumeX; case Volume2; case Ban; + case ImagePlus; + case Camera; + case ChartBar; + case Eye; + case Plus; + case Maximize2; // The achievement badges, named as devnull names their icons (see forBadge()). case Terminal; @@ -136,6 +142,12 @@ public function custom(bool $filled = false): ?string self::VolumeX => 'volume-x', self::Volume2 => 'volume-2', self::Ban => 'ban', + self::ImagePlus => 'image-plus', + self::Camera => 'camera', + self::ChartBar => 'chart-bar', + self::Eye => 'eye', + self::Plus => 'plus', + self::Maximize2 => 'maximize-2', self::Terminal => 'terminal', self::FileCode => 'file-code', self::Flame => 'flame', diff --git a/app/NativeComponents/CommitDetail.php b/app/NativeComponents/CommitDetail.php index 2cb4102..89a7eb1 100644 --- a/app/NativeComponents/CommitDetail.php +++ b/app/NativeComponents/CommitDetail.php @@ -35,6 +35,10 @@ * 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. + * + * The reply bar has no room for photos or a preview, so its expand button + * opens the reply in the composer sheet, which has them (see replyInSheet()). + * The draft goes with it, and back to the bar if the sheet closes unsent. */ class CommitDetail extends NativeComponent { @@ -267,6 +271,32 @@ public function dropReplyStash(): void $this->updatedReply(); } + /** + * A reply sent from the composer sheet goes under the commit, as one sent from the reply bar does. + */ + protected function showCommitted(Commit $commit): void + { + if ($commit->parentId !== $this->id) { + return; + } + + $this->reply = ''; + $this->replyRestored = false; + $this->replies = [...$this->replies, $commit]; + $this->repliesShown = true; + } + + /** + * The reply bar takes back the draft it handed the sheet, as it was left there. + */ + protected function commitSheetGone(string $view): void + { + if ($view === 'reply') { + $this->reply = app(Stash::class)->get($this->me()?->id, $this->replyStashName()); + $this->replyRestored = false; + } + } + /** * 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. diff --git a/app/NativeComponents/Concerns/AttachesToCommits.php b/app/NativeComponents/Concerns/AttachesToCommits.php new file mode 100644 index 0000000..226f8a4 --- /dev/null +++ b/app/NativeComponents/Concerns/AttachesToCommits.php @@ -0,0 +1,341 @@ + + */ + public array $sheetImages = []; + + /** + * The photo whose description is open under the photos, by its place. + */ + public ?int $describing = null; + + /** + * The poll's options, or null with no poll. + * + * @var list|null + */ + public ?array $sheetPoll = null; + + /** + * How long the poll runs, one of PollDraft::Lengths. + */ + public string $sheetPollLength = PollDraft::DefaultLength; + + /** + * Whether the sheet shows the draft as the commit it would become, in place of the field. + */ + public bool $previewing = false; + + /** + * What devnull sent back for the draft's body, with the body it was for under `draft`. + * + * @var array|null + */ + public ?array $sheetPreview = null; + + /** + * Whether a commit with photos is on its way to devnull. + */ + public bool $sheetSending = false; + + /** + * @param Closure(Client): SignedRequest $sign + * @param Closure(array): void $done + * @param Closure(string, ?ApiException): void $failed + */ + abstract protected function sendInBackground(Closure $sign, Closure $done, Closure $failed, int $timeout = 30): void; + + /** + * @param array|null $payload + * @param Closure(array): void $done + * @param Closure(string, ?ApiException): void $failed + */ + abstract protected function writeInBackground(string $method, string $path, ?array $payload, Closure $done, Closure $failed): void; + + /** + * The web's image button: the photo library, for as many photos as there's still room for. + */ + public function pickPhotos(): void + { + if ($this->canAttachPhotos()) { + Camera::pickImages('images', true, DraftImages::MaxImages - count($this->sheetImages))->start(); + } + } + + public function takePhoto(): void + { + if ($this->canAttachPhotos()) { + Camera::getPhoto()->start(); + } + } + + #[On(PhotoTaken::class)] + public function photoTaken(string $path = ''): void + { + $this->attachPhotos([$path]); + } + + /** + * @param list $files + */ + #[On(MediaSelected::class)] + public function photosPicked(array $files = []): void + { + $this->attachPhotos(array_map(fn (mixed $file): string => is_array($file) && is_string($file['path'] ?? null) ? $file['path'] : '', $files)); + } + + #[On(PermissionDenied::class)] + public function cameraDenied(): void + { + if ($this->isComposing()) { + $this->sheetError = DraftImages::CameraDenied; + } + } + + /** + * The ✕ on a photo: it comes off the draft, and off the phone. + */ + public function removePhoto(int $index): void + { + if (! isset($this->sheetImages[$index]) || $this->sheetSending) { + return; + } + + app(DraftImages::class)->discard([$this->sheetImages[$index]]); + array_splice($this->sheetImages, $index, 1); + $this->describing = null; + } + + /** + * The web's ALT on a photo: opens its description under the photos, or closes it. + */ + public function describePhoto(int $index): void + { + if (isset($this->sheetImages[$index])) { + $this->describing = $this->describing === $index ? null : $index; + } + } + + public function photoDescribed(int $index, string $description = ''): void + { + if (isset($this->sheetImages[$index])) { + $this->sheetImages[$index] = $this->sheetImages[$index]->withDescription($description); + } + } + + /** + * The web's poll button: two empty options, open for a day. A commit has photos or a poll, not both, and a + * reply never has one. + */ + public function addPoll(): void + { + if ($this->isComposing() && $this->sheetView !== 'reply' && $this->sheetImages === [] && $this->sheetPoll === null) { + $this->sheetPoll = PollDraft::start(); + $this->sheetPollLength = PollDraft::DefaultLength; + } + } + + public function removePoll(): void + { + $this->sheetPoll = null; + } + + public function addPollOption(): void + { + if ($this->sheetPoll !== null && count($this->sheetPoll) < PollDraft::MaxOptions) { + $this->sheetPoll[] = ''; + } + } + + /** + * Only the options past the first two can go, as on the web. + */ + public function removePollOption(int $index): void + { + if ($this->sheetPoll !== null && $index >= PollDraft::MinOptions && isset($this->sheetPoll[$index])) { + array_splice($this->sheetPoll, $index, 1); + } + } + + public function pollOptionChanged(int $index, string $option = ''): void + { + if ($this->sheetPoll !== null && isset($this->sheetPoll[$index])) { + $this->sheetPoll[$index] = mb_substr($option, 0, PollDraft::MaxOptionLength); + } + } + + /** + * The web's preview toggle. The draft shows as the commit it would become at once, and devnull is asked for its + * body, unless it already said for this one. If devnull can't be reached, the body shows as written. + */ + public function togglePreview(): void + { + if (! $this->isComposing()) { + return; + } + + $this->previewing = ! $this->previewing; + $this->describing = null; + $body = trim($this->sheetBody); + + if (! $this->previewing || $body === '' || ($this->sheetPreview['draft'] ?? null) === $body) { + return; + } + + $this->writeInBackground('POST', Client::PreviewPath, ['body' => $body], function (array $json) use ($body): void { + if (is_array($json['data'] ?? null)) { + $this->sheetPreview = [...$json['data'], 'draft' => $body]; + } + }, fn (): null => null); + } + + /** + * Whether the sheet is a composer: a new commit, a quote or a reply. + */ + protected function isComposing(): bool + { + return $this->sheetOpen && in_array($this->sheetView, ['compose', 'quote', 'reply'], true); + } + + /** + * The poll as devnull takes it, or null with none. + * + * @return array{options: list, duration: int}|null + */ + protected function sheetPollPayload(): ?array + { + return $this->sheetPoll === null || $this->sheetView === 'reply' ? null : PollDraft::payload($this->sheetPoll, $this->sheetPollLength); + } + + /** + * Sends a commit with photos from the background. The sheet goes once devnull has it, as for any commit, or says + * why it didn't take it, keeping the photos. Should the sheet have gone by then, a toast says so instead. + * + * @param Closure(Commit): void $sent + */ + protected function commitPhotosFromSheet(string $body, ?Commit $parent, ?Commit $quoted, Closure $sent): void + { + $images = $this->sheetImages; + $this->sheetSending = true; + $this->sheetError = null; + + $this->sendInBackground( + fn (Client $devnull): SignedRequest => $devnull->signedCommitWithImages($body, $images, $parent?->id, $quoted?->id), + function (array $json) use ($sent): void { + $this->sheetSending = false; + $commit = is_array($json['data'] ?? null) ? $json['data'] : []; + + app(ResponseCache::class)->commitAdded($commit); + $sent(Commit::fromArray($commit)); + }, + function (string $message): void { + $this->sheetSending = false; + + if ($this->isComposing()) { + $this->sheetError = $message; + } else { + Dialog::toast($message); + } + }, + self::UploadTimeout, + ); + } + + /** + * Lets go of the photos, the poll and the preview. Photos still on the phone are deleted, unless they're on + * their way to devnull. + */ + protected function forgetAttachments(): void + { + if (! $this->sheetSending) { + app(DraftImages::class)->discard($this->sheetImages); + } + + $this->sheetImages = []; + $this->describing = null; + $this->sheetPoll = null; + $this->sheetPollLength = PollDraft::DefaultLength; + $this->previewing = false; + $this->sheetPreview = null; + } + + private function canAttachPhotos(): bool + { + return $this->isComposing() && $this->sheetPoll === null && ! $this->sheetSending && count($this->sheetImages) < DraftImages::MaxImages; + } + + /** + * Keeps the photos the camera or the library handed over, as many as there's room for, and says why any didn't + * make it, in the web's words. + * + * @param list $paths + */ + private function attachPhotos(array $paths): void + { + $paths = array_values(array_filter($paths, fn (string $path): bool => $path !== '')); + + if ($paths === [] || ! $this->canAttachPhotos()) { + return; + } + + $this->sheetError = null; + + foreach ($paths as $path) { + if (count($this->sheetImages) >= DraftImages::MaxImages) { + $this->sheetError = DraftImages::TooMany; + + break; + } + + try { + $this->sheetImages[] = app(DraftImages::class)->keep($path); + } catch (InvalidDraftImage $exception) { + $this->sheetError = $exception->getMessage(); + } + } + } +} diff --git a/app/NativeComponents/Concerns/OpensCommitSheet.php b/app/NativeComponents/Concerns/OpensCommitSheet.php index d919c90..2856d06 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -4,6 +4,7 @@ use App\Devnull\Client; use App\Devnull\Data\Commit; +use App\Devnull\Data\Draft; use App\Devnull\ReportReason; use App\Devnull\Stash; use Closure; @@ -24,11 +25,20 @@ * Opened again, it starts from the draft, marked as restored. It's forgotten * once it's sent. * + * A commit's page can open its reply in the same composer, for the photos and + * the preview the reply bar has no room for (see replyInSheet()). The draft + * is the reply bar's, and goes back to it when the sheet closes. + * + * The composer can attach photos, a poll and show a preview, as the web's + * does (see AttachesToCommits). + * * The sheet keeps its commit while it slides away, so it doesn't empty on the * way down, and lets go of it once iOS says it's gone, however it was closed. */ trait OpensCommitSheet { + use AttachesToCommits; + /** * Whether the sheet is up, or on its way up. */ @@ -40,8 +50,8 @@ trait OpensCommitSheet public ?Commit $sheetCommit = null; /** - * What the sheet shows: 'report' (the form), 'reported' (sent), 'quote', or 'compose' (a new commit, with no - * commit of its own). + * What the sheet shows: 'report' (the form), 'reported' (sent), 'quote', 'reply', or 'compose' (a new commit, + * with no commit of its own). */ public string $sheetView = 'report'; @@ -126,6 +136,26 @@ public function quoteCommit(string $id): void $this->restoreSheetDraft(); } + /** + * The reply to a commit in the sheet, from its page's reply bar, with what was written there. The draft was just + * handed over, so it isn't marked as restored. + */ + public function replyInSheet(string $id): void + { + $commit = $this->shownCommit($id); + + if ($commit === null || $commit->isDeleted()) { + return; + } + + $this->forgetCommitSheet(); + $this->sheetCommit = $commit; + $this->sheetView = 'reply'; + $this->sheetOpen = true; + $this->restoreSheetDraft(); + $this->sheetRestored = false; + } + /** * A new commit in the sheet, with nothing quoted. */ @@ -165,28 +195,37 @@ public function dropSheetStash(): void } /** - * Commits what's written in the sheet, quoting its commit if it's a quote. The sheet goes once devnull has it, - * or keeps the draft with why it didn't take it. + * Commits what's written in the sheet, quoting its commit if it's a quote, or replying to it, with its photos or + * its poll. The sheet goes once devnull has it, or keeps the draft with why it didn't take it. */ public function commitFromSheet(): void { + $parent = $this->sheetView === 'reply' ? $this->sheetCommit : null; $quoted = $this->sheetView === 'quote' ? $this->sheetCommit : null; $stashName = $this->sheetStashName(); $body = trim($this->sheetBody); - if ($stashName === null || mb_strlen($body) < Commit::MinLength || mb_strlen($body) > Commit::MaxLength) { + if ($stashName === null || $this->sheetSending || ! Draft::canCommit($body, $this->sheetImages, $this->sheetPoll)) { return; } - $commit = $this->attemptInSheet(fn (Client $devnull): Commit => $devnull->createCommit($body, quotedCommitId: $quoted?->id)); + $sent = function (Commit $commit) use ($stashName): void { + app(Stash::class)->put($this->me()?->id, $stashName, ''); + $this->sheetOpen = false; + $this->showCommitted($commit); + }; + + if ($this->sheetImages !== []) { + $this->commitPhotosFromSheet($body, $parent, $quoted, $sent); - if ($commit === null) { return; } - app(Stash::class)->put($this->me()?->id, $stashName, ''); - $this->sheetOpen = false; - $this->showCommitted($commit); + $commit = $this->attemptInSheet(fn (Client $devnull): Commit => $devnull->createCommit($body, $parent?->id, $quoted?->id, $this->sheetPollPayload())); + + if ($commit !== null) { + $sent($commit); + } } /** @@ -202,7 +241,10 @@ public function hideCommitSheet(): void */ public function commitSheetClosed(): void { + $view = $this->sheetView; + $this->forgetCommitSheet(); + $this->commitSheetGone($view); } /** @@ -213,6 +255,14 @@ protected function showCommitted(Commit $commit): void // } + /** + * The sheet has gone, after showing $view. A screen whose own composer handed it a draft takes it back. + */ + protected function commitSheetGone(string $view): void + { + // + } + private function canQuote(Commit $commit): bool { $viewer = $this->me(); @@ -237,6 +287,7 @@ private function forgetCommitSheet(): void $this->sheetBody = ''; $this->sheetRestored = false; $this->sheetError = null; + $this->forgetAttachments(); } /** @@ -247,6 +298,7 @@ private function sheetStashName(): ?string return match (true) { $this->sheetView === 'compose' => 'commit', $this->sheetView === 'quote' && $this->sheetCommit !== null => 'quote-'.$this->sheetCommit->id, + $this->sheetView === 'reply' && $this->sheetCommit !== null => 'reply-'.$this->sheetCommit->id, default => null, }; } diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index e506842..cc6c730 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -21,9 +21,9 @@ * The actions under a commit: the star, the pastel in pastéis mode, Quote, * Reply, and the More menu's Save commit, Share link, Report commit and Delete * commit (Quote and Report open a sheet, see OpensCommitSheet), plus opening - * its link preview or images in the in-app browser, and "Show more" on a long - * one. Taps name the commit rather than a URL, so a URL is never rebuilt from - * an attribute. + * its link preview or images in the in-app browser, "Show more" on a long + * one, and voting on its poll (see VotesOnPolls). Taps name the commit rather + * than a URL, so a URL is never rebuilt from an attribute. * * A commit can be tapped where a row nests it too: the commit a row quotes, * and the replies a feed shows under a commit. @@ -31,6 +31,7 @@ trait ShowsCommits { use OpensCommitSheet; + use VotesOnPolls; use WritesInBackground; public const string DeleteAlert = 'delete-commit'; diff --git a/app/NativeComponents/Concerns/VotesOnPolls.php b/app/NativeComponents/Concerns/VotesOnPolls.php new file mode 100644 index 0000000..b410b34 --- /dev/null +++ b/app/NativeComponents/Concerns/VotesOnPolls.php @@ -0,0 +1,75 @@ + + */ + public array $pollChoices = []; + + /** + * The commits whose vote is on its way. + * + * @var list + */ + public array $voting = []; + + public function choosePollOption(string $id, string $optionId): void + { + $commit = $this->shownCommit($id); + + if ($commit === null || ! $commit->canBeVotedOnBy($this->me()) || ! in_array($optionId, array_map(fn ($option): string => $option->id, $commit->poll?->options ?? []), true)) { + return; + } + + $this->pollChoices[$id] = $optionId; + } + + public function voteOnPoll(string $id): void + { + $commit = $this->shownCommit($id); + $choice = $this->pollChoices[$id] ?? null; + + if ($commit === null || $choice === null || in_array($id, $this->voting, true) || ! $commit->canBeVotedOnBy($this->me())) { + return; + } + + $this->voting[] = $id; + + $this->writeInBackground('POST', Client::pollVotesPath($id), ['option' => $choice], function (array $json) use ($id): void { + $this->voting = array_values(array_diff($this->voting, [$id])); + unset($this->pollChoices[$id]); + + $voted = Commit::fromArray(is_array($json['data'] ?? null) ? $json['data'] : []); + $this->updateShownCommits(fn (Commit $commit): Commit => $commit->id === $id ? $commit->withPoll($voted->poll) : $commit); + + app(ResponseCache::class)->pollVoted(is_array($json['data'] ?? null) ? $json['data'] : []); + }, function (string $message) use ($id): void { + $this->voting = array_values(array_diff($this->voting, [$id])); + + Dialog::toast($message); + }); + } + + /** + * @param Closure(Commit): Commit $update + */ + abstract protected function updateShownCommits(Closure $update): void; +} diff --git a/app/NativeComponents/Concerns/WritesInBackground.php b/app/NativeComponents/Concerns/WritesInBackground.php index 625cd13..3ff1075 100644 --- a/app/NativeComponents/Concerns/WritesInBackground.php +++ b/app/NativeComponents/Concerns/WritesInBackground.php @@ -7,6 +7,7 @@ use App\Devnull\Exceptions\KeychainUnavailable; use App\Devnull\Exceptions\Unauthenticated; use App\Devnull\FetchInBackground; +use App\Devnull\SignedRequest; use Closure; use Throwable; @@ -30,17 +31,30 @@ trait WritesInBackground * @param Closure(string, ?ApiException): void $failed */ protected function writeInBackground(string $method, string $path, ?array $payload, Closure $done, Closure $failed): void + { + $this->sendInBackground(fn (Client $devnull): SignedRequest => $devnull->signedWrite($method, $path, $payload), $done, $failed); + } + + /** + * Sends a write that $sign signs, like a commit with images, which can take + * longer than $timeout seconds over a slow connection before it gives up. + * + * @param Closure(Client): SignedRequest $sign + * @param Closure(array): void $done + * @param Closure(string, ?ApiException): void $failed + */ + protected function sendInBackground(Closure $sign, Closure $done, Closure $failed, int $timeout = 30): void { try { - $request = app(Client::class)->signedWrite($method, $path, $payload); + $request = $sign(app(Client::class)); } catch (KeychainUnavailable $exception) { $failed($exception->getMessage()); return; } - FetchInBackground::dispatch($request->url, $request->headers, $request->method, $request->body) - ->timeout(30) + FetchInBackground::dispatch($request->url, $request->headers, $request->method, $request->body, $request->contentType) + ->timeout($timeout) ->finished(function (mixed $result) use ($done, $failed): void { $result = is_array($result) ? $result : []; $status = (int) ($result['status'] ?? 0); diff --git a/app/Providers/NativeServiceProvider.php b/app/Providers/NativeServiceProvider.php index ed108e9..42d58cf 100644 --- a/app/Providers/NativeServiceProvider.php +++ b/app/Providers/NativeServiceProvider.php @@ -5,6 +5,7 @@ use Codingwithrk\NativephpSocialAuth\NativephpSocialAuthServiceProvider; use Illuminate\Support\ServiceProvider; use Native\Mobile\Providers\BrowserServiceProvider; +use Native\Mobile\Providers\CameraServiceProvider; use Native\Mobile\Providers\SecureStorageServiceProvider; use Native\Mobile\Providers\ShareServiceProvider; use Native\Mobile\UI\NativeUIServiceProvider; @@ -45,6 +46,7 @@ public function plugins(): array return [ NativeUIServiceProvider::class, BrowserServiceProvider::class, + CameraServiceProvider::class, SecureStorageServiceProvider::class, ShareServiceProvider::class, MobileSplashscreenServiceProvider::class, diff --git a/composer.json b/composer.json index fdca3b6..2842fa7 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,10 @@ "type": "vcs", "url": "https://github.com/NativePHP/mobile-ui" }, + { + "type": "vcs", + "url": "https://github.com/NativePHP/mobile-camera" + }, { "name": "nativephp-plugins", "type": "composer", @@ -33,6 +37,7 @@ "laravel/tinker": "^3.0", "nativephp/mobile": "dev-combined/dev-null-app as 4.6.0", "nativephp/mobile-browser": "^1.0", + "nativephp/mobile-camera": "dev-fix/present-over-sheets as 1.0.5", "nativephp/mobile-secure-storage": "^1.1", "nativephp/mobile-share": "^1.1", "nativephp/mobile-ui": "dev-combined/dev-null-app as 0.8.0", diff --git a/composer.lock b/composer.lock index 0b36930..eb889a5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "db9e34f24e114a45a188e38b4d6524f8", + "content-hash": "b21d8afc9fe4684cf40902c02c26b386", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2893,6 +2893,70 @@ }, "time": "2026-09-03T18:26:19+00:00" }, + { + "name": "nativephp/mobile-camera", + "version": "dev-fix/present-over-sheets", + "source": { + "type": "git", + "url": "https://github.com/NativePHP/mobile-camera.git", + "reference": "e838409c0368bb2d1dd33be8679602e5560196ae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/NativePHP/mobile-camera/zipball/e838409c0368bb2d1dd33be8679602e5560196ae", + "reference": "e838409c0368bb2d1dd33be8679602e5560196ae", + "shasum": "" + }, + "require": { + "nativephp/mobile": "^3.0 || ^4.0", + "php": "^8.2" + }, + "require-dev": { + "orchestra/testbench": "^10.0", + "pestphp/pest": "^2.7|^3.0|^4.0" + }, + "type": "nativephp-plugin", + "extra": { + "laravel": { + "providers": [ + "Native\\Mobile\\Providers\\CameraServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + }, + "autoload": { + "psr-4": { + "Native\\Mobile\\Providers\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "test": [ + "pest" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "NativePHP", + "email": "info@nativephp.com" + } + ], + "description": "Camera plugin for NativePHP Mobile (Photo capture, Video recording, Gallery picker)", + "support": { + "source": "https://github.com/NativePHP/mobile-camera/tree/fix/present-over-sheets", + "issues": "https://github.com/NativePHP/mobile-camera/issues" + }, + "time": "2026-10-19T19:09:59+00:00" + }, { "name": "nativephp/mobile-secure-storage", "version": "1.1.0", @@ -11079,6 +11143,12 @@ "alias": "4.6.0", "alias_normalized": "4.6.0.0" }, + { + "package": "nativephp/mobile-camera", + "version": "dev-fix/present-over-sheets", + "alias": "1.0.5", + "alias_normalized": "1.0.5.0" + }, { "package": "nativephp/mobile-ui", "version": "dev-combined/dev-null-app", @@ -11089,6 +11159,7 @@ "minimum-stability": "stable", "stability-flags": { "nativephp/mobile": 20, + "nativephp/mobile-camera": 20, "nativephp/mobile-ui": 20 }, "prefer-stable": true, diff --git a/config/nativephp.php b/config/nativephp.php index a74f160..949da2e 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -110,9 +110,8 @@ */ 'permissions' => [ - // 'NSCameraUsageDescription' => 'Used to take a profile photo.', - // 'NSMicrophoneUsageDescription' => 'Used to record audio with your videos.', - // 'NSPhotoLibraryUsageDescription' => 'Used to select photos for your post.', + 'NSCameraUsageDescription' => 'devnull uses the camera when you take a photo to attach to a commit.', + 'NSPhotoLibraryUsageDescription' => 'devnull opens your photos when you pick some to attach to a commit.', ], /* diff --git a/resources/icons/camera.svg b/resources/icons/camera.svg new file mode 100644 index 0000000..4a73852 --- /dev/null +++ b/resources/icons/camera.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/chart-bar.svg b/resources/icons/chart-bar.svg new file mode 100644 index 0000000..8220620 --- /dev/null +++ b/resources/icons/chart-bar.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/eye.svg b/resources/icons/eye.svg new file mode 100644 index 0000000..8f1ff9a --- /dev/null +++ b/resources/icons/eye.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/image-plus.svg b/resources/icons/image-plus.svg new file mode 100644 index 0000000..0d1c571 --- /dev/null +++ b/resources/icons/image-plus.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/maximize-2.svg b/resources/icons/maximize-2.svg new file mode 100644 index 0000000..12b88ce --- /dev/null +++ b/resources/icons/maximize-2.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/plus.svg b/resources/icons/plus.svg new file mode 100644 index 0000000..e4c6006 --- /dev/null +++ b/resources/icons/plus.svg @@ -0,0 +1 @@ + diff --git a/resources/views/native/commit-detail.blade.php b/resources/views/native/commit-detail.blade.php index 0bf6538..78ecc29 100644 --- a/resources/views/native/commit-detail.blade.php +++ b/resources/views/native/commit-detail.blade.php @@ -107,7 +107,9 @@ - + + {{-- The bar has no room for photos or a preview, so this opens the reply in the composer sheet, which has them. --}} + @include('native.partials.composer-tool', ['ref' => 'reply-expand', 'icon' => \App\Icons\Lucide::Maximize2, 'label' => 'Reply with photos or a preview', 'press' => "replyInSheet('{$commit->id}')"]) {{-- EDGE sends no focus event, so a press on the field is how the page learns you're replying. --}} + @include('native.partials.body-counter', ['body' => $reply, 'ref' => 'reply-budget']) + Reply {{-- A reply stashed when you left the commit comes back with the web's stash@{0} and drop under it. --}} diff --git a/resources/views/native/partials/commit-poll.blade.php b/resources/views/native/partials/commit-poll.blade.php index c9b10de..9df5bc5 100644 --- a/resources/views/native/partials/commit-poll.blade.php +++ b/resources/views/native/partials/commit-poll.blade.php @@ -4,11 +4,16 @@ its percentage in mono on the right, and "4 votes · 3h left" under them all. Without results, the options are plain boxes over the time left. - Polls are read-only here: devnull has no way to vote from the app yet, so where the web offers radio buttons and - Vote, the options are the same plain boxes and nothing is tappable. $static is the quote card's copy, which never is. + On someone else's published commit whose poll is open and has no vote of yours yet, you vote as on the web: tap an + option to pick it, its box in Commit Blue on Blue Wash with its radio filled, then Vote, beside the time left, + since votes are final (see VotesOnPolls). Vote spins while it goes, and the poll then shows its results. $static + is the quote card's copy, which never votes. --}} @php($poll = $commit->poll) @php($static ??= false) +@php($canVote = ! $static && $commit->canBeVotedOnBy($viewer ?? app(\App\Devnull\Account::class)->user())) +@php($choice = $pollChoices[$commit->id] ?? null) +@php($isVoting = in_array($commit->id, $voting ?? [], true)) @foreach ($poll->options as $option) @if ($poll->showsResults()) @@ -31,6 +36,18 @@ {{ $option->share() }}% + @elseif ($canVote) + @php($chosen = $choice === $option->id) + + + + @if ($chosen) + + @endif + + {{ $option->label }} + + @else {{ $option->label }} @@ -38,5 +55,13 @@ @endif @endforeach - {{ $poll->showsResults() ? $poll->summary() : $poll->status() }} + @if ($canVote) + + {{ $poll->status() }} + {{-- The label stays put while it spins, so the button keeps its size. --}} + Vote + + @else + {{ $poll->showsResults() ? $poll->summary() : $poll->status() }} + @endif diff --git a/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php index 983703f..598c1b0 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -5,6 +5,13 @@ commit, without the card. A draft stashed last time comes back in it, with the web's stash@{0} and drop on the left of Commit's row. + A commit's page opens its reply here too, as "Reply to @handle", over the commit being answered as a static card, + for the photos and the preview its reply bar has no room for. Commit says Reply there. + + Under what's being written come its photos, then the poll editor, then a row of tools: photos, the camera, a poll + (not on a reply), and the preview, which shows the draft as the commit it would become in place of the field (see + AttachesToCommits). With photos, Commit spins while they upload, and a short body, or none, will do. + Report commit, in someone else's published commit's More menu, opens it as the web's Report dialog: the private, not-hidden line, the commit's body clamped to three lines, the four reasons, optional details, and Send report, which waits for a reason. Once devnull has the report, the sheet says so, with Done. If devnull says no, why shows @@ -15,30 +22,46 @@ --}} @if ($sheetCommit || $sheetView === 'compose') - @if (in_array($sheetView, ['quote', 'compose'], true)) - @php($sheetLength = mb_strlen(trim($sheetBody))) + @if (in_array($sheetView, ['quote', 'compose', 'reply'], true)) @php($quoting = $sheetView === 'quote') + @php($replying = $sheetView === 'reply') - {{ $quoting ? 'Quote '.$sheetCommit->author->handle() : 'New commit' }} - - + {{ match (true) { $quoting => 'Quote '.$sheetCommit->author->handle(), $replying => 'Reply to '.$sheetCommit->author->handle(), default => 'New commit' } }} + + @if ($replying) + @include('native.partials.quoted-commit', ['quoted' => $sheetCommit, 'static' => true, 'label' => 'Replying to']) + @endif - @if ($quoting) - @include('native.partials.quoted-commit', ['quoted' => $sheetCommit, 'static' => true]) + @if ($previewing) + @include('native.partials.composer-preview') + @else + + + @include('native.partials.composer-photos') + + @if ($sheetPoll !== null && ! $replying) + @include('native.partials.poll-editor') + @endif + + @if ($quoting) + @include('native.partials.quoted-commit', ['quoted' => $sheetCommit, 'static' => true]) + @endif @endif + @include('native.partials.composer-tools') + @if ($sheetError) {{ $sheetError }} @endif @@ -49,7 +72,8 @@ class="w-full text-theme-ink" @endif @include('native.partials.body-counter', ['body' => $sheetBody, 'ref' => 'sheet-budget']) - Commit + {{-- The label stays put while it spins, so the button keeps its size. --}} + {{ $replying ? 'Reply' : 'Commit' }} diff --git a/resources/views/native/partials/composer-photos.blade.php b/resources/views/native/partials/composer-photos.blade.php new file mode 100644 index 0000000..d4e4843 --- /dev/null +++ b/resources/views/native/partials/composer-photos.blade.php @@ -0,0 +1,52 @@ +{{-- + The photos attached to a draft, as the web's ImagePickerPreviews: 80pt squares, each with ✕ to take it off and + ALT to describe it for people who can't see it, inverted once it has a description. The open description sits + under the photos with its count out of 1,000 and Done. +--}} +@if ($sheetImages !== []) + + + @foreach ($sheetImages as $index => $image) + + + + + + + + + + + + {{ $image->isDescribed() ? 'ALT' : '+ALT' }} + + + + @endforeach + + + @if ($describing !== null && isset($sheetImages[$describing])) + @php($described = $sheetImages[$describing]) + + Description of photo {{ $describing + 1 }} + + + {{ mb_strlen($described->description) }}/{{ \App\Devnull\DraftImages::MaxDescriptionLength }} + Done + + + @endif + +@endif diff --git a/resources/views/native/partials/composer-preview.blade.php b/resources/views/native/partials/composer-preview.blade.php new file mode 100644 index 0000000..e0db493 --- /dev/null +++ b/resources/views/native/partials/composer-preview.blade.php @@ -0,0 +1,27 @@ +{{-- + The draft in the composer as the commit it would become, in place of the field, as the web's ComposerPreviewBody: + its body as devnull would keep it, with its code and mentions once devnull has said, then its photos, its poll's + options and how long it runs once published, and the commit it quotes. With nothing to show yet, it says so. +--}} +@php($draftCommit = \App\Devnull\Data\Draft::asCommit($me ?? app(\App\Devnull\Account::class)->user() ?? \App\Devnull\Data\User::fromArray([]), $sheetBody, $sheetPreview, $sheetImages, $sheetView === 'reply' ? null : $sheetPoll, $sheetView === 'quote' ? $sheetCommit : null)) + + @if (trim($sheetBody) === '' && $sheetImages === [] && $sheetPoll === null) + Nothing to preview yet. + @else + @include('native.partials.commit-body', ['commit' => $draftCommit, 'focused' => true]) + @include('native.partials.commit-media', ['commit' => $draftCommit, 'withEmbeds' => false]) + @if ($draftCommit->poll) + + @foreach ($draftCommit->poll->options as $option) + + {{ $option->label }} + + @endforeach + {{ \App\Devnull\Data\PollDraft::runs($sheetPollLength) }} + + @endif + @if ($draftCommit->quotedCommit) + @include('native.partials.quoted-commit', ['commit' => $draftCommit, 'quoted' => $draftCommit->quotedCommit, 'static' => true]) + @endif + @endif + diff --git a/resources/views/native/partials/composer-tool.blade.php b/resources/views/native/partials/composer-tool.blade.php new file mode 100644 index 0000000..475cb1f --- /dev/null +++ b/resources/views/native/partials/composer-tool.blade.php @@ -0,0 +1,17 @@ +{{-- + One of the composer's tools, as the web's ghost icon buttons: a 44pt target with the icon in muted ink, on Blue + Wash in Commit Blue while it's on ($active). While it can't be used ($disabled), it's faded, with nothing to press. +--}} +@php($active ??= false) +@php($disabled ??= false) +@if ($disabled) + + + +@else + + + + + +@endif diff --git a/resources/views/native/partials/composer-tools.blade.php b/resources/views/native/partials/composer-tools.blade.php new file mode 100644 index 0000000..b46ae58 --- /dev/null +++ b/resources/views/native/partials/composer-tools.blade.php @@ -0,0 +1,14 @@ +{{-- + The composer's tools, on a row of their own under what's being written, as the web's composer wraps them on a + phone: photos from the library, a photo from the camera, a poll, and the preview. Up to three photos, or a poll, + not both, as on the web, so each greys out while the other is there; a reply has no poll at all. +--}} +@php($photosFull = count($sheetImages) >= \App\Devnull\DraftImages::MaxImages) + + @include('native.partials.composer-tool', ['ref' => 'sheet-photos', 'icon' => \App\Icons\Lucide::ImagePlus, 'label' => 'Attach up to 3 photos', 'press' => 'pickPhotos', 'disabled' => $sheetPoll !== null || $photosFull || $sheetSending]) + @include('native.partials.composer-tool', ['ref' => 'sheet-camera', 'icon' => \App\Icons\Lucide::Camera, 'label' => 'Take a photo', 'press' => 'takePhoto', 'disabled' => $sheetPoll !== null || $photosFull || $sheetSending]) + @unless ($sheetView === 'reply') + @include('native.partials.composer-tool', ['ref' => 'sheet-poll', 'icon' => \App\Icons\Lucide::ChartBar, 'label' => $sheetImages === [] ? 'Add poll' : 'A commit can have images or a poll, not both', 'press' => 'addPoll', 'disabled' => $sheetImages !== [] || $sheetPoll !== null || $sheetSending]) + @endunless + @include('native.partials.composer-tool', ['ref' => 'sheet-preview', 'icon' => \App\Icons\Lucide::Eye, 'label' => $previewing ? 'Edit' : 'Preview', 'press' => 'togglePreview', 'active' => $previewing]) + diff --git a/resources/views/native/partials/poll-editor.blade.php b/resources/views/native/partials/poll-editor.blade.php new file mode 100644 index 0000000..1e1f180 --- /dev/null +++ b/resources/views/native/partials/poll-editor.blade.php @@ -0,0 +1,50 @@ +{{-- + The poll in a new commit or a quote, as the web's PollEditor: a hairline box with an outlined field per option, + two to start, ✕ on the third and fourth, "Add option" until there are four, then "Poll length" and Remove poll + under a hairline. Options are up to 50 characters, as devnull takes them. +--}} + + @foreach ($sheetPoll as $index => $option) + + + @if ($index >= \App\Devnull\Data\PollDraft::MinOptions) + + + + + + @endif + + @endforeach + + @if (count($sheetPoll) < \App\Devnull\Data\PollDraft::MaxOptions) + + + + Add option + + + @endif + + + + + + + + + Remove poll + + + + diff --git a/resources/views/native/partials/quoted-commit.blade.php b/resources/views/native/partials/quoted-commit.blade.php index 6a6f2a8..f919a70 100644 --- a/resources/views/native/partials/quoted-commit.blade.php +++ b/resources/views/native/partials/quoted-commit.blade.php @@ -4,9 +4,11 @@ The whole box opens the quoted commit. One you may no longer see, or one that was deleted, is a dashed box in git's voice instead, with no author and nothing to open. Quotes embed one level only. - $static draws the card under the quote you're writing, for $quoted, the commit you're quoting. It opens nothing. + $static draws the card under the quote you're writing, for $quoted, the commit you're quoting, or over the reply + you're writing, $label 'Replying to'. It opens nothing. --}} @php($static ??= false) +@php($label ??= 'Quoting') @php($quoted = $static ? $quoted : $commit->quotedCommit) @if ($quoted === null || $quoted->isDeleted()) @@ -16,7 +18,7 @@ @else @php($quotedAuthor = $quoted->author) @if ($static) - + @else @endif diff --git a/tests/Feature/Devnull/ClientTest.php b/tests/Feature/Devnull/ClientTest.php index fe9014c..d2200a3 100644 --- a/tests/Feature/Devnull/ClientTest.php +++ b/tests/Feature/Devnull/ClientTest.php @@ -3,6 +3,8 @@ use App\Devnull\Client; use App\Devnull\Data\Commit; use App\Devnull\Data\CurrentUser; +use App\Devnull\Data\DraftImage; +use App\Devnull\Data\PollDraft; use App\Devnull\Data\User; use App\Devnull\DeviceKey; use App\Devnull\ErrorCode; @@ -112,6 +114,60 @@ function receivedMessage(Request $request): string && str_ends_with(receivedMessage($request), "\nPOST\n/api/v1/commits\n".'{"body":"This, all of it.","quoted_commit_id":"c1"}')); }); +it('starts a poll with a commit, its options trimmed and its length in minutes', function () { + Http::fake(['devnull.test/*' => Http::response(['data' => commitJson(['id' => 'p1'])], 201)]); + + app(Client::class)->createCommit('Tabs or spaces?', poll: PollDraft::payload(['Tabs', ' Spaces '], '3 days')); + + Http::assertSent(fn (Request $request): bool => $request->body() === '{"body":"Tabs or spaces?","poll":{"options":["Tabs","Spaces"],"duration":4320}}' + && $request->header('X-SSH-Signature')[0] === testKey()->sign(receivedMessage($request), 'devnull-api')); +}); + +it('signs a commit with images as multipart, with their SHA-256s on a line before the JSON body', function () { + $first = new DraftImage(testPhoto('jpg'), 'image/jpeg', 40, 30, ' A cabin at sunrise. '); + $second = new DraftImage(testPhoto('png'), 'image/png', 40, 30); + + $request = app(Client::class)->signedCommitWithImages('Offsite photo dump', [$first, $second], quotedCommitId: 'c1'); + + expect($request->url)->toBe('http://devnull.test/api/v1/commits') + ->and($request->method)->toBe('POST') + ->and($request->contentType)->toMatch('/^multipart\/form-data; boundary=devnull-[0-9a-f]{32}$/'); + + $boundary = substr($request->contentType, strlen('multipart/form-data; boundary=')); + $parts = explode("--{$boundary}", $request->body); + $payload = '{"body":"Offsite photo dump","quoted_commit_id":"c1","image_descriptions":["A cabin at sunrise.",null]}'; + + expect(array_slice($parts, 1))->toBe([ + "\r\nContent-Disposition: form-data; name=\"payload\"; filename=\"payload.json\"\r\nContent-Type: application/json\r\n\r\n{$payload}\r\n", + "\r\nContent-Disposition: form-data; name=\"images[]\"; filename=\"image-1.jpeg\"\r\nContent-Type: image/jpeg\r\n\r\n".file_get_contents($first->path)."\r\n", + "\r\nContent-Disposition: form-data; name=\"images[]\"; filename=\"image-2.png\"\r\nContent-Type: image/png\r\n\r\n".file_get_contents($second->path)."\r\n", + "--\r\n", + ]); + + $message = RequestSigner::message( + $request->headers['X-Devnull-Timestamp'], + $request->headers['X-Devnull-Nonce'], + 'POST', + '/api/v1/commits', + hash_file('sha256', $first->path).','.hash_file('sha256', $second->path)."\n".$payload, + ); + + expect($request->headers['X-Devnull-Timestamp'])->toBe('1760000000') + ->and($request->headers['X-SSH-Signature'])->toBe(testKey()->sign($message, 'devnull-api-images')); +}); + +it('sends a signed request with the content type it was signed with', function () { + Http::fake(['devnull.test/*' => Http::response(['data' => commitJson()], 201)]); + + $request = app(Client::class)->signedCommitWithImages('Look', [new DraftImage(testPhoto(), 'image/png', 40, 30)], parentId: 'c1'); + + expect(Client::sendSigned($request)->status())->toBe(201); + + Http::assertSent(fn (Request $request2): bool => $request2->header('Content-Type') === [$request->contentType] + && $request2->body() === $request->body + && $request2->header('X-SSH-Signature') === [$request->headers['X-SSH-Signature']]); +}); + it('uses a fresh nonce for every request', function () { Http::fake(['devnull.test/*' => Http::response(['data' => userJson()])]); diff --git a/tests/Feature/Devnull/DraftImagesTest.php b/tests/Feature/Devnull/DraftImagesTest.php new file mode 100644 index 0000000..7413329 --- /dev/null +++ b/tests/Feature/Devnull/DraftImagesTest.php @@ -0,0 +1,79 @@ +keep($picked); + + expect($image) + ->mimeType->toBe($mimeType) + ->width->toBe(40) + ->height->toBe(30) + ->description->toBe('') + ->and($image->path)->toStartWith(storage_path('app/draft-images/')) + ->and(file_exists($image->path))->toBeTrue() + ->and(file_exists($picked))->toBeFalse(); +})->with([ + 'jpeg' => ['jpg', 'image/jpeg'], + 'png' => ['png', 'image/png'], + 'webp' => ['webp', 'image/webp'], +]); + +it('refuses what devnull would, in the web\'s words, and deletes it', function (Closure $picked, string $message) { + $path = $picked(); + + expect(fn () => app(DraftImages::class)->keep($path))->toThrow(InvalidDraftImage::class, $message) + ->and(file_exists($path))->toBeFalse() + ->and(glob(storage_path('app/draft-images/*')) ?: [])->toBe([]); +})->with([ + 'a gif' => [fn (): string => testPhoto('gif'), DraftImages::Unsupported], + 'not an image' => [function (): string { + $path = testPhoto(); + file_put_contents($path, 'not a photo'); + + return $path; + }, DraftImages::Unreadable], + 'over 10 MB' => [function (): string { + $path = testPhoto(); + file_put_contents($path, str_repeat("\0", DraftImages::MaxBytes), FILE_APPEND); + + return $path; + }, DraftImages::Unsupported], + 'wider than 6000 pixels' => [fn (): string => testPhoto('png', DraftImages::MaxDimension + 1, 1), DraftImages::TooLarge], + 'taller than 6000 pixels' => [fn (): string => testPhoto('png', 1, DraftImages::MaxDimension + 1), DraftImages::TooLarge], +]); + +it('says a photo it can\'t find is unreadable', function () { + expect(fn () => app(DraftImages::class)->keep('/nowhere/picked.png'))->toThrow(InvalidDraftImage::class, DraftImages::Unreadable); +}); + +it('deletes the photos of a draft, but never a file it didn\'t keep', function () { + $kept = app(DraftImages::class)->keep(testPhoto()); + $elsewhere = testPhoto(); + + app(DraftImages::class)->discard([$kept, new DraftImage($elsewhere, 'image/png', 40, 30)]); + + expect(file_exists($kept->path))->toBeFalse() + ->and(file_exists($elsewhere))->toBeTrue(); +}); + +it('deletes the photos of drafts left for more than a day', function () { + $old = app(DraftImages::class)->keep(testPhoto()); + touch($old->path, time() - 86_401); + $recent = app(DraftImages::class)->keep(testPhoto()); + touch($recent->path, time() - 3_600); + + app(DraftImages::class)->keep(testPhoto()); + + expect(file_exists($old->path))->toBeFalse() + ->and(file_exists($recent->path))->toBeTrue(); +}); diff --git a/tests/Feature/Screens/ComposerPhotosTest.php b/tests/Feature/Screens/ComposerPhotosTest.php new file mode 100644 index 0000000..b023fa0 --- /dev/null +++ b/tests/Feature/Screens/ComposerPhotosTest.php @@ -0,0 +1,277 @@ + 'http://devnull.test']); + signIn(); +}); + +afterEach(function () { + File::deleteDirectory(storage_path('app/draft-images')); +}); + +/** + * The Log's composer open in the sheet, whose commit devnull answers with $answer. + * + * @param array{0: mixed, 1?: int}|null $answer + */ +function composerOnLog(?array $answer = null): TestableComponent +{ + serveApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'body' => 'An older commit', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])])])], + 'POST /api/v1/commits' => $answer ?? [['data' => commitJson(['id' => 'n1', 'body' => 'Look at this', 'status' => 'pending', 'images' => [['id' => 'i1', 'url' => 'http://devnull.test/commit-images/i1?expires=1&signature=abc', 'width' => 40, 'height' => 30, 'description' => null]]])], 201], + ]); + + return Native::visit('/log', platform: 'ios')->press('composeInSheet'); +} + +/** + * The library handing back the photos at $paths, as the plugin sends them. + * + * @param list $paths + */ +function photosPicked(TestableComponent $screen, array $paths): TestableComponent +{ + return $screen->emitNative(MediaSelected::class, [ + 'success' => true, + 'files' => array_map(fn (string $path): array => ['path' => $path, 'mimeType' => 'image/'.pathinfo($path, PATHINFO_EXTENSION), 'type' => 'image'], $paths), + 'count' => count($paths), + ]); +} + +/** + * The parts of a multipart body, by name, in order. + * + * @return list + */ +function multipartParts(Request $request): array +{ + preg_match('/boundary=(\S+)/', $request->header('Content-Type')[0], $matches); + + return array_map(function (string $part): array { + [$headers, $contents] = explode("\r\n\r\n", ltrim($part, "\r\n"), 2); + preg_match('/name="([^"]+)"/', $headers, $name); + preg_match('/Content-Type: (\S+)/', $headers, $type); + + return ['name' => $name[1], 'type' => $type[1], 'contents' => substr($contents, 0, -2)]; + }, array_slice(explode('--'.$matches[1], $request->body()), 1, -1)); +} + +function isTool(TestableComponent $screen, string $ref): bool +{ + return nodeByRef($screen, $ref)['type'] === 'pressable'; +} + +it('opens the photo library for up to three photos, then for what\'s left', function () { + $screen = composerOnLog()->tap('sheet-photos'); + + $screen->assertNativeCalled('Camera.PickMedia', fn (array $params): bool => $params['mediaType'] === 'images' && $params['multiple'] === true && $params['maxItems'] === 3); + + photosPicked($screen, [testPhoto()])->tap('sheet-photos'); + + $screen->assertNativeCalled('Camera.PickMedia', fn (array $params): bool => $params['maxItems'] === 2) + ->tap('sheet-camera') + ->assertNativeCalled('Camera.GetPhoto'); +}); + +it('attaches the photos the library hands back and the one the camera takes, as the web shows them', function () { + $screen = photosPicked(composerOnLog(), [testPhoto('jpg', 40, 30), testPhoto('webp', 20, 20)]) + ->emitNative(PhotoTaken::class, ['path' => testPhoto('jpg', 30, 40), 'mimeType' => 'image/jpeg']); + + $images = $screen->instance()->sheetImages; + + expect(array_map(fn (DraftImage $image): array => [$image->mimeType, $image->width, $image->height], $images))->toBe([ + ['image/jpeg', 40, 30], + ['image/webp', 20, 20], + ['image/jpeg', 30, 40], + ]) + ->and(findNode(nodeByRef($screen, 'sheet-image-0'), fn (array $node): bool => ($node['type'] ?? null) === 'image')['props']['src'])->toBe($images[0]->path) + ->and(nodeByRef($screen, 'sheet-image-1-alt')['props']['a11y_label'])->toBe('Add description to photo 2') + ->and(nodeByRef($screen, 'sheet-image-2-remove')['props']['a11y_label'])->toBe('Remove photo 3') + // Three is all a commit takes, so both photo tools grey out, and a poll can't come with photos. + ->and(isTool($screen, 'sheet-photos'))->toBeFalse() + ->and(isTool($screen, 'sheet-camera'))->toBeFalse() + ->and(isTool($screen, 'sheet-poll'))->toBeFalse() + ->and(nodeByRef($screen, 'sheet-poll')['props']['a11y_label'])->toBe('A commit can have images or a poll, not both'); +}); + +it('says why a photo won\'t do, in the web\'s words', function (Closure $photos, string $message, int $kept) { + $screen = photosPicked(composerOnLog(), $photos()); + + expect(nodeByRef($screen, 'sheet-error')['props']['text'])->toBe($message) + ->and($screen->instance()->sheetImages)->toHaveCount($kept); +})->with([ + 'a gif' => [fn (): array => [testPhoto('gif')], DraftImages::Unsupported, 0], + 'too many pixels' => [fn (): array => [testPhoto('png', 6001, 1)], DraftImages::TooLarge, 0], + 'more than three' => [fn (): array => [testPhoto(), testPhoto(), testPhoto(), testPhoto()], DraftImages::TooMany, 3], +]); + +it('asks for the camera in Settings when it\'s not allowed', function () { + $screen = composerOnLog()->emitNative(PermissionDenied::class, ['action' => 'camera']); + + expect(nodeByRef($screen, 'sheet-error')['props']['text'])->toBe(DraftImages::CameraDenied); +}); + +it('describes a photo for people who can\'t see it, and takes one off the draft and the phone', function () { + $screen = photosPicked(composerOnLog(), [testPhoto(), testPhoto()])->tap('sheet-image-0-alt'); + + expect(nodeByRef($screen, 'sheet-image-description-input')['props'])->toMatchArray(['autofocus' => true, 'max_length' => 1000]); + + $screen->input('sheet-image-description-input', 'The cabin at sunrise.'); + + expect($screen->instance()->sheetImages[0]->description)->toBe('The cabin at sunrise.') + ->and(nodeByRef($screen, 'sheet-image-0-alt')['props']['a11y_label'])->toBe('Edit description of photo 1') + ->and(treeShows($screen->tree(), '21/1000'))->toBeTrue(); + + $screen->tap('sheet-image-description-done'); + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-image-description'))->toBeNull(); + + $first = $screen->instance()->sheetImages[0]->path; + $screen->tap('sheet-image-0-remove'); + + expect(file_exists($first))->toBeFalse() + ->and($screen->instance()->sheetImages)->toHaveCount(1) + ->and($screen->instance()->sheetImages[0]->description)->toBe(''); +}); + +it('commits photos with a short body, or none, as multipart, then shows the commit and lets the photos go', function (string $body, string $sent) { + $screen = photosPicked(composerOnLog(), [testPhoto('jpg'), testPhoto('png')]); + $paths = array_map(fn (DraftImage $image): string => $image->path, $screen->instance()->sheetImages); + $contents = array_map(file_get_contents(...), $paths); + + $screen->tap('sheet-image-1-alt')->input('sheet-image-description-input', 'A terminal.'); + + if ($body !== '') { + $screen->input('sheet-body', $body); + } + + expect(nodeByRef($screen, 'sheet-commit')['props']['disabled'] ?? false)->toBeFalse(); + + $screen->tap('sheet-commit')->dismissSheet('sheet'); + + Http::assertSent(function (Request $request) use ($contents, $sent): bool { + if ($request->method() !== 'POST' || $request->url() !== 'http://devnull.test/api/v1/commits') { + return false; + } + + expect(multipartParts($request))->toBe([ + ['name' => 'payload', 'type' => 'application/json', 'contents' => $sent], + ['name' => 'images[]', 'type' => 'image/jpeg', 'contents' => $contents[0]], + ['name' => 'images[]', 'type' => 'image/png', 'contents' => $contents[1]], + ]) + ->and($request->hasHeader('X-SSH-Signature'))->toBeTrue(); + + return true; + }); + + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeFalse() + ->and($screen->instance()->commits[0]->id)->toBe('n1') + ->and($screen->instance()->commits[0]->images[0]->url)->toBe('http://devnull.test/commit-images/i1?expires=1&signature=abc') + ->and($screen->instance()->sheetImages)->toBe([]) + ->and(array_filter($paths, file_exists(...)))->toBe([]) + ->and(app(Stash::class)->get(app(Account::class)->user()?->id, 'commit'))->toBe(''); +})->with([ + 'none' => ['', '{"body":"","image_descriptions":[null,"A terminal."]}'], + 'short' => ['ok', '{"body":"ok","image_descriptions":[null,"A terminal."]}'], +]); + +it('spins Commit while the photos upload, and says why devnull turned them down, keeping them', function () { + holdRefreshes(); + + $screen = photosPicked(composerOnLog([['message' => 'The images.0 field must be an image.', 'errors' => ['images.0' => ['The images.0 field must be an image.']]], 422]), [testPhoto()]) + ->input('sheet-body', 'Look at this'); + $sent = refreshesSent($screen); + + $screen->tap('sheet-commit'); + + expect(nodeByRef($screen, 'sheet-commit')['props'])->toMatchArray(['loading' => true, 'disabled' => true]) + ->and(isTool($screen, 'sheet-photos'))->toBeFalse(); + + // Pressed again while it's on its way, nothing more goes. + $screen->call('commitFromSheet'); + expect(refreshesSent($screen))->toBe($sent + 1); + + finishRefreshes($screen); + + expect(nodeByRef($screen, 'sheet-error')['props']['text'])->toBe('The images.0 field must be an image.') + ->and(nodeByRef($screen, 'sheet-commit')['props'])->not->toHaveKey('loading') + ->and(nodeByRef($screen, 'sheet')['props']['visible'])->toBeTrue() + ->and($screen->instance()->sheetImages)->toHaveCount(1) + ->and(file_exists($screen->instance()->sheetImages[0]->path))->toBeTrue(); +}); + +it('drops the photos when the sheet goes unsent, and deletes them from the phone', function () { + $screen = photosPicked(composerOnLog(), [testPhoto()]); + $path = $screen->instance()->sheetImages[0]->path; + + $screen->dismissSheet('sheet'); + + expect(file_exists($path))->toBeFalse() + ->and($screen->instance()->sheetImages)->toBe([]); + + // A photo that arrives once the sheet has gone attaches to nothing. + photosPicked($screen, [testPhoto()]); + + expect($screen->instance()->sheetImages)->toBe([]); +}); + +it('opens a commit\'s reply in the sheet with what the reply bar had, and replies with photos', function () { + serveApi([ + 'GET /api/v1/commits/c2' => [commitPageJson(commitJson(['id' => 'c2', 'body' => 'The focused commit', 'author' => userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada'])]))], + 'GET /api/v1/commits/c2/replies' => [pageJson([])], + 'POST /api/v1/commits' => [['data' => replyJson(['id' => 'r9', 'parent_id' => 'c2', 'body' => 'Here it is'])], 201], + ]); + + $screen = Native::visit('/log/commits/c2') + ->input('reply-input', 'Here it') + ->tap('reply-expand') + ->assertSee('Reply to @ada'); + + expect(nodeByRef($screen, 'sheet-body')['props'])->toMatchArray(['value' => 'Here it', 'placeholder' => 'Reply to @ada']) + ->and(nodeByRef($screen, 'quote-card')['props']['a11y_label'])->toBe('Replying to Ada') + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-stash'))->toBeNull() + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-poll'))->toBeNull() + ->and(nodeByRef($screen, 'sheet-commit')['props']['label'] ?? null)->toBe('Reply'); + + photosPicked($screen, [testPhoto()]) + ->input('sheet-body', 'Here it is') + ->tap('sheet-commit') + ->dismissSheet('sheet') + ->assertSee('Here it is'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && multipartParts($request)[0]['contents'] === '{"body":"Here it is","parent_id":"c2"}'); + + expect(array_map(fn ($reply): string => $reply->id, $screen->instance()->replies))->toBe(['r9']) + ->and($screen->instance()->reply)->toBe('') + ->and(nodeByRef($screen, 'reply-input')['props']['value'])->toBe(''); +}); + +it('hands the reply back to the reply bar when the sheet goes unsent', function () { + serveApi([ + 'GET /api/v1/commits/c2' => [commitPageJson(commitJson(['id' => 'c2', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])]))], + 'GET /api/v1/commits/c2/replies' => [pageJson([])], + ]); + + $screen = Native::visit('/log/commits/c2') + ->input('reply-input', 'Half') + ->tap('reply-expand') + ->input('sheet-body', 'Half a reply') + ->dismissSheet('sheet'); + + expect(nodeByRef($screen, 'reply-input')['props']['value'])->toBe('Half a reply') + ->and(app(Stash::class)->get(app(Account::class)->user()?->id, 'reply-c2'))->toBe('Half a reply'); + + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); +}); diff --git a/tests/Feature/Screens/ComposerPollTest.php b/tests/Feature/Screens/ComposerPollTest.php new file mode 100644 index 0000000..a49567d --- /dev/null +++ b/tests/Feature/Screens/ComposerPollTest.php @@ -0,0 +1,132 @@ + 'http://devnull.test']); + signIn(); +}); + +afterEach(function () { + File::deleteDirectory(storage_path('app/draft-images')); +}); + +function pollComposerOnLog(): TestableComponent +{ + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])])])], + 'POST /api/v1/commits' => [['data' => commitJson(['id' => 'p1', 'body' => 'Tabs or spaces?', 'status' => 'pending', 'poll' => [ + 'id' => 'poll1', 'closes_at' => null, 'is_closed' => false, 'has_voted' => false, 'total_votes' => null, + 'options' => [['id' => 'o1', 'label' => 'Tabs', 'votes_count' => null, 'voted' => false, 'percentage' => null], ['id' => 'o2', 'label' => 'Spaces', 'votes_count' => null, 'voted' => false, 'percentage' => null]], + ]])], 201], + ]); + + return Native::visit('/log', platform: 'ios')->press('composeInSheet'); +} + +function sheetCanCommit(TestableComponent $screen): bool +{ + return ! (nodeByRef($screen, 'sheet-commit')['props']['disabled'] ?? false); +} + +it('adds a poll with two empty options, open for a day, and waits for both before it commits', function () { + $screen = pollComposerOnLog()->input('sheet-body', 'Tabs or spaces?')->tap('sheet-poll'); + + expect(nodeByRef($screen, 'poll-option-0')['props'])->toMatchArray(['label' => 'Option 1', 'value' => '', 'autofocus' => true, 'max_length' => 50]) + ->and(nodeByRef($screen, 'poll-option-1')['props'])->toMatchArray(['label' => 'Option 2', 'value' => '']) + ->and(nodeByRef($screen, 'poll-length')['props'])->toMatchArray(['label' => 'Poll length', 'options' => ['1 hour', '1 day', '3 days', '7 days'], 'value' => '1 day']) + ->and(findNode($screen->tree(), fn (array $node): bool => in_array($node['ref'] ?? null, ['poll-option-0-remove', 'poll-option-1-remove'], true)))->toBeNull() + // The poll button is on, and a commit with a poll can't have photos. + ->and(nodeByRef($screen, 'sheet-poll')['type'])->toBe('column') + ->and(nodeByRef($screen, 'sheet-photos')['type'])->toBe('column') + ->and(sheetCanCommit($screen))->toBeFalse(); + + $screen->input('poll-option-0', 'Tabs'); + expect(sheetCanCommit($screen))->toBeFalse(); + + $screen->input('poll-option-1', ' '); + expect(sheetCanCommit($screen))->toBeFalse(); + + $screen->input('poll-option-1', 'Spaces'); + expect(sheetCanCommit($screen))->toBeTrue(); +}); + +it('adds options up to four, and takes off any past the first two', function () { + $screen = pollComposerOnLog()->tap('sheet-poll')->tap('poll-add-option')->tap('poll-add-option'); + + expect($screen->instance()->sheetPoll)->toBe(['', '', '', '']) + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'poll-add-option'))->toBeNull(); + + $screen->input('poll-option-2', 'Both')->input('poll-option-3', 'Neither')->tap('poll-option-2-remove'); + + expect($screen->instance()->sheetPoll)->toBe(['', '', 'Neither']); + + // The first two stay, whatever. + $screen->call('removePollOption', 1); + expect($screen->instance()->sheetPoll)->toBe(['', '', 'Neither']); +}); + +it('commits the poll with its options and length, as devnull takes it', function () { + pollComposerOnLog() + ->input('sheet-body', 'Tabs or spaces?') + ->tap('sheet-poll') + ->input('poll-option-0', 'Tabs') + ->input('poll-option-1', ' Spaces ') + ->select('poll-length', '3 days') + ->tap('sheet-commit') + ->assertSee('Voting opens once approved'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->body() === '{"body":"Tabs or spaces?","poll":{"options":["Tabs","Spaces"],"duration":4320}}'); +}); + +it('keeps an option to 50 characters', function () { + $screen = pollComposerOnLog()->tap('sheet-poll')->input('poll-option-0', str_repeat('a', 60)); + + expect($screen->instance()->sheetPoll[0])->toBe(str_repeat('a', 50)); +}); + +it('drops the poll with Remove poll, and with the sheet', function () { + $screen = pollComposerOnLog()->tap('sheet-poll')->tap('poll-remove'); + + expect($screen->instance()->sheetPoll)->toBeNull() + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'poll-editor'))->toBeNull() + ->and(nodeByRef($screen, 'sheet-photos')['type'])->toBe('pressable'); + + $screen->tap('sheet-poll')->dismissSheet('sheet')->press('composeInSheet'); + + expect($screen->instance()->sheetPoll)->toBeNull(); +}); + +it('offers no poll once there are photos', function () { + $screen = pollComposerOnLog()->emitNative(MediaSelected::class, ['success' => true, 'files' => [['path' => testPhoto()]]]); + + expect(nodeByRef($screen, 'sheet-poll')['type'])->toBe('column'); + + $screen->call('addPoll'); + + expect($screen->instance()->sheetPoll)->toBeNull(); +}); + +it('quotes with a poll, as the web lets you', function () { + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])])])], + 'POST /api/v1/commits' => [['data' => commitJson(['id' => 'q1', 'quoted_commit_id' => 'c1'])], 201], + ]); + + Native::visit('/log', platform: 'ios') + ->call('quoteCommit', 'c1') + ->input('sheet-body', 'Which is it?') + ->tap('sheet-poll') + ->input('poll-option-0', 'DNS') + ->input('poll-option-1', 'Not DNS') + ->tap('sheet-commit'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->body() === '{"body":"Which is it?","quoted_commit_id":"c1","poll":{"options":["DNS","Not DNS"],"duration":1440}}'); +}); diff --git a/tests/Feature/Screens/ComposerPreviewTest.php b/tests/Feature/Screens/ComposerPreviewTest.php new file mode 100644 index 0000000..c97e2fe --- /dev/null +++ b/tests/Feature/Screens/ComposerPreviewTest.php @@ -0,0 +1,108 @@ + 'http://devnull.test']); + signIn(); +}); + +afterEach(function () { + File::deleteDirectory(storage_path('app/draft-images')); +}); + +/** + * @param array{0: mixed, 1?: int}|null $preview + */ +function previewComposerOnLog(?array $preview = null): TestableComponent +{ + serveApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'body' => 'A commit to quote', 'author' => userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada'])])])], + 'POST /api/v1/commits/preview' => $preview ?? [['data' => [ + 'body' => 'Shipped 🚀 with @ada', + 'body_segments' => [['type' => 'text', 'text' => 'Shipped 🚀 with @ada']], + 'preview_segments' => null, + 'mentions' => [['username' => 'ada', 'offset' => 15, 'length' => 4]], + ]]], + ]); + + return Native::visit('/log', platform: 'ios')->press('composeInSheet'); +} + +it('shows the draft as the commit it would become, with the body devnull would keep, in place of the field', function () { + holdRefreshes(); + + $screen = previewComposerOnLog()->input('sheet-body', 'Shipped :rocket: with @ada')->tap('sheet-preview'); + + // At once, the body as written, while devnull is asked. + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-body'))->toBeNull() + ->and(nodeByRef($screen, 'sheet-preview')['props']['a11y_label'])->toBe('Edit') + ->and(treeShows(nodeByRef($screen, 'sheet-draft-preview'), 'Shipped :rocket: with @ada'))->toBeTrue(); + + finishRefreshes($screen); + + $preview = nodeByRef($screen, 'sheet-draft-preview'); + + expect(treeShows($preview, 'Shipped 🚀 with '))->toBeTrue() + ->and(paragraphRuns($screen, 'Shipped 🚀'))->toBe([['Shipped 🚀 with '], ['@ada', 'mention']]) + // Commit stays where it was, and works from here. + ->and(nodeByRef($screen, 'sheet-commit')['props']['disabled'] ?? false)->toBeFalse(); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/'.Client::PreviewPath + && $request->body() === '{"body":"Shipped :rocket: with @ada"}'); + + // Back to the field, with the draft as it was. Shown again, devnull isn't asked twice for the same draft. + $screen->tap('sheet-preview'); + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('Shipped :rocket: with @ada'); + + $screen->tap('sheet-preview'); + expect(collect(Http::recorded())->filter(fn (array $sent): bool => str_ends_with($sent[0]->url(), Client::PreviewPath)))->toHaveCount(1) + ->and(treeShows(nodeByRef($screen, 'sheet-draft-preview'), 'Shipped 🚀 with '))->toBeTrue(); +}); + +it('shows the photos, the poll and the commit being quoted in the preview', function () { + $screen = previewComposerOnLog() + ->call('quoteCommit', 'c1') + ->input('sheet-body', 'Which is it?') + ->tap('sheet-poll') + ->input('poll-option-0', 'DNS') + ->input('poll-option-1', 'Not DNS') + ->select('poll-length', '7 days') + ->tap('sheet-preview'); + + $preview = nodeByRef($screen, 'sheet-draft-preview'); + + expect(treeShows($preview, 'DNS'))->toBeTrue() + ->and(treeShows($preview, 'Not DNS'))->toBeTrue() + ->and(treeShows($preview, 'Open for 7 days once published'))->toBeTrue() + ->and(treeShows($preview, 'A commit to quote'))->toBeTrue(); + + $screen = previewComposerOnLog() + ->emitNative(MediaSelected::class, ['success' => true, 'files' => [['path' => testPhoto()]]]) + ->tap('sheet-preview'); + + $image = findNode(nodeByRef($screen, 'sheet-draft-preview'), fn (array $node): bool => ($node['type'] ?? null) === 'image'); + + expect($image['props']['src'])->toBe($screen->instance()->sheetImages[0]->path); +}); + +it('says there\'s nothing to preview yet, without asking devnull', function () { + $screen = previewComposerOnLog()->tap('sheet-preview')->assertSee('Nothing to preview yet.'); + + Http::assertNotSent(fn (Request $request): bool => str_ends_with($request->url(), Client::PreviewPath)); +}); + +it('keeps the body as written when devnull can\'t preview it', function () { + $screen = previewComposerOnLog([['message' => 'Server Error'], 500]) + ->input('sheet-body', 'Shipped :rocket:') + ->tap('sheet-preview'); + + expect(treeShows(nodeByRef($screen, 'sheet-draft-preview'), 'Shipped :rocket:'))->toBeTrue() + ->and($screen->instance()->sheetError)->toBeNull(); +}); diff --git a/tests/Feature/Screens/PollVoteTest.php b/tests/Feature/Screens/PollVoteTest.php new file mode 100644 index 0000000..abda290 --- /dev/null +++ b/tests/Feature/Screens/PollVoteTest.php @@ -0,0 +1,111 @@ + 'http://devnull.test']); + signIn(); + $this->travelTo(now()->parse('2026-10-19T12:00:00Z')); +}); + +/** + * A poll as devnull sends it to someone who hasn't voted, or with $results once they have. + * + * @param array $overrides + * @return array + */ +function openPollJson(array $overrides = [], bool $results = false): array +{ + return [ + 'id' => 'poll1', + 'closes_at' => now()->addDay()->toIso8601ZuluString(), + 'is_closed' => false, + 'has_voted' => $results, + 'total_votes' => $results ? 3 : null, + 'options' => [ + ['id' => 'o1', 'label' => 'Tabs', 'votes_count' => $results ? 2 : null, 'voted' => false, 'percentage' => $results ? 67 : null], + ['id' => 'o2', 'label' => 'Spaces', 'votes_count' => $results ? 1 : null, 'voted' => $results, 'percentage' => $results ? 33 : null], + ], + ...$overrides, + ]; +} + +/** + * The Log with Ada's commit c1 and its poll, whose vote devnull answers with $answer. + * + * @param array $commit + * @param array{0: mixed, 1?: int}|null $answer + */ +function pollOnLog(array $commit = [], ?array $answer = null): TestableComponent +{ + $ada = userJson(['id' => 'u2', 'username' => 'ada']); + + serveApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'body' => 'Tabs or spaces?', 'author' => $ada, 'poll' => openPollJson(), ...$commit])])], + 'POST /api/v1/'.Client::pollVotesPath('c1') => $answer ?? [['data' => commitJson(['id' => 'c1', 'body' => 'Tabs or spaces?', 'author' => $ada, 'poll' => openPollJson(results: true)])]], + ]); + + return Native::visit('/log', platform: 'ios'); +} + +it('picks an option, then votes, and shows the results here and in what\'s kept', function () { + $screen = pollOnLog(); + + expect(nodeByRef($screen, 'poll-vote-c1')['props'])->toMatchArray(['label' => 'Vote', 'disabled' => true]) + ->and(nodeByRef($screen, 'poll-summary-c1')['props']['text'])->toBe('1d left'); + + $screen->tap('poll-option-o2'); + + expect(nodeByRef($screen, 'poll-picked-o2'))->not->toBeEmpty() + ->and(nodeByRef($screen, 'poll-option-o2')['props']['a11y_label'])->toBe('Spaces, option 2 of 2, picked') + ->and(nodeByRef($screen, 'poll-vote-c1')['props']['disabled'] ?? false)->toBeFalse(); + + $screen->tap('poll-vote-c1'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->url() === 'http://devnull.test/api/v1/commits/c1/poll/votes' + && $request->body() === '{"option":"o2"}'); + + expect(nodeByRef($screen, 'poll-voted-o2'))->not->toBeEmpty() + ->and(nodeByRef($screen, 'poll-summary-c1')['props']['text'])->toBe('3 votes · 1d left') + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'poll-vote-c1'))->toBeNull() + ->and($screen->instance()->pollChoices)->toBe([]) + ->and(kept('log/following')['data'][0]['poll']['has_voted'])->toBeTrue(); +}); + +it('spins Vote while the vote goes, and says why devnull turned it down, keeping the pick', function () { + holdRefreshes(); + + $screen = pollOnLog(answer: [['message' => 'This poll has closed.', 'errors' => ['option' => ['This poll has closed.']]], 422]); + finishRefreshes($screen); + + $screen->tap('poll-option-o1')->tap('poll-vote-c1'); + + expect(nodeByRef($screen, 'poll-vote-c1')['props'])->toMatchArray(['loading' => true, 'disabled' => true]); + + finishRefreshes($screen)->assertNativeCalled('Dialog.Toast', fn (array $params): bool => $params['message'] === 'This poll has closed.'); + + expect(nodeByRef($screen, 'poll-vote-c1')['props'])->not->toHaveKey('loading') + ->and($screen->instance()->pollChoices)->toBe(['c1' => 'o1']); +}); + +it('offers no vote where the web offers none', function (array $commit) { + $screen = pollOnLog($commit)->call('choosePollOption', 'c1', 'o1'); + + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'poll-vote-c1'))->toBeNull() + ->and(nodeByRef($screen, 'poll-option-o1')['type'])->not->toBe('pressable') + ->and($screen->instance()->pollChoices)->toBe([]); + + $screen->call('voteOnPoll', 'c1'); + + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); +})->with([ + 'your own poll' => [['author' => userJson()]], + 'one awaiting approval' => [['status' => 'pending', 'poll' => openPollJson(['closes_at' => null])]], + 'a closed poll' => [['poll' => openPollJson(['closes_at' => '2026-10-19T11:59:00Z'])]], + 'one you voted on' => [['poll' => openPollJson(results: true)]], +]); diff --git a/tests/Pest.php b/tests/Pest.php index c18b1d2..509942d 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -797,3 +797,26 @@ function dayMarkers(TestableComponent $screen): array return $text['text'] ?? $text['props']['text'] ?? ''; }, findNodes($tree, fn (array $node): bool => str_starts_with((string) ($node['ref'] ?? ''), 'day-'))); } + +/** + * A photo as the camera or the photo library hands one over: a real image file + * of the given type and size, in a folder of its own outside the app's storage. + */ +function testPhoto(string $type = 'png', int $width = 40, int $height = 30): string +{ + $directory = sys_get_temp_dir().'/devnull-picked-'.bin2hex(random_bytes(6)); + mkdir($directory); + $path = "{$directory}/picked.{$type}"; + + $image = imagecreatetruecolor($width, $height); + imagefill($image, 0, 0, (int) imagecolorallocate($image, 4, 171, 166)); + + match ($type) { + 'jpg' => imagejpeg($image, $path), + 'webp' => imagewebp($image, $path), + 'gif' => imagegif($image, $path), + default => imagepng($image, $path), + }; + + return $path; +} From f6bed43dd8bf8b3b019fe14db8e8c18a02bf3baf Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Wed, 21 Oct 2026 01:15:53 +0100 Subject: [PATCH 2/2] Pin the composer's toolbar to the bottom of the sheet The tools, stash@{0} and the counter now sit in a bar under the scroll view instead of at the end of it, so they stay just above the keyboard while you write, and above the home indicator when it's down. Co-Authored-By: Claude Opus 5.5 --- .../native/partials/commit-sheet.blade.php | 34 ++++++++++--------- .../native/partials/composer-tools.blade.php | 6 ++-- tests/Feature/Screens/ComposerPhotosTest.php | 9 +++++ 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php index f24e707..421870f 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -5,7 +5,7 @@ colour; on Android they're text buttons, as in Material 3's full-screen dialog header. The title is centred. Cancel closes it as a swipe does, keeping the draft. If devnull says no, why shows here and the draft stays. The Log's compose button opens the same composer for a new commit, without the card. A draft stashed last time comes - back in it, with the web's stash@{0} and drop on the left of the counter's row. + back in it, with the web's stash@{0} and drop beside the counter, in the toolbar at the bottom. Report commit, in someone else's published commit's More menu, opens it as the web's Report dialog: the private, not-hidden line, the commit's body clamped to three lines, the four reasons, optional details, and Send report, @@ -19,10 +19,11 @@ A commit's page opens its reply here too, as "Reply to @handle", with the commit being answered as a static card over the field, for the photos and the preview its reply bar has no room for. Commit says Reply there. - Under what's being written come its photos, then the poll editor, then a row of tools: photos, the camera, a poll - (not on a reply), and the preview, which shows the draft as the commit it would become in place of the field (see - AttachesToCommits). An issue draft has none of them, as on the web. With photos, Commit spins while they upload, - and no body at all will do. + Under what's being written come its photos and the poll editor. A toolbar is pinned to the bottom of the sheet, so + it stays just above the keyboard: the tools on the left, photos, the camera, a poll (not on a reply) and the + preview, which shows the draft as the commit it would become in place of the field (see AttachesToCommits), and + stash@{0} and the counter on the right. An issue draft has no tools, as on the web. With photos, Commit spins while + they upload, and no body at all will do. It's full height for the forms, and short for the receipts. It's always on the screen, shown or not, so iOS can slide it away and say when it's gone. @@ -95,19 +96,20 @@ class="w-full text-theme-ink" @endif @endif - @unless ($issueCommand) - @include('native.partials.composer-tools') - @endunless - - - @if ($sheetRestored) - @include('native.partials.stash-label', ['ref' => 'sheet-stash', 'drop' => 'dropSheetStash']) - - @endif - @include('native.partials.body-counter', ['body' => $sheetBody, 'ref' => 'sheet-budget', 'literal' => $issueCommand !== null]) - + {{-- Pinned under the scroll view, so it rides just above the keyboard, and above the home indicator without it. --}} + + + @unless ($issueCommand) + @include('native.partials.composer-tools') + @endunless + + @if ($sheetRestored) + @include('native.partials.stash-label', ['ref' => 'sheet-stash', 'drop' => 'dropSheetStash']) + @endif + @include('native.partials.body-counter', ['body' => $sheetBody, 'ref' => 'sheet-budget', 'literal' => $issueCommand !== null]) + @elseif ($sheetView === 'report') diff --git a/resources/views/native/partials/composer-tools.blade.php b/resources/views/native/partials/composer-tools.blade.php index 7c13af6..99d230e 100644 --- a/resources/views/native/partials/composer-tools.blade.php +++ b/resources/views/native/partials/composer-tools.blade.php @@ -1,10 +1,10 @@ {{-- - The composer's tools, on a row of their own under what's being written, as the web's composer wraps them on a - phone: photos from the library, a photo from the camera, a poll, and the preview. Up to three photos, or a poll, + The composer's tools, on the left of the toolbar pinned to the bottom of the sheet: photos from the library, a photo + from the camera, a poll, and the preview. Up to three photos, or a poll, not both, as on the web, so each greys out while the other is there; a reply has no poll at all. --}} @php($photosFull = count($sheetImages) >= \App\Devnull\DraftImages::MaxImages) - + @include('native.partials.composer-tool', ['ref' => 'sheet-photos', 'icon' => \App\Icons\Lucide::ImagePlus, 'label' => 'Attach up to 3 photos', 'press' => 'pickPhotos', 'disabled' => $sheetPoll !== null || $photosFull || $sheetPosting]) @include('native.partials.composer-tool', ['ref' => 'sheet-camera', 'icon' => \App\Icons\Lucide::Camera, 'label' => 'Take a photo', 'press' => 'takePhoto', 'disabled' => $sheetPoll !== null || $photosFull || $sheetPosting]) @unless ($sheetView === 'reply') diff --git a/tests/Feature/Screens/ComposerPhotosTest.php b/tests/Feature/Screens/ComposerPhotosTest.php index b023fa0..997b608 100644 --- a/tests/Feature/Screens/ComposerPhotosTest.php +++ b/tests/Feature/Screens/ComposerPhotosTest.php @@ -74,6 +74,15 @@ function isTool(TestableComponent $screen, string $ref): bool return nodeByRef($screen, $ref)['type'] === 'pressable'; } +it('pins the toolbar under the scroll view, so it stays just above the keyboard', function () { + $screen = composerOnLog(); + $toolbar = nodeByRef($screen, 'sheet-toolbar'); + + expect(findNode(nodeByRef($screen, 'sheet-composer'), fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-toolbar'))->toBeNull() + ->and(findNode($toolbar, fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-tools'))->not->toBeNull() + ->and(findNode($toolbar, fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-preview'))->not->toBeNull(); +}); + it('opens the photo library for up to three photos, then for what\'s left', function () { $screen = composerOnLog()->tap('sheet-photos');