From 63993fe1c845344f53cafa6dfc101bf1461ba23d Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sun, 18 Oct 2026 17:52:57 +0200 Subject: [PATCH] feat: allow images and a poll on the same commit Co-Authored-By: Claude Opus 5.5 --- KNOWLEDGE_BASE.md | 6 +-- app/Http/Requests/CreateCommitRequest.php | 14 ++++- .../Requests/CreateScheduledCommitRequest.php | 2 +- resources/js/components/commit-composer.tsx | 30 ++++------- tests/Feature/Controllers/CommitPollsTest.php | 28 ++++++++-- tests/Feature/ScheduledCommitsTest.php | 20 ++++++- tests/Unit/ComposerDraft.test.tsx | 52 +++++++++++++++++-- 7 files changed, 116 insertions(+), 36 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 729760e19..5ee78aba9 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -140,7 +140,7 @@ Sources: [stash hook](resources/js/hooks/use-stash.ts), [image storage](resource ### Images and descriptions -Attach up to **three JPEG, PNG, or WebP images** to a commit or reply. Each image can be at most **10 MB** and **6,000 pixels in either dimension**. Add an image description of up to **1,000 characters** for accessibility. Images can be posted without accompanying text. Image attachments and a poll cannot be combined in the same commit. Open an attached photo to view it in the photo viewer and move through the commit's images. +Attach up to **three JPEG, PNG, or WebP images** to a commit or reply. Each image can be at most **10 MB** and **6,000 pixels in either dimension**. Add an image description of up to **1,000 characters** for accessibility. Images can be posted without accompanying text, unless the commit also has a poll, since the text is the poll's question. Open an attached photo to view it in the photo viewer and move through the commit's images. Sources: [image picker](resources/js/components/commit-image-picker.tsx), [image limits](app/Actions/CreateCommitImages.php), [attachment validation](app/Http/Requests/CreateCommitRequest.php), [photo controller](app/Http/Controllers/CommitPhotoController.php). @@ -170,7 +170,7 @@ Sources: [quote validation](app/Http/Requests/CreateCommitRequest.php), [quoted ### Polls and voting -Add a poll to a top-level commit with **two to four distinct options**, each up to **50 characters**, and a duration of **one hour, one day, three days, or seven days**. Polls cannot accompany images or be added to replies. Voting opens when the commit is approved, so time spent pending does not consume the voting period. +Add a poll to a top-level commit with **two to four distinct options**, each up to **50 characters**, and a duration of **one hour, one day, three days, or seven days**. A poll can go together with images, but it cannot be added to a reply. Voting opens when the commit is approved, so time spent pending does not consume the voting period. Signed-in readers can choose one option and vote once while an approved, visible poll is open. The author cannot vote in their own poll. Results are visible to the author and people who have voted, and to everyone once the poll closes. Other people's individual votes are not revealed. @@ -178,7 +178,7 @@ Sources: [poll editor](resources/js/components/poll-editor.tsx), [poll results]( ### Scheduled commits -Use the composer's scheduling control to submit a top-level commit later. Pick a future time within **30 days**; you can have at most **10 outstanding scheduled commits**. Scheduled posts can carry images or a poll under the normal attachment rules, but replies cannot be scheduled. The scheduled-commits page is private to you and lets you inspect or cancel queued posts. +Use the composer's scheduling control to submit a top-level commit later. Pick a future time within **30 days**; you can have at most **10 outstanding scheduled commits**. Scheduled posts can carry images, a poll, or both under the normal attachment rules, but replies cannot be scheduled. The scheduled-commits page is private to you and lets you inspect or cancel queued posts. The scheduler checks due posts every minute. Scheduling submits the post for the normal publishing and moderation flow; it is not a promise that an approved public post will appear at the exact second selected. To change a queued post, cancel it and schedule a replacement. diff --git a/app/Http/Requests/CreateCommitRequest.php b/app/Http/Requests/CreateCommitRequest.php index 7b59359ea..b50e226ce 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -30,6 +30,16 @@ final class CreateCommitRequest extends FormRequest */ public const array BODY_WITH_IMAGES_RULES = ['nullable', 'string', 'max:'.Commit::MAX_BODY_LENGTH]; + /** + * The body of a poll is its question, so a poll needs a full body even when images come with it. + * + * @return list + */ + public static function bodyRules(FormRequest $request): array + { + return $request->hasFile('images') && ! $request->filled('poll') ? self::BODY_WITH_IMAGES_RULES : self::BODY_RULES; + } + /** * Blank lines are collapsed before the body is measured, by every request that takes a body with BODY_RULES. */ @@ -48,7 +58,7 @@ public static function normalizeBody(FormRequest $request): void public static function attachmentRules(): array { return [ - 'poll' => ['nullable', 'array:options,duration', 'prohibits:parent_id,images'], + 'poll' => ['nullable', 'array:options,duration', 'prohibits:parent_id'], 'poll.options' => ['required_with:poll', 'list', 'min:'.NewPoll::MinOptions, 'max:'.NewPoll::MaxOptions], 'poll.options.*' => ['required', 'string', 'max:'.NewPoll::MaxOptionLength, 'distinct:ignore_case'], 'poll.duration' => ['required_with:poll', Rule::enum(PollDuration::class)], @@ -81,7 +91,7 @@ public function rules(): array $body = $this->input('body'); return [ - 'body' => $this->hasFile('images') ? self::BODY_WITH_IMAGES_RULES : self::BODY_RULES, + 'body' => self::bodyRules($this), 'quoted_commit_id' => ['nullable', 'uuid', 'prohibits:parent_id', new VisibleCommit($user, 'That commit is no longer available to quote.')], 'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])], ...self::attachmentRules(), diff --git a/app/Http/Requests/CreateScheduledCommitRequest.php b/app/Http/Requests/CreateScheduledCommitRequest.php index 228b59316..db5e2182d 100644 --- a/app/Http/Requests/CreateScheduledCommitRequest.php +++ b/app/Http/Requests/CreateScheduledCommitRequest.php @@ -33,7 +33,7 @@ public function rules(): array $body = $this->input('body'); return [ - 'body' => $this->hasFile('images') ? CreateCommitRequest::BODY_WITH_IMAGES_RULES : CreateCommitRequest::BODY_RULES, + 'body' => CreateCommitRequest::bodyRules($this), 'scheduled_at' => ['required', 'date_format:Y-m-d\TH:i:sP', 'after:now', 'before_or_equal:'.now()->addDays(30)->toIso8601String(), new ScheduledCommitLimit($user)], 'parent_id' => ['prohibited'], 'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])], diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index e20801eb9..645a9e9c6 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -125,7 +125,9 @@ function CommitComposerBody({ setIsScheduling, } = schedule; const hasImages = attachments.images.length > 0; - const isFilled = hasImages || characterCount(body.trim()) >= minBodyLength; + const isFilled = + (hasImages && poll === null) || + characterCount(body.trim()) >= minBodyLength; const error = errors.body ?? errors.link_preview_url; const scheduleError = errors.scheduled_at ?? @@ -137,10 +139,7 @@ function CommitComposerBody({ const isIssue = issue.command !== null; return ( -
+
{attachments.isDraggingFiles && ( )} @@ -178,9 +177,7 @@ function CommitComposerBody({ rows={1} value={body} onValueChange={issue.change} - onPaste={ - poll === null ? attachments.onPaste : undefined - } + onPaste={attachments.onPaste} onKeyDown={(event) => preview.handleShortcut(event) || onKeyDown(event) @@ -332,25 +329,17 @@ function CommitComposerBody({ <> - +