diff --git a/README.md b/README.md index 0a5dfe6..b0eeb21 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ For now the app tracks unreleased branches, which Composer pulls straight from G - button variants named after a theme colour ([#137](https://github.com/NativePHP/mobile-ui/pull/137)), - a sticky header for lists ([#138](https://github.com/NativePHP/mobile-ui/pull/138)), - a pager header that scrolls with the page showing ([#139](https://github.com/NativePHP/mobile-ui/pull/139)). +- `nativephp/mobile-camera` on `fix/present-over-sheets`, the plugin's `main` plus presenting the camera and the photo library from the topmost view controller, so they open over the composer sheet ([NativePHP/mobile-camera#17](https://github.com/NativePHP/mobile-camera/pull/17)). `main` also has the HEIC to JPEG conversion and the persistent storage fix that 1.0.4 lacks. Once those are released, the app goes back to tagged versions and the combined branches can be deleted. diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 9a79c6e..c4da605 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,6 +67,11 @@ */ public const string PasteisModePath = 'me/pasteis-mode'; + /** + * Where a draft is shown as the commit it would become, as the web's composer previews it. + */ + public const string PreviewPath = 'commits/preview'; + /** * Your Remotes inbox, and where Remotes are switched on and off and their settings saved, as on the web. */ @@ -87,6 +93,14 @@ public function __construct(private DeviceKey $deviceKey, private string $baseUrl, private ResponseCache $cache) {} + /** + * Where a vote on a commit's poll goes. devnull answers with the commit, whose poll then shows its results. + */ + public static function pollVotesPath(string $id): string + { + return 'commits/'.rawurlencode($id).'/poll/votes'; + } + /** * Where a Remotes thread lives: with a person, by their username, or an org, by its id. Its messages are pushed * to the same path, and its read, typing and stash live beside it. @@ -198,17 +212,57 @@ public function replies(string $id, ?string $cursor = null): Page /** * Commits, as a reply to $parentId or quoting $quotedCommitId, as the web's * composer, reply box and Quote do. devnull takes one or the other, not both. + * A new commit or a quote may start a poll (see PollDraft::payload()). + * + * @param array{options: list, 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)); @@ -497,7 +551,7 @@ public static function sendSigned(SignedRequest $request): Response $pending = $request->body === '' ? $pending->withRequestMiddleware(fn (RequestInterface $request): RequestInterface => $request->withoutHeader('Content-Type')) - : $pending->withBody($request->body, 'application/json'); + : $pending->withBody($request->body, $request->contentType); return $pending->send($request->method, $request->url); } @@ -569,6 +623,24 @@ private function send(string $method, string $path, array $query = [], ?array $p return $response; } + /** + * What a commit sends, leaving out what it doesn't have. + * + * @param list $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 !== []); + } + /** * Whether a successful response is devnull's API answering, and not a page * served in its place, such as the HTML that dev-null.sh answers every URL @@ -601,10 +673,10 @@ private static function body(?array $payload): string /** * @return array{X-Devnull-Timestamp: string, X-Devnull-Nonce: string, X-SSH-Signature: string} */ - private function sign(string $method, string $target, string $body): array + private function sign(string $method, string $target, string $body, string $namespace = RequestSigner::Namespace): array { return (new RequestSigner($this->deviceKey->current())) - ->headers($method, $target, $body, CarbonImmutable::now()->getTimestamp(), RequestSigner::nonce()); + ->headers($method, $target, $body, CarbonImmutable::now()->getTimestamp(), RequestSigner::nonce(), $namespace); } /** diff --git a/app/Devnull/Data/Commit.php b/app/Devnull/Data/Commit.php index a5312b8..dd0cc7b 100644 --- a/app/Devnull/Data/Commit.php +++ b/app/Devnull/Data/Commit.php @@ -156,6 +156,14 @@ public function withPinned(bool $isPinned): self return new self(...[...get_object_vars($this), 'isPinned' => $isPinned]); } + /** + * This commit with the poll a fresher copy of it has, as after you voted. + */ + public function withPoll(?Poll $poll): self + { + return new self(...[...get_object_vars($this), 'poll' => $poll]); + } + /** * This commit with the star and pastéis state of a fresher copy of * it, keeping everything else, such as the parent a list loaded with it. @@ -294,6 +302,22 @@ public function isApproved(): bool return $this->status === 'approved'; } + /** + * Whether $viewer may vote on the commit's poll, as the web decides it: someone else's published commit, whose + * poll is open and hasn't had their vote yet. + */ + public function canBeVotedOnBy(?User $viewer): bool + { + return $viewer !== null + && $this->poll !== null + && $this->isApproved() + && $this->author->id !== $viewer->id + && $this->poll->closesAt !== null + && ! $this->poll->hasVoted + && ! $this->poll->showsResults() + && ! $this->poll->hasExpired(); + } + /** * The author's lane, or Rail Gray for a deleted commit, which has no author. */ diff --git a/app/Devnull/Data/Draft.php b/app/Devnull/Data/Draft.php new file mode 100644 index 0000000..fba3ebc --- /dev/null +++ b/app/Devnull/Data/Draft.php @@ -0,0 +1,57 @@ + $images + * @param list|null $poll + */ + public static function canCommit(string $body, array $images = [], ?array $poll = null): bool + { + return (Commit::fits($body) || ($images !== [] && trim($body) === '')) + && ($poll === null || PollDraft::isComplete($poll)); + } + + /** + * The draft as the commit it would become, by you, with what devnull sent + * back for its body when it's for this draft: the body as it would be + * kept, its code and mentions. Until then it's the body as written. + * + * @param array|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, from_devnull: bool} */ - public function handle(string $url, array $headers, string $method = 'GET', string $body = '', bool $withImages = false, ?int $pruneBefore = null): array + public function handle(string $url, array $headers, string $method = 'GET', string $body = '', bool $withImages = false, ?int $pruneBefore = null, string $contentType = 'application/json'): array { if ($pruneBefore !== null) { DownloadImages::prune($pruneBefore); } 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' => '', 'from_devnull' => false]; } 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 c1b9230..1adcd54 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -252,6 +252,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 starred or unstarred a commit. A commit you starred is the newest, * so it goes on top of your starred commits; one you unstarred leaves them, diff --git a/app/Devnull/RowInputs.php b/app/Devnull/RowInputs.php index eabea40..70a6129 100644 --- a/app/Devnull/RowInputs.php +++ b/app/Devnull/RowInputs.php @@ -6,7 +6,8 @@ /** * What a commit row and a thread reply show besides their partial's data, for - * RowCache to keep them against: what the partials read from the screen, and + * RowCache to keep them against: what the partials read from the screen, such + * as the option you picked on a poll and whether your vote is on its way, and * what they work out from the time. * * Images aren't here: ImageCache draws each URL the same way for as long as @@ -35,6 +36,7 @@ public static function for(Commit $commit, array $scope): array 'tombstones' => $scope['tombstones'] ?? false, 'tombstoneReplies' => array_key_exists($commit->id, $scope['tombstoneReplies'] ?? []), 'shown' => [self::changing($commit), $commit->quotedCommit === null ? null : self::changing($commit->quotedCommit)], + 'poll' => [$scope['pollChoices'][$commit->id] ?? null, in_array($commit->id, $scope['voting'] ?? [], true)], ]; } 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 2da39fd..e209092 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -66,6 +66,12 @@ enum Lucide case SmilePlus; case Lightbulb; case Bug; + 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; @@ -158,6 +164,12 @@ public function custom(bool $filled = false): ?string self::SmilePlus => 'smile-plus', self::Lightbulb => 'lightbulb', self::Bug => 'bug', + 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 d7c3b38..20c1fc7 100644 --- a/app/NativeComponents/CommitDetail.php +++ b/app/NativeComponents/CommitDetail.php @@ -36,6 +36,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 { @@ -294,6 +298,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..044bb25 --- /dev/null +++ b/app/NativeComponents/Concerns/AttachesToCommits.php @@ -0,0 +1,336 @@ + + */ + 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; + + /** + * @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->sheetPosting) { + 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->sheetPosting = 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->sheetPosting = false; + $commit = is_array($json['data'] ?? null) ? $json['data'] : []; + + app(ResponseCache::class)->commitAdded($commit); + $sent(Commit::fromArray($commit)); + }, + function (string $message): void { + $this->sheetPosting = 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->sheetPosting) { + 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->sheetPosting && 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 224280b..397ade4 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; @@ -27,11 +28,19 @@ * A new commit's draft can become a `/feature` or `/bug` issue, as on the web * (see DraftsIssues). * + * 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; use DraftsIssues; /** @@ -45,8 +54,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'; @@ -133,6 +142,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. */ @@ -173,34 +202,43 @@ 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 (! $this->sheetOpen || $this->sheetPosting || $stashName === null || $this->sheetIssue !== null || ! Commit::fits($body)) { + if (! $this->sheetOpen || $this->sheetPosting || $stashName === null || $this->sheetIssue !== null || ! Draft::canCommit($body, $this->sheetImages, $this->sheetPoll)) { + return; + } + + $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); + return; } $this->sheetPosting = true; try { - $commit = $this->attemptInSheet(fn (Client $devnull): Commit => $devnull->createCommit($body, quotedCommitId: $quoted?->id)); + $commit = $this->attemptInSheet(fn (Client $devnull): Commit => $devnull->createCommit($body, $parent?->id, $quoted?->id, $this->sheetPollPayload())); } finally { $this->sheetPosting = false; } - if ($commit === null) { - return; + if ($commit !== null) { + $sent($commit); } - - app(Stash::class)->put($this->me()?->id, $stashName, ''); - $this->sheetOpen = false; - $this->showCommitted($commit); } /** @@ -217,7 +255,10 @@ public function hideCommitSheet(): void */ public function commitSheetClosed(): void { + $view = $this->sheetView; + $this->forgetCommitSheet(); + $this->commitSheetGone($view); } /** @@ -228,6 +269,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(); @@ -252,6 +301,7 @@ private function forgetCommitSheet(): void $this->sheetBody = ''; $this->sheetRestored = false; $this->sheetError = null; + $this->forgetAttachments(); $this->forgetIssueDraft(); } @@ -263,6 +313,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 cc8dd71..c13f821 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -22,8 +22,9 @@ * Reply, and the More menu's Save commit, Share link, Pin to profile, 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. + * 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 +32,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 e1541dc..fa7fa00 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, false, null, $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 0a3fcf6..297eef0 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.2", + "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 5f376a5..52cc95a 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": "03e37596dd8bb9cc83599a7f836d1f05", + "content-hash": "dd9f8ac9c92e7eb0fa3d9f0ecacf7bee", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2893,6 +2893,70 @@ }, "time": "2026-10-21T16:15:09+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 9c43dfb..3273f10 100644 --- a/resources/views/native/commit-detail.blade.php +++ b/resources/views/native/commit-detail.blade.php @@ -110,7 +110,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. --}} 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 bd01544..00ce8fb 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -6,7 +6,7 @@ handle over the sheet, as iOS does, so the bar starts at the top there too. 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, @@ -17,23 +17,33 @@ field, its placeholder in it, and Open issue (the same check) in Commit's place in the bar. Opened, the sheet says so, with View issue and Done. + 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 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. --}} @if ($sheetCommit || in_array($sheetView, ['compose', 'issued'], true)) - @if (in_array($sheetView, ['quote', 'compose'], true)) + @if (in_array($sheetView, ['quote', 'compose', 'reply'], true)) @php($quoting = $sheetView === 'quote') + @php($replying = $sheetView === 'reply') @php($issueCommand = $sheetIssue === null ? null : \App\Devnull\IssueCommand::from($sheetIssue)) - @php($fits = \App\Devnull\Data\Commit::fits($sheetBody)) + @php($canCommit = \App\Devnull\Data\Draft::canCommit($sheetBody, $sheetImages, $replying ? null : $sheetPoll)) @include('native.partials.sheet-bar-button', ['ref' => 'sheet-cancel', 'icon' => \App\Icons\Lucide::X, 'label' => 'Cancel', 'hint' => 'Keeps your draft for next time', 'press' => 'hideCommitSheet']) - {{ $quoting ? 'Quote '.$sheetCommit->author->handle() : 'New commit' }} + {{ match (true) { $quoting => 'Quote '.$sheetCommit->author->handle(), $replying => 'Reply to '.$sheetCommit->author->handle(), default => 'New commit' } }} @if ($issueCommand) @include('native.partials.sheet-bar-button', ['ref' => 'sheet-open-issue', 'icon' => \App\Icons\Lucide::Check, 'label' => 'Open issue', 'main' => true, 'loading' => $openingIssue, 'press' => $openingIssue || ! \App\Devnull\IssueCommand::fits($sheetBody) ? null : 'openIssueFromSheet']) @else - @include('native.partials.sheet-bar-button', ['ref' => 'sheet-commit', 'icon' => \App\Icons\Lucide::Check, 'label' => 'Commit', 'main' => true, 'loading' => $sheetPosting, 'press' => $sheetPosting || ! $fits ? null : 'commitFromSheet']) + @include('native.partials.sheet-bar-button', ['ref' => 'sheet-commit', 'icon' => \App\Icons\Lucide::Check, 'label' => $replying ? 'Reply' : 'Commit', 'main' => true, 'loading' => $sheetPosting, 'press' => $sheetPosting || ! $canCommit ? null : 'commitFromSheet']) @endif @@ -47,41 +57,60 @@ @include('native.partials.issue-badge', ['command' => $issueCommand]) @endif - - - @if (! $issueCommand && \App\Devnull\Data\CommitBody::hasOversizedUrl($sheetBody)) - Each link must not be greater than {{ \App\Devnull\Data\BodyLink::MaxLength }} characters. + @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]) - @elseif (! $issueCommand) - @include('native.partials.issue-suggestions', [ - 'suggestions' => $sheetTyped ? \App\Devnull\IssueCommand::suggested($sheetBody) : [], - 'hint' => $issueHintsDismissed ? null : \App\Devnull\IssueCommand::hinted($sheetBody), - ]) - @endif + @if ($previewing) + @include('native.partials.composer-preview') + @else + - - @if ($sheetRestored) - @include('native.partials.stash-label', ['ref' => 'sheet-stash', 'drop' => 'dropSheetStash']) - + @if (! $issueCommand && \App\Devnull\Data\CommitBody::hasOversizedUrl($sheetBody)) + Each link must not be greater than {{ \App\Devnull\Data\BodyLink::MaxLength }} characters. @endif - @include('native.partials.body-counter', ['body' => $sheetBody, 'ref' => 'sheet-budget', 'literal' => $issueCommand !== null]) - + + @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]) + @elseif (! $replying && ! $issueCommand) + @include('native.partials.issue-suggestions', [ + 'suggestions' => $sheetTyped ? \App\Devnull\IssueCommand::suggested($sheetBody) : [], + 'hint' => $issueHintsDismissed ? null : \App\Devnull\IssueCommand::hinted($sheetBody), + ]) + @endif + @endif + + {{-- 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-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..99d230e --- /dev/null +++ b/resources/views/native/partials/composer-tools.blade.php @@ -0,0 +1,14 @@ +{{-- + 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') + @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 || $sheetPosting]) + @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 532ea5f..e8c714a 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..0b7b8c6 --- /dev/null +++ b/tests/Feature/Screens/ComposerPhotosTest.php @@ -0,0 +1,288 @@ + '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('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'); + + $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(sheetButtonEnabled($screen, 'sheet-commit'))->toBeTrue(); + + $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'); + + // The check turns into a spinner, and can't be tapped again. + expect(sheetButtonEnabled($screen, 'sheet-commit'))->toBeFalse() + ->and(findNode(sheetButtonCircle($screen, 'sheet-commit'), fn (array $node): bool => ($node['type'] ?? null) === 'activity_indicator'))->not->toBeNull() + ->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(sheetButtonEnabled($screen, 'sheet-commit'))->toBeTrue() + ->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']['a11y_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..f83488d --- /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 sheetButtonEnabled($screen, 'sheet-commit'); +} + +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..762efc9 --- /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(sheetButtonEnabled($screen, 'sheet-commit'))->toBeTrue(); + + 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 85319cb..9ea9bd6 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -825,6 +825,29 @@ function dayMarkers(TestableComponent $screen): array }, 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; +} + /** * A Remotes message as devnull sends it to someone in the conversation. Its segments follow its body unless a test * sends its own.