From 42a1e4f7a8b154d44acb9f58515285ddf48ff8e5 Mon Sep 17 00:00:00 2001 From: Sheraz Manzoor Date: Fri, 6 Nov 2026 19:38:25 +0500 Subject: [PATCH 1/8] feat: add commit recommit endpoint --- .../Controllers/CommitRecommitController.php | 44 +++++++++++++++++++ .../Requests/CreateCommitRecommitRequest.php | 39 ++++++++++++++++ routes/web.php | 5 +++ 3 files changed, 88 insertions(+) create mode 100644 app/Http/Controllers/CommitRecommitController.php create mode 100644 app/Http/Requests/CreateCommitRecommitRequest.php diff --git a/app/Http/Controllers/CommitRecommitController.php b/app/Http/Controllers/CommitRecommitController.php new file mode 100644 index 000000000..7077105e9 --- /dev/null +++ b/app/Http/Controllers/CommitRecommitController.php @@ -0,0 +1,44 @@ +unlockedBadgeNames($user); + $recommit = $action->handle($user, '', quotedCommit: $commit, linkPreview: false); + $unlocked = $achievements->unlockedBadgeNames($user)->diff($before)->values(); + + Inertia::flash('committed', [ + 'id' => $recommit->id, + 'body' => $recommit->body, + 'author' => ['handle' => $user->handle], + ]); + + if ($unlocked->isNotEmpty()) { + Inertia::flash('achievementsUnlocked', $unlocked->all()); + } + + if ($recommit->status === CommitStatus::Pending) { + Inertia::flash('toast', [ + 'type' => 'info', + 'message' => 'Your commit is pending review. Everyone will see it once it is approved.', + ]); + } + + return back(); + } +} diff --git a/app/Http/Requests/CreateCommitRecommitRequest.php b/app/Http/Requests/CreateCommitRecommitRequest.php new file mode 100644 index 000000000..be9aeaa81 --- /dev/null +++ b/app/Http/Requests/CreateCommitRecommitRequest.php @@ -0,0 +1,39 @@ +user(); + assert($user instanceof User); + + $commit = $this->route('commit'); + assert($commit instanceof Commit); + + if (! $commit->isVisibleTo($user)) { + return Response::denyAsNotFound(); + } + + return $commit->status === CommitStatus::Approved && ! $commit->isOwnedBy($user) + ? Response::allow() + : Response::deny(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/routes/web.php b/routes/web.php index cdeb1dd6c..0579c48ae 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,6 +27,7 @@ use App\Http\Controllers\CommitPollController; use App\Http\Controllers\CommitPollVoteController; use App\Http\Controllers\CommitPreviewController; +use App\Http\Controllers\CommitRecommitController; use App\Http\Controllers\CommitRejectionController; use App\Http\Controllers\CommitReplyController; use App\Http\Controllers\CommitReportController; @@ -447,6 +448,10 @@ Route::post('commits', [CommitController::class, 'store']) ->middleware([PreventPostingInTimeout::class, RequireCommitRules::class, ThrottleCommits::class]) ->name('commit.store'); + Route::post('commits/{commit}/recommit', [CommitRecommitController::class, 'store']) + ->middleware([PreventPostingInTimeout::class, RequireCommitRules::class, ThrottleCommits::class]) + ->whereUuid('commit') + ->name('commit-recommit.store'); Route::post('commits/preview', [CommitPreviewController::class, 'store']) ->name('commit-preview.store'); Route::post('commits/{commit}/translation', [CommitTranslationController::class, 'store']) From 6dc6577d4117b7364be4001f1d5e476bba6db6dc Mon Sep 17 00:00:00 2001 From: Sheraz Manzoor Date: Fri, 6 Nov 2026 19:38:38 +0500 Subject: [PATCH 2/8] feat: add recommit action to commit cards --- KNOWLEDGE_BASE.md | 8 ++- resources/js/components/commit-card.tsx | 5 ++ .../js/components/commit-recommit-button.tsx | 60 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 resources/js/components/commit-recommit-button.tsx diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 4f6391d9f..f2d9ce5da 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -326,11 +326,13 @@ Sources: [related commits query](app/Queries/RelatedCommitsQuery.php), [related Quote a visible commit to publish a new top-level commit with your own commentary and an embedded card pointing to the original. Quotes follow normal commit limits, image support, and moderation. The quote composer has a preview and link-card choice, but no poll or scheduling. Quoting is different from replying: the quote appears in logs as its own post, and it is also listed among the original's replies and counted in its reply count. The original author is notified after approval, except for self-quotes. -The embedded original only shows content the viewer may see. Deleted originals appear as `[deleted]`; otherwise-inaccessible originals appear as `[unavailable]` without exposing their author or body. There is no comment-free repost action. +Use **Recommit** to share an approved commit without adding commentary. It creates a top-level commit under your profile with the original embedded, so it appears in logs and gives other readers a way to discover you. It follows the normal moderation and commit rate limits, and is listed with the original's replies. -Sources: [quote validation](app/Http/Requests/CreateCommitRequest.php), [quoted commit card](resources/js/components/quoted-commit.tsx), [quote notifications](app/Actions/CreateQuoteNotifications.php). +The embedded original only shows content the viewer may see. Deleted originals appear as `[deleted]`; otherwise-inaccessible originals appear as `[unavailable]` without exposing their author or body. -Livestream history: on [day two at 7:31:27](https://www.youtube.com/watch?v=nfmVdXZfzUE&t=27087s), the team explained why plain reposts were unnecessary: quoting already existed and required the person sharing to write something themselves. +Sources: [quote validation](app/Http/Requests/CreateCommitRequest.php), [recommit authorization](app/Http/Requests/CreateCommitRecommitRequest.php), [recommit action](resources/js/components/commit-recommit-button.tsx), [quoted commit card](resources/js/components/quoted-commit.tsx), [quote notifications](app/Actions/CreateQuoteNotifications.php). + +Livestream history: before Recommit was added, on [day two at 7:31:27](https://www.youtube.com/watch?v=nfmVdXZfzUE&t=27087s), the team explained why plain reposts seemed unnecessary: quoting already existed and required the person sharing to write something themselves. ### Polls and voting diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 142d7e0de..9341ee843 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -9,6 +9,7 @@ import { CommitImages } from '@/components/commit-images'; import { CommitPastelButton } from '@/components/commit-pastel-button'; import { CommitPoll } from '@/components/commit-poll'; import { CommitQuoteButton } from '@/components/commit-quote-button'; +import { CommitRecommitButton } from '@/components/commit-recommit-button'; import { CommitSecondaryActions } from '@/components/commit-secondary-actions'; import { CommitStarButton } from '@/components/commit-star-button'; import { CommitStatusBadge } from '@/components/commit-status-badge'; @@ -537,6 +538,7 @@ export function CommitCard({ commit={commit} keyShortcut="q" /> + {translation.button} + {translation.button} {threadActions} diff --git a/resources/js/components/commit-recommit-button.tsx b/resources/js/components/commit-recommit-button.tsx new file mode 100644 index 000000000..abf64d7c7 --- /dev/null +++ b/resources/js/components/commit-recommit-button.tsx @@ -0,0 +1,60 @@ +import { router, usePage } from '@inertiajs/react'; +import { Repeat2 } from 'lucide-react'; +import { useState } from 'react'; +import { store } from '@/actions/App/Http/Controllers/CommitRecommitController'; +import { ActionTooltip } from '@/components/action-tooltip'; +import { useOwnsCommit } from '@/hooks/use-owns-commit'; +import { signIn } from '@/lib/auth'; +import { cn } from '@/lib/utils'; +import type { Commit } from '@/types'; + +export function CommitRecommitButton({ + commit, + className, +}: { + commit: Commit; + className?: string; +}) { + const { auth } = usePage().props; + const ownsCommit = useOwnsCommit(); + const [isRecommitting, setIsRecommitting] = useState(false); + + if (commit.status !== 'approved' || ownsCommit(commit)) { + return null; + } + + return ( + + + + ); +} From 48b1301ce338c6cc556dc4822fe9fa129817222a Mon Sep 17 00:00:00 2001 From: Sheraz Manzoor Date: Fri, 6 Nov 2026 19:52:44 +0500 Subject: [PATCH 3/8] fix: show and persist recommit state --- .../Controllers/CommitRecommitController.php | 13 ++- .../Requests/CreateCommitRecommitRequest.php | 14 ++- app/Models/Commit.php | 1 + app/Queries/CommitBuilder.php | 24 +++++ resources/js/components/commit-card.tsx | 10 ++- .../js/components/commit-recommit-button.tsx | 90 ++++++++++++++----- resources/js/types/commit.ts | 1 + .../CommitRecommitControllerTest.php | 51 +++++++++++ 8 files changed, 169 insertions(+), 35 deletions(-) create mode 100644 tests/Feature/Controllers/CommitRecommitControllerTest.php diff --git a/app/Http/Controllers/CommitRecommitController.php b/app/Http/Controllers/CommitRecommitController.php index 7077105e9..dcd07ff58 100644 --- a/app/Http/Controllers/CommitRecommitController.php +++ b/app/Http/Controllers/CommitRecommitController.php @@ -22,12 +22,6 @@ public function store(CreateCommitRecommitRequest $request, #[CurrentUser] User $recommit = $action->handle($user, '', quotedCommit: $commit, linkPreview: false); $unlocked = $achievements->unlockedBadgeNames($user)->diff($before)->values(); - Inertia::flash('committed', [ - 'id' => $recommit->id, - 'body' => $recommit->body, - 'author' => ['handle' => $user->handle], - ]); - if ($unlocked->isNotEmpty()) { Inertia::flash('achievementsUnlocked', $unlocked->all()); } @@ -35,7 +29,12 @@ public function store(CreateCommitRecommitRequest $request, #[CurrentUser] User if ($recommit->status === CommitStatus::Pending) { Inertia::flash('toast', [ 'type' => 'info', - 'message' => 'Your commit is pending review. Everyone will see it once it is approved.', + 'message' => 'Your recommit is pending review. Everyone will see it once it is approved.', + ]); + } else { + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => 'Recommitted.', ]); } diff --git a/app/Http/Requests/CreateCommitRecommitRequest.php b/app/Http/Requests/CreateCommitRecommitRequest.php index be9aeaa81..fec559db9 100644 --- a/app/Http/Requests/CreateCommitRecommitRequest.php +++ b/app/Http/Requests/CreateCommitRecommitRequest.php @@ -24,9 +24,17 @@ public function authorize(): Response return Response::denyAsNotFound(); } - return $commit->status === CommitStatus::Approved && ! $commit->isOwnedBy($user) - ? Response::allow() - : Response::deny(); + if ($commit->status !== CommitStatus::Approved || $commit->isOwnedBy($user)) { + return Response::deny(); + } + + $alreadyRecommitted = Commit::query() + ->where('user_id', $user->id) + ->where('quoted_commit_id', $commit->id) + ->where('body', '') + ->exists(); + + return $alreadyRecommitted ? Response::deny() : Response::allow(); } /** diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 805af50de..d91dad966 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -46,6 +46,7 @@ * @property-read int $stars_count * @property-read int $pasteis_count * @property-read bool $starred + * @property-read bool $recommitted * @property-read User|null $user * @property-read Profile|null $author * @property-read Collection $stars diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 34f06f291..0cba11b8f 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -267,6 +267,29 @@ public function withStarsFor(?User $viewer): self ->withCasts(['starred' => 'bool']); } + /** Whether the viewer has already shared each commit without commentary. */ + public function withRecommitFor(?User $viewer): self + { + if (! $viewer instanceof User) { + return $this + ->addSelect(['recommitted' => new QueryExpression('false')]) + ->withCasts(['recommitted' => 'bool']); + } + + $recommits = $this->getModel()->newQuery() + ->from('commits as recommits') + ->withoutGlobalScopes() + ->selectRaw('count(*) > 0') + ->whereColumn('recommits.quoted_commit_id', $this->qualifyColumn('id')) + ->where('recommits.body', '') + ->whereNull('recommits.deleted_at') + ->where('recommits.user_id', $viewer->id); + + return $this + ->addSelect(['recommitted' => $recommits]) + ->withCasts(['recommitted' => 'bool']); + } + /** * Loads the poll with each option's vote count and whether the viewer chose it, in two queries for any number of commits. */ @@ -297,6 +320,7 @@ public function withCardDataFor(?User $viewer): self ->with(['author', 'linkPreview', 'images']) ->withQuotedCommitFor($viewer) ->withStarsFor($viewer) + ->withRecommitFor($viewer) ->withPollFor($viewer) ->afterQuery(fn (Collection $commits) => resolve(CommitTranslations::class)->attach($commits)); } diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 9341ee843..ed46d3eb6 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -1,5 +1,5 @@ import { Link, router, usePage } from '@inertiajs/react'; -import { CornerDownRight } from 'lucide-react'; +import { CornerDownRight, Repeat2 } from 'lucide-react'; import type { MouseEvent, PointerEvent, ReactNode } from 'react'; import { useCallback, useContext, useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; @@ -428,6 +428,14 @@ export function CommitCard({ + {commit.body === '' && + commit.quoted_commit_id !== null && ( +

+ + Recommitted +

+ )} + {commit.parent && isDeleted(commit.parent) && (

diff --git a/resources/js/components/commit-recommit-button.tsx b/resources/js/components/commit-recommit-button.tsx index abf64d7c7..ed4abf26c 100644 --- a/resources/js/components/commit-recommit-button.tsx +++ b/resources/js/components/commit-recommit-button.tsx @@ -1,8 +1,9 @@ -import { router, usePage } from '@inertiajs/react'; -import { Repeat2 } from 'lucide-react'; -import { useState } from 'react'; +import { Form, usePage } from '@inertiajs/react'; +import { Check, Repeat2 } from 'lucide-react'; +import { useRef } from 'react'; import { store } from '@/actions/App/Http/Controllers/CommitRecommitController'; import { ActionTooltip } from '@/components/action-tooltip'; +import { CommitRulesDialog, useCommitRules } from '@/components/commit-rules'; import { useOwnsCommit } from '@/hooks/use-owns-commit'; import { signIn } from '@/lib/auth'; import { cn } from '@/lib/utils'; @@ -17,37 +18,23 @@ export function CommitRecommitButton({ }) { const { auth } = usePage().props; const ownsCommit = useOwnsCommit(); - const [isRecommitting, setIsRecommitting] = useState(false); + const commitRules = useCommitRules(); + const textareaRef = useRef(null); if (commit.status !== 'approved' || ownsCommit(commit)) { return null; } - return ( + const recommitted = commit.recommitted === true; + + return auth.user === null ? ( + ) : ( +

commitRules.beforeSubmit()} + transform={(data) => ({ + ...data, + commit_rules_accepted: commitRules.hasAccepted(), + })} + options={{ preserveScroll: true }} + > + {({ processing }) => ( + <> + + + +