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/app/Actions/CreateCommitRecommit.php b/app/Actions/CreateCommitRecommit.php new file mode 100644 index 000000000..939433cdc --- /dev/null +++ b/app/Actions/CreateCommitRecommit.php @@ -0,0 +1,43 @@ +lockForUpdate()->findOrFail($commit->id); + + if ($commit->status !== CommitStatus::Approved || $commit->isOwnedBy($user)) { + throw new AuthorizationException; + } + + $alreadyRecommitted = Commit::query() + ->where('user_id', $user->id) + ->where('quoted_commit_id', $commit->id) + ->where('body', '') + ->exists(); + + if ($alreadyRecommitted) { + throw new AuthorizationException; + } + + return $this->createCommit->handle($user, '', quotedCommit: $commit, linkPreview: false); + }, attempts: 3); + } +} diff --git a/app/Http/Controllers/CommitRecommitController.php b/app/Http/Controllers/CommitRecommitController.php new file mode 100644 index 000000000..7ae1f6df3 --- /dev/null +++ b/app/Http/Controllers/CommitRecommitController.php @@ -0,0 +1,43 @@ +unlockedBadgeNames($user); + $recommit = $action->handle($user, $commit); + $unlocked = $achievements->unlockedBadgeNames($user)->diff($before)->values(); + + if ($unlocked->isNotEmpty()) { + Inertia::flash('achievementsUnlocked', $unlocked->all()); + } + + if ($recommit->status === CommitStatus::Pending) { + Inertia::flash('toast', [ + 'type' => 'info', + 'message' => 'Your recommit is pending review. Everyone will see it once it is approved.', + ]); + } else { + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => 'Recommitted.', + ]); + } + + return back(); + } +} diff --git a/app/Http/Requests/CreateCommitRecommitRequest.php b/app/Http/Requests/CreateCommitRecommitRequest.php new file mode 100644 index 000000000..d96157b46 --- /dev/null +++ b/app/Http/Requests/CreateCommitRecommitRequest.php @@ -0,0 +1,41 @@ +user(); + assert($user instanceof User); + + $commit = $this->route('commit'); + assert($commit instanceof Commit); + + if (! $commit->isVisibleTo($user)) { + return Response::denyAsNotFound(); + } + + if ($commit->status !== CommitStatus::Approved || $commit->isOwnedBy($user)) { + return Response::deny(); + } + + return Response::allow(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} 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/composer.json b/composer.json index c6158539e..deae18f17 100644 --- a/composer.json +++ b/composer.json @@ -111,7 +111,7 @@ "test:unit": [ "Composer\\Config::disableProcessTimeout", "@putenv XDEBUG_MODE=coverage", - "@php -d memory_limit=-1 vendor/bin/pest --parallel --coverage --exactly=100.0 --passthru-php=\"-d memory_limit=-1\"", + "@php -d zend.assertions=1 -d memory_limit=-1 vendor/bin/pest --parallel --coverage --exactly=100.0 --passthru-php=\"-d zend.assertions=1 -d memory_limit=-1\"", "bun run test:unit" ], "test:types": [ diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 142d7e0de..084fbc20b 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, Quote, 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'; @@ -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'; @@ -155,6 +156,10 @@ export function CommitCard({ hasRailTail && railNodePosition !== 'bottom' && endsWithDot; const graphEndId = `${commit.id}:end`; const isOwn = useOwnsCommit()(commit); + const isRecommit = + commit.quoted_commit_id !== null && + commit.body === '' && + (commit.images?.length ?? 0) === 0; const isFresh = isOwn && isWithinSeconds(commit.created_at, 15); const isDeletable = isOwn && canDelete; const hash = shortHash(commit.id); @@ -427,6 +432,17 @@ export function CommitCard({ + {commit.quoted_commit_id !== null && ( +

+ {isRecommit ? ( + + ) : ( + + )} + {isRecommit ? 'Recommitted' : 'Quoted'} +

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

@@ -537,6 +553,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..ed4abf26c --- /dev/null +++ b/resources/js/components/commit-recommit-button.tsx @@ -0,0 +1,102 @@ +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'; +import type { Commit } from '@/types'; + +export function CommitRecommitButton({ + commit, + className, +}: { + commit: Commit; + className?: string; +}) { + const { auth } = usePage().props; + const ownsCommit = useOwnsCommit(); + const commitRules = useCommitRules(); + const textareaRef = useRef(null); + + if (commit.status !== 'approved' || ownsCommit(commit)) { + return null; + } + + const recommitted = commit.recommitted === true; + + return auth.user === null ? ( + + + + ) : ( +

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