From 6aafcb7c6510e41cebc0dc38134b3daf9c7e99c2 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 26 Oct 2026 17:06:52 +0100 Subject: [PATCH 1/6] feat: label commit sources and filter their visibility --- .github/actions/commit/action.yml | 5 + .github/actions/commit/commit.sh | 7 +- KNOWLEDGE_BASE.md | 27 ++- app/Actions/CreateCommit.php | 7 +- app/Actions/CreateExport.php | 7 +- app/Actions/DeleteCommitContent.php | 2 +- app/Actions/DeleteUser.php | 2 +- app/Actions/UpdateUserCommitVisibility.php | 18 ++ app/Enums/CommitSource.php | 11 + app/Http/Controllers/Api/CommitController.php | 3 +- .../Api/CommitVisibilityController.php | 23 ++ .../Api/NotificationController.php | 3 +- .../Api/NotificationGroupController.php | 3 +- .../Api/UserCommitVisibilityController.php | 26 +++ app/Http/Controllers/Cli/CommitController.php | 3 +- .../Controllers/CommitPhotoController.php | 3 +- .../CommitRestorationController.php | 4 +- .../CommitVisibilityController.php | 23 ++ .../UserCommitVisibilityController.php | 37 ++++ .../Middleware/AuthenticateSshSignature.php | 3 + app/Http/Middleware/HandleInertiaRequests.php | 5 + app/Http/Requests/CreateApiCommitRequest.php | 3 + app/Http/Requests/CreateCliCommitRequest.php | 3 + .../ShowApiCommitVisibilityRequest.php | 26 +++ .../Requests/ShowCommitVisibilityRequest.php | 26 +++ .../UpdateApiUserCommitVisibilityRequest.php | 39 ++++ .../UpdateUserCommitVisibilityRequest.php | 39 ++++ app/Http/Resources/CommitResource.php | 24 +++ app/Models/Commit.php | 48 ++++- app/Models/User.php | 4 + app/Queries/CommitBuilder.php | 32 ++- app/Queries/CommitSearchQuery.php | 1 + app/Queries/LinkCommitsQuery.php | 3 +- app/Queries/LogQueries/FollowingLogQuery.php | 1 + app/Queries/LogQueries/LatestLogQuery.php | 1 + app/Queries/NotificationGroupsQuery.php | 5 +- app/Queries/PinnedCommitQuery.php | 1 + app/Queries/StarredCommitsQuery.php | 3 +- app/Queries/SuggestedCommitsQuery.php | 1 + app/Queries/UserCommitsQuery.php | 3 +- app/Services/SignedCommits.php | 34 ++- app/Services/TheAlgorithm.php | 1 + ...gin_to_commits_and_visibility_to_users.php | 22 ++ resources/js/components/commit-card.tsx | 168 +++++++++------ .../js/components/commit-channel-badges.tsx | 34 +++ resources/js/components/commit-rules.tsx | 2 +- resources/js/components/commit-thread.tsx | 90 +++++--- resources/js/components/filtered-commit.tsx | 76 +++++++ resources/js/components/page-tabs.tsx | 19 +- resources/js/components/quoted-commit.tsx | 38 +++- resources/js/layouts/settings/layout.tsx | 1 + resources/js/layouts/settings/tabs.ts | 2 + resources/js/pages/commit-visibility/edit.tsx | 139 ++++++++++++ resources/js/pages/commit/show.tsx | 8 +- resources/js/types/commit.ts | 6 + resources/scripts/commit.sh | 32 ++- routes/api.php | 7 + routes/web.php | 7 + tests/Feature/CommitScriptTest.php | 39 +++- tests/Feature/CommitVisibilityTest.php | 199 ++++++++++++++++++ .../Controllers/Api/CommitControllerTest.php | 38 ++++ .../Controllers/Api/LogControllerTest.php | 2 + .../Api/NotificationControllerTest.php | 4 + .../UserCommitVisibilityControllerTest.php | 71 +++++++ .../Controllers/Cli/CommitControllerTest.php | 27 ++- .../Controllers/Cli/ScriptControllerTest.php | 7 - .../Controllers/ExportControllerTest.php | 3 + .../UserCommitVisibilityControllerTest.php | 85 ++++++++ tests/Feature/GitHubActionTest.php | 26 ++- tests/Pest.php | 12 +- .../Unit/Actions/DeleteCommitContentTest.php | 5 +- tests/Unit/Actions/DeleteUserTest.php | 9 +- tests/Unit/CommitVisibility.test.tsx | 160 ++++++++++++++ tests/Unit/Models/CommitTest.php | 2 + tests/Unit/Services/SignedCommitsTest.php | 31 +++ tests/draft-images.config.ts | 1 + tests/tsconfig.json | 1 + 77 files changed, 1730 insertions(+), 163 deletions(-) create mode 100644 app/Actions/UpdateUserCommitVisibility.php create mode 100644 app/Enums/CommitSource.php create mode 100644 app/Http/Controllers/Api/CommitVisibilityController.php create mode 100644 app/Http/Controllers/Api/UserCommitVisibilityController.php create mode 100644 app/Http/Controllers/CommitVisibilityController.php create mode 100644 app/Http/Controllers/UserCommitVisibilityController.php create mode 100644 app/Http/Requests/ShowApiCommitVisibilityRequest.php create mode 100644 app/Http/Requests/ShowCommitVisibilityRequest.php create mode 100644 app/Http/Requests/UpdateApiUserCommitVisibilityRequest.php create mode 100644 app/Http/Requests/UpdateUserCommitVisibilityRequest.php create mode 100644 database/migrations/2026_10_26_153931_add_origin_to_commits_and_visibility_to_users.php create mode 100644 resources/js/components/commit-channel-badges.tsx create mode 100644 resources/js/components/filtered-commit.tsx create mode 100644 resources/js/pages/commit-visibility/edit.tsx create mode 100644 tests/Feature/CommitVisibilityTest.php create mode 100644 tests/Feature/Controllers/Api/UserCommitVisibilityControllerTest.php create mode 100644 tests/Feature/Controllers/UserCommitVisibilityControllerTest.php create mode 100644 tests/Unit/CommitVisibility.test.tsx diff --git a/.github/actions/commit/action.yml b/.github/actions/commit/action.yml index 0e5a9abda..296786feb 100644 --- a/.github/actions/commit/action.yml +++ b/.github/actions/commit/action.yml @@ -17,6 +17,10 @@ inputs: description: Paths of JPEG, PNG or WebP files to attach, one per line, up to 3. required: false default: '' + ai: + description: Whether an AI agent generated this commit on your behalf. + required: false + default: 'false' url: description: The devnull to commit to. required: false @@ -32,5 +36,6 @@ runs: DEVNULL_SSH_KEY: ${{ inputs.ssh-key }} DEVNULL_REPLY_TO: ${{ inputs.reply-to }} DEVNULL_IMAGES: ${{ inputs.images }} + DEVNULL_AI: ${{ inputs.ai }} DEVNULL_URL: ${{ inputs.url }} run: bash "$GITHUB_ACTION_PATH/commit.sh" diff --git a/.github/actions/commit/commit.sh b/.github/actions/commit/commit.sh index 555423c5c..c48c48838 100755 --- a/.github/actions/commit/commit.sh +++ b/.github/actions/commit/commit.sh @@ -28,7 +28,12 @@ main() { || fail "Could not load the ssh-key input: it must be a private Ed25519 or RSA key without a passphrase." unset DEVNULL_SSH_KEY - local arguments=() + local arguments=(--source ci) + case "${DEVNULL_AI:-false}" in + true) arguments+=(--ai) ;; + false) ;; + *) fail "The ai input must be true or false." ;; + esac if [ -n "${DEVNULL_REPLY_TO:-}" ]; then arguments+=(--reply "$DEVNULL_REPLY_TO") fi diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2d9ded45b..a7cd63eea 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -17,6 +17,7 @@ This file documents the platform's features, stack, and stories. Feature entries - [Following, Latest, and Trending logs](#following-latest-and-trending-logs) - [Searching developers and commits](#searching-developers-and-commits) - [Writing commits](#writing-commits) + - [Commit badges and visibility](#commit-badges-and-visibility) - [Translating commits](#translating-commits) - [Code, emoji, and mentions](#code-emoji-and-mentions) - [Draft previews](#draft-previews) @@ -184,6 +185,23 @@ Commits, replies, quotes, scheduling, terminal submissions, and app (signed API) Sources: [composer](resources/js/components/commit-composer.tsx), [commit model](app/Models/Commit.php), [commit validation](app/Http/Requests/CreateCommitRequest.php), [text normalization](app/Services/CommitBodies.php), [submission throttle](app/Http/Middleware/ThrottleCommits.php). +### Commit badges and visibility + +Small badges identify a commit's source or how its content was produced: + +- **cli** means it was posted through the terminal client. +- **ci** means it was posted through a workflow. A workflow that invokes the terminal script gets **ci**, without a second **cli** badge. +- **ai** means an AI agent generated content on a person's behalf. It can appear alongside **cli** or **ci**. +- **bot** identifies Tom's and World's replies. Their replies are excluded from **ai**, and **bot** has no visibility setting. + +Website, API, and native-app submissions have no separate source badges by default. Sources and AI content are declared by the submitting client, not guessed from the text. Existing commits and submissions from older clients remain unclassified. + +Under **Settings → Visibility**, choose whether to show terminal, workflow, and AI-generated commits. All three are shown by default. These preferences belong to your account and apply across devices, including logs, profiles, search, starred commits, notifications, and signed API responses. A commit matching any category you hide is filtered, while your own commits remain visible to you. + +Filtered top-level commits disappear from lists. In a thread, filtered replies keep their place, with a message explaining that your visibility settings hid them; visible replies beneath them remain accessible. Quoted commits and direct links show a placeholder too. Choose **Show reply** or **Show commit** to reveal that individual commit temporarily without changing your saved preferences. These are personal reading preferences, not privacy controls: the public commit remains available to other people. + +Sources: [commit model](app/Models/Commit.php), [visibility settings](resources/js/pages/commit-visibility/edit.tsx), [badges](resources/js/components/commit-channel-badges.tsx), [filtered commits](resources/js/components/filtered-commit.tsx), [visibility settings action](app/Actions/UpdateUserCommitVisibility.php). + Livestream history: the team settled on calling posts **commits** and their list a **log** on [day one at 2:56:00](https://www.youtube.com/watch?v=6zCNQa3hNm0&t=10560s). At [3:02:19](https://www.youtube.com/watch?v=6zCNQa3hNm0&t=10939s), they agreed to rename the post model too, rather than changing only the UI. These are naming decisions, not timestamps proving the rename had finished. ### Translating commits @@ -446,6 +464,8 @@ Source: [shortcut reference](resources/js/components/keyboard-shortcuts-dialog.t Settings → Terminal shows how to post using the `/commit` shell script. It signs the submission locally with an Ed25519 or RSA SSH key whose public half is on your GitHub account, as an authentication or a signing key, and /dev/null verifies that signature. No browser login or API token is required for that submission; your private key is not uploaded. The script can create commits, reply with `--reply` using a commit hash or link, and attach up to three images with `--image`, without descriptions. +Terminal submissions are labeled **cli** by default. Add **`--ai`** when an agent generates a commit on your behalf. Workflows that call the script directly should add **`--source ci`**; combine it with **`--ai`** when the workflow publishes generated content. Use **`--help`** to see the options. These declarations are signed with the commit. + Before posting from the terminal or a GitHub Action, accept the [commit rules](#moderation) by making your first submission through the web composer. Terminal posts then follow the same moderation and 10-per-minute submission limit as browser posts, and always need 1–4,000 characters of text, even with images. Their first link supplies the default link card. Review a downloaded script before running it, and never paste a private key into a commit. Sources: [terminal instructions](resources/js/pages/terminal/show.tsx), [CLI routes](routes/api.php), [signature verification](app/Http/Middleware/AuthenticateSshSignature.php). @@ -456,6 +476,8 @@ Livestream history: terminal posting was reported working on [day one at 6:32:47 Use `dev-null-sh/dev-null.sh/.github/actions/commit@production` to publish updates from a GitHub workflow, for example when a release is published. Settings → Terminal includes a workflow example. The action accepts a commit body, an SSH private key supplied through a GitHub Actions secret, an optional `reply-to` reference, optional image paths (up to three), and an optional `url` for another /dev/null instance. +The action labels its commits **ci** automatically. Set its optional **`ai`** input to **`'true'`** when an agent generated the content on your behalf; it defaults to **`'false'`**. Generated workflow content carries both **ci** and **ai**. + Generate a dedicated CI key, add its public half to GitHub as a signing or authentication key, and store the private half only in the repository's encrypted secrets. The action supports Ed25519 or RSA keys without a passphrase. It reports the created commit's link and whether it is pending review as a workflow annotation; it has no step outputs, and a refused submission fails the job. The usual moderation and submission limits still apply. Sources: [workflow instructions](resources/js/pages/terminal/show.tsx), [GitHub action](.github/actions/commit/action.yml). @@ -486,8 +508,11 @@ A signed request sees what the web shows you, such as your own pending commits. - switch Pastéis mode and give pastéis; - report commits; - sync from GitHub and save your lane color; +- read and update your commit visibility preferences, and reveal an individually filtered commit; - delete your account. +Signed commit creation accepts optional **`source`** (`cli` or `ci`) and **`is_ai`** (a boolean) declarations. Without a source declaration, API and app commits have no source badge. **bot** is reserved for Tom and World. Read or update the hidden categories through **`GET`** or **`PATCH /api/v1/me/commit-visibility`**, using **`hidden_commit_channels`** with any of `cli`, `ci`, and `ai`. A signed **`GET /api/v1/commits/{commit}/visibility`** reveals an individual filtered commit without changing those preferences; normal visibility and block rules still apply. + Signed request bodies are JSON, or multipart when they carry images or a voice note, so app commits can have images with descriptions or a poll but no link-card choice. Before your first app submission, accept the [commit rules](#moderation) through the web composer. Scheduling, search, and translations remain web-only. Signed requests are limited to **120 requests per minute per user**. Settings → Devices lists your approved devices with when each was last used, and revokes them. Sources: [signed requests](app/Services/SignedRequests.php), [sign-in page](app/Http/Controllers/MobileAuthorizationController.php), [devices settings](resources/js/pages/device/index.tsx), [API routes](routes/api.php). @@ -553,7 +578,7 @@ Ordinary photos of children are not prohibited just because a child appears in t Posts awaiting automated checks stay pending if those checks fail; a failed check never approves a post. -Before you first commit, reply, quote, or schedule a commit, the web composer shows the commit rules, and keeps showing them on every attempt until you send with "Got it, commit": no engagement bait, no low-effort posts, no AI-generated posts, no spam or self-promotion, and no harassment, hate, or private information, followed by a link to the terms. Your first submission must come from the web so you can accept these rules; terminal, GitHub Action, and app posting become available afterwards. +Before you first commit, reply, quote, or schedule a commit, the web composer shows the commit rules, and keeps showing them on every attempt until you send with "Got it, commit": no engagement bait, no low-effort posts, no undisclosed AI-generated posts, no spam or self-promotion, and no harassment, hate, or private information, followed by a link to the terms. Disclose generated content rather than presenting it as your own writing. Your first submission must come from the web so you can accept these rules; terminal, GitHub Action, and app posting become available afterwards. During a **timeout**, you can still read and react, but cannot commit, reply, quote, schedule, send remotes, or start, rename, add people to, or pin messages in orgs from any surface; each attempt says how long is left and why. Scheduled commits wait until the timeout ends. Like bans, timeouts belong to the GitHub account: deleting the /dev/null account and signing in again does not end a timeout or erase its history. diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 95354559f..2d6bab1ab 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -6,6 +6,7 @@ use App\Dto\NewPoll; use App\Dto\ThreadEngagement; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Jobs\CheckUserLevel; use App\Jobs\EmbedCommit; @@ -47,7 +48,7 @@ public function __construct( * @param list $images * @param array $imageDescriptions */ - public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null): Commit + public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, ?CommitSource $source = null, bool $isAi = false): Commit { $url = match ($linkPreview) { true => $this->urls->first($body), @@ -55,12 +56,14 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit default => $linkPreview, }; - return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll): Commit { + return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $source, $isAi): Commit { $linkPreview = $this->linkPreviewFor($url); $createdAt = now(); $hasGoodReputation = $user->hasGoodReputation(); $commit = $user->commits()->create([ + 'source' => $source, + 'is_ai' => $isAi, 'body' => $this->emojiShortcodes->replace($body), 'mentions' => $this->mentionedUsers->get($body), 'parent_id' => $parent?->id, diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index b4ad5b244..d5f973b75 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -34,7 +34,7 @@ * @phpstan-type UserExport array{ * schema_version: int, * exported_at: string, - * profile: array, + * profile: array|null>, * commits: Generator>, * polls: Generator, * poll_votes: Generator, @@ -81,6 +81,7 @@ public function handle(User $user): array 'preferred_lane' => $user->preferred_lane, 'timezone' => $user->timezone, 'show_local_time' => $user->show_local_time, + 'hidden_commit_channels' => $user->hidden_commit_channels, 'translation_language' => $user->translation_language, 'automatic_translation' => $user->automatic_translation ?? true, 'lane_color' => $user->lane_color, @@ -166,7 +167,7 @@ private function commits(User $user): Generator { $commits = $user->commits() ->withTrashed() - ->select(['id', 'parent_id', 'quoted_commit_id', 'body', 'status', 'pasteis_count', 'created_at', 'updated_at', 'deleted_at']) + ->select(['id', 'parent_id', 'quoted_commit_id', 'body', 'status', 'source', 'is_ai', 'pasteis_count', 'created_at', 'updated_at', 'deleted_at']) ->with('images:id,commit_id') ->lazyById(500); @@ -183,6 +184,8 @@ private function commits(User $user): Generator 'body' => $commit->body, 'cached_translations' => $cached[$translations->key($commit)], 'status' => $commit->status->value, + 'source' => $commit->source?->value, + 'is_ai' => $commit->is_ai, 'pasteis_count' => $commit->pasteis_count, 'images' => $commit->images->map(fn (CommitImage $image): string => $image->url())->all(), 'created_at' => $commit->created_at->toIso8601String(), diff --git a/app/Actions/DeleteCommitContent.php b/app/Actions/DeleteCommitContent.php index 4bb934876..67a40196f 100644 --- a/app/Actions/DeleteCommitContent.php +++ b/app/Actions/DeleteCommitContent.php @@ -33,7 +33,7 @@ public function handle(Commit $commit): void LinkPreview::query()->where('url', 'like', "%/commits/{$commit->id}%")->delete(); - $commit->update(['body' => '', 'mentions' => null, 'embedding' => null, 'link_preview_id' => null]); + $commit->update(['body' => '', 'source' => null, 'is_ai' => false, 'mentions' => null, 'embedding' => null, 'link_preview_id' => null]); return $imagePaths; }); diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 9fb528780..5fc09e657 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -59,7 +59,7 @@ public function handle(User $user): void } $user->commits()->delete(); - $user->commits()->withTrashed()->update(['user_id' => null, 'body' => '', 'mentions' => null, 'embedding' => null, 'link_preview_id' => null]); + $user->commits()->withTrashed()->update(['user_id' => null, 'body' => '', 'source' => null, 'is_ai' => false, 'mentions' => null, 'embedding' => null, 'link_preview_id' => null]); $this->deleteOrgs->handle($this->emptyOrgs($user)); diff --git a/app/Actions/UpdateUserCommitVisibility.php b/app/Actions/UpdateUserCommitVisibility.php new file mode 100644 index 000000000..c394f193b --- /dev/null +++ b/app/Actions/UpdateUserCommitVisibility.php @@ -0,0 +1,18 @@ + $hiddenCommitChannels + */ + public function handle(User $user, array $hiddenCommitChannels): void + { + $user->update(['hidden_commit_channels' => $hiddenCommitChannels]); + } +} diff --git a/app/Enums/CommitSource.php b/app/Enums/CommitSource.php new file mode 100644 index 000000000..42957a285 --- /dev/null +++ b/app/Enums/CommitSource.php @@ -0,0 +1,11 @@ +handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), $request->images(), $request->imageDescriptions(), poll: $request->poll()); + $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), $request->quotedCommit(), $request->images(), $request->imageDescriptions(), poll: $request->poll(), source: $request->enum('source', CommitSource::class), isAi: $request->boolean('is_ai')); return CommitResource::make($page->get($commit, $user)) ->response() diff --git a/app/Http/Controllers/Api/CommitVisibilityController.php b/app/Http/Controllers/Api/CommitVisibilityController.php new file mode 100644 index 000000000..fdda6ed12 --- /dev/null +++ b/app/Http/Controllers/Api/CommitVisibilityController.php @@ -0,0 +1,23 @@ +get($commit, $user); + $shown->isFiltered = false; + + return CommitResource::make($shown); + } +} diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php index 264062eee..c653b225a 100644 --- a/app/Http/Controllers/Api/NotificationController.php +++ b/app/Http/Controllers/Api/NotificationController.php @@ -9,7 +9,6 @@ use App\Http\Resources\NotificationResource; use App\Models\Notification; use App\Models\User; -use App\Queries\CommitBuilder; use App\Queries\NotificationGroupsQuery; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Database\Eloquent\Collection; @@ -36,7 +35,7 @@ public function index(ListNotificationsRequest $request, #[CurrentUser] User $us new Collection($items)->loadMissing([ 'commit.user', 'commit.parent' => fn (BelongsTo $query): BelongsTo => $query - ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($user)) + ->visibleTo($user)->withVisibilityFor($user) ->with('user'), ]); diff --git a/app/Http/Controllers/Api/NotificationGroupController.php b/app/Http/Controllers/Api/NotificationGroupController.php index bb28b7216..b14cc44b9 100644 --- a/app/Http/Controllers/Api/NotificationGroupController.php +++ b/app/Http/Controllers/Api/NotificationGroupController.php @@ -8,7 +8,6 @@ use App\Http\Resources\NotificationResource; use App\Models\Notification; use App\Models\User; -use App\Queries\CommitBuilder; use App\Queries\NotificationGroupsQuery; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -26,7 +25,7 @@ public function show(ShowNotificationGroupRequest $request, #[CurrentUser] User ->with([ 'commit.user', 'commit.parent' => fn (BelongsTo $query): BelongsTo => $query - ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($user)) + ->visibleTo($user)->withVisibilityFor($user) ->with('user'), ]) ->latest() diff --git a/app/Http/Controllers/Api/UserCommitVisibilityController.php b/app/Http/Controllers/Api/UserCommitVisibilityController.php new file mode 100644 index 000000000..d24bb14bf --- /dev/null +++ b/app/Http/Controllers/Api/UserCommitVisibilityController.php @@ -0,0 +1,26 @@ +json(['hidden_commit_channels' => $user->hidden_commit_channels]); + } + + public function update(UpdateApiUserCommitVisibilityRequest $request, #[CurrentUser] User $user, UpdateUserCommitVisibility $action): JsonResponse + { + $action->handle($user, $request->channels()); + + return response()->json(['hidden_commit_channels' => $user->hidden_commit_channels]); + } +} diff --git a/app/Http/Controllers/Cli/CommitController.php b/app/Http/Controllers/Cli/CommitController.php index a2e10a1d1..2c6e0f0ef 100644 --- a/app/Http/Controllers/Cli/CommitController.php +++ b/app/Http/Controllers/Cli/CommitController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Cli; use App\Actions\CreateCommit; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Http\Requests\CreateCliCommitRequest; use App\Models\User; @@ -15,7 +16,7 @@ { public function store(CreateCliCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action): Response { - $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), images: $request->images()); + $commit = $action->handle($user, $request->string('body')->value(), $request->parent(), images: $request->images(), source: $request->enum('source', CommitSource::class), isAi: $request->boolean('is_ai')); $url = route('commit.show', [$user, $commit]); diff --git a/app/Http/Controllers/CommitPhotoController.php b/app/Http/Controllers/CommitPhotoController.php index f1f4cae5a..27e5adeab 100644 --- a/app/Http/Controllers/CommitPhotoController.php +++ b/app/Http/Controllers/CommitPhotoController.php @@ -22,13 +22,14 @@ { public function show(ShowCommitPhotoRequest $request, #[CurrentUser] ?User $currentUser, User $user, Commit $commit, int $photo, CommitAncestorsQuery $ancestors, CommitContinuationQuery $continuation, CommitRepliesQuery $replies): RedirectResponse|Response { - if (! $currentUser instanceof User) { + if (! $currentUser instanceof User || $commit->isFilteredFor($currentUser)) { return to_route('commit.show', [$user, $commit]); } $focusedCommit = null; $getCommit = function () use (&$focusedCommit, $commit, $currentUser): Commit { return $focusedCommit ??= Commit::query() + ->withVisibilityFor($currentUser) ->withVisibleReplyCount($currentUser) ->withContinuationId($currentUser) ->with(['user', 'linkPreview', 'images']) diff --git a/app/Http/Controllers/CommitRestorationController.php b/app/Http/Controllers/CommitRestorationController.php index a3653ae77..570487115 100644 --- a/app/Http/Controllers/CommitRestorationController.php +++ b/app/Http/Controllers/CommitRestorationController.php @@ -23,8 +23,8 @@ public function store(CreateCommitRestorationRequest $request, #[CurrentUser] Us 'user', 'linkPreview', 'images', - 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->visibleTo($user)->withVisibleReplyCount($user)->withContinuationId($user)->with(['user', 'linkPreview', 'images'])->withPollFor($user), - 'parent' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->visibleTo($user)->withVisibleReplyCount($user)->withContinuationId($user)->with('user'), + 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->visibleTo($user)->withVisibilityFor($user)->withVisibleReplyCount($user)->withContinuationId($user)->with(['user', 'linkPreview', 'images'])->withPollFor($user), + 'parent' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()->visibleTo($user)->withVisibilityFor($user)->withVisibleReplyCount($user)->withContinuationId($user)->with('user'), ])->findOrFail($commit->id); Inertia::flash('restored', $restored->setAttribute('is_pinned', $restored->user?->pinned_commit_id === $restored->id)); diff --git a/app/Http/Controllers/CommitVisibilityController.php b/app/Http/Controllers/CommitVisibilityController.php new file mode 100644 index 000000000..3546e138a --- /dev/null +++ b/app/Http/Controllers/CommitVisibilityController.php @@ -0,0 +1,23 @@ +get($commit, $user); + $shown->isFiltered = false; + + return response()->json($shown)->header('Cache-Control', 'private, no-store'); + } +} diff --git a/app/Http/Controllers/UserCommitVisibilityController.php b/app/Http/Controllers/UserCommitVisibilityController.php new file mode 100644 index 000000000..167aae8ef --- /dev/null +++ b/app/Http/Controllers/UserCommitVisibilityController.php @@ -0,0 +1,37 @@ + $user->hidden_commit_channels, + ]); + } + + public function update(UpdateUserCommitVisibilityRequest $request, #[CurrentUser] User $user, UpdateUserCommitVisibility $action): RedirectResponse + { + $action->handle($user, $request->channels()); + + Inertia::clearHistory(); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => 'Commit visibility saved.', + ]); + + return back(); + } +} diff --git a/app/Http/Middleware/AuthenticateSshSignature.php b/app/Http/Middleware/AuthenticateSshSignature.php index 06926f848..890547764 100644 --- a/app/Http/Middleware/AuthenticateSshSignature.php +++ b/app/Http/Middleware/AuthenticateSshSignature.php @@ -37,6 +37,7 @@ public function handle(Request $request, Closure $next): Response $withImages ? $this->payloadFile($request) : $request->getContent(), $request->headers->get('X-SSH-Signature', '') ?? '', $withImages ? fn (): array => $this->imageHashes($request) : null, + $request->header('X-Devnull-Commit-Version') === '2', ); } catch (InvalidArgumentException $invalidArgumentException) { abort(401, $invalidArgumentException->getMessage()); @@ -49,6 +50,8 @@ public function handle(Request $request, Closure $next): Response $request->merge([ 'body' => $commit['body'], 'reply_to' => $commit['reply_to'], + 'source' => $commit['source']?->value, + 'is_ai' => $commit['is_ai'], ]); // The body comes out of the signed payload only now, after the global middleware took NUL bytes out of the inputs. diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 7d60a25d1..1f7a30215 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -64,6 +64,11 @@ public function share(Request $request): array { $isAdmin = $request->user()?->isAdmin() ?? false; $user = $request->user(); + + if ($user instanceof User) { + Inertia::encryptHistory(); + } + $showsWhoToFollow = $user instanceof User && $request->cookie('who_to_follow') !== 'false'; $context = match (true) { $request->routeIs('user.show') => $request->route('user'), diff --git a/app/Http/Requests/CreateApiCommitRequest.php b/app/Http/Requests/CreateApiCommitRequest.php index c60dc0deb..8b5ed5938 100644 --- a/app/Http/Requests/CreateApiCommitRequest.php +++ b/app/Http/Requests/CreateApiCommitRequest.php @@ -5,6 +5,7 @@ namespace App\Http\Requests; use App\Dto\NewPoll; +use App\Enums\CommitSource; use App\Enums\PollDuration; use App\Models\Commit; use App\Models\User; @@ -46,6 +47,8 @@ public function rules(): array return [ 'body' => CreateCommitRequest::bodyRules($this->hasFile('images')), + 'source' => ['nullable', new Enum(CommitSource::class)], + 'is_ai' => ['sometimes', 'boolean'], 'parent_id' => ['bail', 'nullable', 'uuid', new VisibleCommit($user)], 'quoted_commit_id' => ['bail', 'nullable', 'uuid', 'prohibits:parent_id', new VisibleCommit($user, __('That commit is no longer available to quote.'))], 'poll' => [...$attachmentRules['poll'], 'prohibits:quoted_commit_id'], diff --git a/app/Http/Requests/CreateCliCommitRequest.php b/app/Http/Requests/CreateCliCommitRequest.php index 46ad6fd11..05c4429f3 100644 --- a/app/Http/Requests/CreateCliCommitRequest.php +++ b/app/Http/Requests/CreateCliCommitRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests; +use App\Enums\CommitSource; use App\Models\Commit; use App\Models\User; use App\Queries\CommitByReferenceQuery; @@ -37,6 +38,8 @@ public function rules(): array return [ 'body' => CreateCommitRequest::bodyRules(), 'reply_to' => ['nullable', 'string', new CommitReference($user)], + 'source' => ['nullable', new Enum(CommitSource::class)], + 'is_ai' => ['required', 'boolean'], 'images' => $attachmentRules['images'], 'images.*' => $attachmentRules['images.*'], ]; diff --git a/app/Http/Requests/ShowApiCommitVisibilityRequest.php b/app/Http/Requests/ShowApiCommitVisibilityRequest.php new file mode 100644 index 000000000..258f05d4b --- /dev/null +++ b/app/Http/Requests/ShowApiCommitVisibilityRequest.php @@ -0,0 +1,26 @@ +route('commit'); + assert($commit instanceof Commit); + + return $commit->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound(); + } + + /** @return array{} */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ShowCommitVisibilityRequest.php b/app/Http/Requests/ShowCommitVisibilityRequest.php new file mode 100644 index 000000000..30825e7b0 --- /dev/null +++ b/app/Http/Requests/ShowCommitVisibilityRequest.php @@ -0,0 +1,26 @@ +route('commit'); + assert($commit instanceof Commit); + + return $commit->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound(); + } + + /** @return array{} */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/UpdateApiUserCommitVisibilityRequest.php b/app/Http/Requests/UpdateApiUserCommitVisibilityRequest.php new file mode 100644 index 000000000..e0ec46af0 --- /dev/null +++ b/app/Http/Requests/UpdateApiUserCommitVisibilityRequest.php @@ -0,0 +1,39 @@ +> + */ + public function rules(): array + { + return [ + 'hidden_commit_channels' => ['present', 'array', 'list', 'max:3'], + 'hidden_commit_channels.*' => ['required', 'string', 'distinct', Rule::in(['cli', 'ci', 'ai'])], + ]; + } + + /** + * @return list + */ + public function channels(): array + { + /** @var list $channels */ + $channels = $this->array('hidden_commit_channels'); + + return $channels; + } +} diff --git a/app/Http/Requests/UpdateUserCommitVisibilityRequest.php b/app/Http/Requests/UpdateUserCommitVisibilityRequest.php new file mode 100644 index 000000000..67dc50f92 --- /dev/null +++ b/app/Http/Requests/UpdateUserCommitVisibilityRequest.php @@ -0,0 +1,39 @@ +> + */ + public function rules(): array + { + return [ + 'hidden_commit_channels' => ['present', 'array', 'list', 'max:3'], + 'hidden_commit_channels.*' => ['required', 'string', 'distinct', Rule::in(['cli', 'ci', 'ai'])], + ]; + } + + /** + * @return list + */ + public function channels(): array + { + /** @var list $channels */ + $channels = $this->array('hidden_commit_channels'); + + return $channels; + } +} diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index 6ce3b53b2..7791eb22d 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -52,8 +52,32 @@ public function toArray(Request $request): array $isSigned = $request->user() instanceof User; + if ($this->isFiltered) { + return [ + 'id' => $this->id, + 'parent_id' => $this->parent_id, + 'html_url' => route('commit.show', [$this->user, $this->resource]), + 'created_at' => $this->created_at->toIso8601ZuluString(), + 'author' => UserResource::make($this->user), + 'channels' => $this->channels(), + 'is_filtered' => true, + 'body' => '', + 'body_segments' => [], + 'preview_segments' => null, + 'deleted_at' => null, + 'replies_count' => $this->whenCounted('replies'), + 'continuation_id' => $this->whenHas('continuation_id'), + 'reply_preview' => $this->whenLoaded('replyPreview', fn (Collection $replies): array => [ + 'data' => self::collection($replies), + 'next_cursor' => data_get($this->getAttribute('reply_preview'), 'next_cursor'), + ]), + ]; + } + $commit = [ 'id' => $this->id, + 'channels' => $this->channels(), + 'is_filtered' => false, 'short_hash' => mb_substr(str_replace('-', '', $this->id), -7), 'body' => $this->body, 'parent_id' => $isSigned ? $this->parent_id : $this->parent?->id, diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 8f0fc157c..00d006ac6 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\CommitSource; use App\Enums\CommitStatus; use App\Queries\CommitBuilder; use App\Services\CommitBodies; @@ -29,6 +30,8 @@ * @property-read string|null $root_id * @property-read float $hot_score * @property-read string $body + * @property-read CommitSource|null $source + * @property-read bool $is_ai * @property-read array|null $mentions * @property-read CommitStatus $status * @property-read string|null $link_preview_id @@ -59,6 +62,8 @@ 'ai_decision', 'ai_categories', 'embedding', + 'source', + 'is_ai', ])] #[UseEloquentBuilder(CommitBuilder::class)] final class Commit extends Model @@ -88,10 +93,13 @@ final class Commit extends Model public const int PREVIEW_LINES = 8; + public bool $isFiltered = false; + /** * @var array */ protected $attributes = [ + 'is_ai' => false, 'pasteis_count' => 0, ]; @@ -113,6 +121,8 @@ public function casts(): array 'hot_score' => 'float', 'pasteis_count' => 'integer', 'body' => 'string', + 'source' => CommitSource::class, + 'is_ai' => 'boolean', 'mentions' => 'array', 'status' => CommitStatus::class, 'link_preview_id' => 'string', @@ -141,11 +151,47 @@ public function toArray(): array return array_intersect_key($this->attributesToArray(), array_flip($keys)); } + if ($this->isFiltered) { + return [ + ...parent::toArray(), + 'is_filtered' => true, + 'channels' => $this->channels(), + 'body' => '', + 'body_segments' => [], + 'preview_segments' => null, + 'mentions' => null, + 'images' => [], + 'poll' => null, + 'link_preview' => null, + 'link_preview_id' => null, + 'quoted_commit' => null, + 'translation' => null, + 'is_checking' => false, + ]; + } + if ($this->segments === null || $this->isDirty('body')) { $this->segments = resolve(CommitBodies::class)->segments($this->body); } - return [...parent::toArray(), ...$this->segments, 'is_checking' => $this->isBeingChecked()]; + return [...parent::toArray(), ...$this->segments, 'is_checking' => $this->isBeingChecked(), 'is_filtered' => false, 'channels' => $this->channels()]; + } + + /** @return list */ + public function channels(): array + { + if ($this->relationLoaded('user') && in_array($this->user?->github_id, User::BOT_GITHUB_IDS, true)) { + return $this->parent_id === null ? [] : ['bot']; + } + + return array_values(array_filter([$this->source?->value, $this->is_ai ? 'ai' : null])); + } + + public function isFilteredFor(?User $viewer): bool + { + return $viewer instanceof User && $viewer->id !== $this->user_id + && (($this->source !== null && in_array($this->source->value, $viewer->hidden_commit_channels, true)) + || ($this->is_ai && in_array('ai', $viewer->hidden_commit_channels, true))); } /** diff --git a/app/Models/User.php b/app/Models/User.php index db64ec385..60f387873 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -49,6 +49,7 @@ * @property-read string|null $translation_language * @property-read string|null $timezone * @property-read bool $show_local_time + * @property-read list $hidden_commit_channels * @property-read LivePlatform|null $live_platform * @property-read string|null $live_url * @property-read CarbonInterface|null $live_ends_at @@ -88,6 +89,7 @@ 'remember_token', 'translation_language', 'automatic_translation', + 'hidden_commit_channels', 'preferred_lane', 'lane_color', 'pasteis_mode', @@ -134,6 +136,7 @@ final class User extends Authenticatable * @var array */ protected $attributes = [ + 'hidden_commit_channels' => '[]', 'log' => Log::Following->value, 'translation_language' => null, 'show_local_time' => false, @@ -167,6 +170,7 @@ public static function botGitHubIdsArray(): string public function casts(): array { return [ + 'hidden_commit_channels' => 'array', 'id' => 'string', 'github_id' => 'string', 'username' => 'string', diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 4243256ec..00407eff8 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Pagination\Cursor; +use Illuminate\Support\Collection as BaseCollection; /** * @extends Builder @@ -28,6 +29,7 @@ final class CommitBuilder extends Builder private const array COLUMNS_BUT_EMBEDDING = [ 'id', 'user_id', 'body', 'created_at', 'updated_at', 'status', 'parent_id', 'deleted_at', 'link_preview_id', 'quoted_commit_id', 'mentions', 'ai_decision', 'ai_categories', 'root_id', 'hot_score', 'pasteis_count', + 'source', 'is_ai', ]; /** @@ -83,6 +85,32 @@ public function withoutMutedAuthorsFor(?User $user): self ->orWhereNotIn($this->qualifyColumn('user_id'), $user->mutedIds())) : $this; } + public function withoutFilteredChannelsFor(?User $viewer): self + { + if (! $viewer instanceof User || $viewer->hidden_commit_channels === []) { + return $this; + } + + $sources = array_values(array_intersect($viewer->hidden_commit_channels, ['cli', 'ci'])); + + return $this->where(fn (self $query): self => $query + ->where($this->qualifyColumn('user_id'), $viewer->id) + ->orWhere(fn (self $query): self => $query + ->when($sources !== [], fn (self $query): self => $query->where(fn (self $query): self => $query + ->whereNull($this->qualifyColumn('source')) + ->orWhereNotIn($this->qualifyColumn('source'), $sources))) + ->when(in_array('ai', $viewer->hidden_commit_channels, true), fn (self $query): self => $query->where($this->qualifyColumn('is_ai'), false)))); + } + + public function withVisibilityFor(?User $viewer): self + { + return $this->afterQuery(function (BaseCollection $commits) use ($viewer): void { + foreach ($commits->whereInstanceOf(Commit::class) as $commit) { + $commit->isFiltered = $commit->isFilteredFor($viewer); + } + }); + } + /** * Deleted commits stay in a thread as tombstones while they still have replies the given user can see. * Replies by muted people are left out along with the replies under them. @@ -194,7 +222,8 @@ public function withQuotedCommitFor(?User $viewer): self return $this->with([ 'quotedCommit' => fn (BelongsTo $query): BelongsTo => $query ->withTrashed() - ->where(fn (self $query): self => $query->visibleTo($viewer)) + ->visibleTo($viewer) + ->withVisibilityFor($viewer) ->with(['user', 'linkPreview', 'images']) ->withPollFor($viewer), ]); @@ -271,6 +300,7 @@ public function withPollFor(?User $viewer): self public function withCardDataFor(?User $viewer): self { return $this + ->withVisibilityFor($viewer) ->with(['user', 'linkPreview', 'images']) ->withQuotedCommitFor($viewer) ->withStarsFor($viewer) diff --git a/app/Queries/CommitSearchQuery.php b/app/Queries/CommitSearchQuery.php index 6e6ad442e..bab314e20 100644 --- a/app/Queries/CommitSearchQuery.php +++ b/app/Queries/CommitSearchQuery.php @@ -53,6 +53,7 @@ public function get(string $term, ?User $viewer, int $limit = 20): Collection $candidates = Commit::query() ->visibleTo($viewer) + ->withoutFilteredChannelsFor($viewer) ->withoutMutedAuthorsFor($viewer) ->whereNotNull('embedding') ->withVisibleReplyCount($viewer) diff --git a/app/Queries/LinkCommitsQuery.php b/app/Queries/LinkCommitsQuery.php index 2b06d0b8b..e6e9c1fd1 100644 --- a/app/Queries/LinkCommitsQuery.php +++ b/app/Queries/LinkCommitsQuery.php @@ -19,12 +19,13 @@ public function builder(LinkPreview $link, User $user): CommitBuilder return Commit::query() ->where('link_preview_id', $link->id) ->visibleTo($user) + ->withoutFilteredChannelsFor($user) ->withVisibleReplyCount($user) ->withReplyPreviews($user) ->withCardDataFor($user) ->with([ 'parent' => function (BelongsTo $parent) use ($user): void { - $parent->getQuery()->withTrashed()->visibleTo($user)->with('user'); + $parent->getQuery()->withTrashed()->visibleTo($user)->withVisibilityFor($user)->with('user'); }, ]) ->orderBy('commits.created_at')->orderBy('commits.id'); diff --git a/app/Queries/LogQueries/FollowingLogQuery.php b/app/Queries/LogQueries/FollowingLogQuery.php index f50c43748..2c315599a 100644 --- a/app/Queries/LogQueries/FollowingLogQuery.php +++ b/app/Queries/LogQueries/FollowingLogQuery.php @@ -25,6 +25,7 @@ public function builder(?User $user): CommitBuilder ->whereIn('user_id', $following->newPivotQuery()->select($following->getRelatedPivotKeyName())) ->orWhere('user_id', $user->id)) ->visibleTo($user) + ->withoutFilteredChannelsFor($user) ->withoutMutedAuthorsFor($user) ->topLevel() ->withVisibleReplyCount($user) diff --git a/app/Queries/LogQueries/LatestLogQuery.php b/app/Queries/LogQueries/LatestLogQuery.php index be849a996..65a4e870e 100644 --- a/app/Queries/LogQueries/LatestLogQuery.php +++ b/app/Queries/LogQueries/LatestLogQuery.php @@ -15,6 +15,7 @@ public function builder(?User $user): CommitBuilder { return Commit::query() ->visibleTo($user) + ->withoutFilteredChannelsFor($user) ->withoutMutedAuthorsFor($user) ->topLevel() ->withVisibleReplyCount($user) diff --git a/app/Queries/NotificationGroupsQuery.php b/app/Queries/NotificationGroupsQuery.php index 927458fb8..56756211c 100644 --- a/app/Queries/NotificationGroupsQuery.php +++ b/app/Queries/NotificationGroupsQuery.php @@ -121,7 +121,10 @@ private function groupsSince(User $user, CarbonInterface $since, string $type = private function visible(User $user): NotificationBuilder { - return Notification::query()->where('recipient_id', $user->id)->relevant()->select('notifications.*'); + return Notification::query()->where('recipient_id', $user->id)->relevant() + ->where(fn (Builder $query): Builder => $query->whereNull('commit_id') + ->orWhereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->withoutFilteredChannelsFor($user))) + ->select('notifications.*'); } private function group(NotificationBuilder $visible, string $type = 'all', ?Notification $member = null): NotificationBuilder diff --git a/app/Queries/PinnedCommitQuery.php b/app/Queries/PinnedCommitQuery.php index ee66f9a98..d266c56f9 100644 --- a/app/Queries/PinnedCommitQuery.php +++ b/app/Queries/PinnedCommitQuery.php @@ -21,6 +21,7 @@ public function get(User $user, ?User $viewer): ?Commit return Commit::query() ->whereBelongsTo($user) ->visibleTo($viewer) + ->withoutFilteredChannelsFor($viewer) ->topLevel() ->withVisibleReplyCount($viewer) ->withReplyPreviews($viewer) diff --git a/app/Queries/StarredCommitsQuery.php b/app/Queries/StarredCommitsQuery.php index 36189d501..e3cde46f5 100644 --- a/app/Queries/StarredCommitsQuery.php +++ b/app/Queries/StarredCommitsQuery.php @@ -18,12 +18,13 @@ public function builder(User $user): CommitBuilder ->select('commits.*') ->addSelect('stars.created_at as starred_order') ->visibleTo($user) + ->withoutFilteredChannelsFor($user) ->withVisibleReplyCount($user) ->withReplyPreviews($user) ->withCardDataFor($user) ->with([ 'parent' => function (BelongsTo $parent) use ($user): void { - $parent->getQuery()->withTrashed()->visibleTo($user)->with('user'); + $parent->getQuery()->withTrashed()->visibleTo($user)->withVisibilityFor($user)->with('user'); }, ]) ->orderByDesc('starred_order')->orderByDesc('commits.id'); diff --git a/app/Queries/SuggestedCommitsQuery.php b/app/Queries/SuggestedCommitsQuery.php index 0085e0771..cbcff8f30 100644 --- a/app/Queries/SuggestedCommitsQuery.php +++ b/app/Queries/SuggestedCommitsQuery.php @@ -17,6 +17,7 @@ public function get(User $user, int $limit = 3): Collection { return Commit::query() ->visibleTo($user) + ->withoutFilteredChannelsFor($user) ->withoutMutedAuthorsFor($user) ->topLevel() ->withStarsFor($user) diff --git a/app/Queries/UserCommitsQuery.php b/app/Queries/UserCommitsQuery.php index 6d0284520..85bd0f330 100644 --- a/app/Queries/UserCommitsQuery.php +++ b/app/Queries/UserCommitsQuery.php @@ -20,12 +20,13 @@ public function builder(User $user, ?User $viewer, bool $replies = false): Commi return Commit::query() ->whereBelongsTo($user) ->visibleTo($viewer) + ->withoutFilteredChannelsFor($viewer) ->when( $replies, fn (CommitBuilder $query): CommitBuilder => $query ->whereNotNull('parent_id') ->with(['parent' => fn (BelongsTo $query): BelongsTo => $query - ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($viewer)) + ->visibleTo($viewer)->withVisibilityFor($viewer) ->with('user')]), fn (CommitBuilder $query): CommitBuilder => $query->topLevel() ->when($user->pinned_commit_id, fn (CommitBuilder $query, string $id): CommitBuilder => $query->whereKeyNot($id)), diff --git a/app/Services/SignedCommits.php b/app/Services/SignedCommits.php index 5c3e9fee8..63eec1286 100644 --- a/app/Services/SignedCommits.php +++ b/app/Services/SignedCommits.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Actions\CreateCommitImages; +use App\Enums\CommitSource; use App\Models\User; use Closure; use InvalidArgumentException; @@ -15,7 +16,8 @@ * The signed payload is "\n\n\n". A commit * with images is signed under its own namespace, so the signature states which format it covers, and * carries one more line before the body: the comma-separated SHA-256 of each image, in the order - * they are attached, so the signature covers the images as well as the text. + * they are attached, so the signature covers the images as well as the text. Version two adds the source and + * AI declaration before the body, under distinct namespaces so removing the version cannot remove the declaration. */ final readonly class SignedCommits { @@ -23,6 +25,10 @@ public const string ImagesNamespace = 'devnull-commit-images'; + public const string SourceNamespace = 'devnull-commit-v2'; + + public const string ImagesSourceNamespace = 'devnull-commit-images-v2'; + public function __construct(private SshSignatures $sshSignatures) { // @@ -30,19 +36,35 @@ public function __construct(private SshSignatures $sshSignatures) /** * @param (Closure(): list)|null $imageHashes - * @return array{user: User, reply_to: string|null, body: string} + * @return array{user: User, reply_to: string|null, body: string, source: CommitSource|null, is_ai: bool} */ - public function verify(string $payload, string $signature, ?Closure $imageHashes = null): array + public function verify(string $payload, string $signature, ?Closure $imageHashes = null, bool $withSource = false): array { $withImages = $imageHashes instanceof Closure; - $publicKey = $this->sshSignatures->verify($payload, $signature, $withImages ? self::ImagesNamespace : self::Namespace); + $namespace = $withSource + ? ($withImages ? self::ImagesSourceNamespace : self::SourceNamespace) + : ($withImages ? self::ImagesNamespace : self::Namespace); + $publicKey = $this->sshSignatures->verify($payload, $signature, $namespace); - $lines = $withImages ? 5 : 4; + $lines = ($withImages ? 5 : 4) + ($withSource ? 2 : 0); $parts = explode("\n", $payload, $lines); throw_if(count($parts) !== $lines || ! mb_check_encoding($payload, 'UTF-8'), InvalidArgumentException::class, 'Malformed payload.'); + $source = null; + $isAi = false; + + if ($withSource) { + [$declaredSource, $declaredAi, $body] = array_splice($parts, $withImages ? 4 : 3); + $source = CommitSource::tryFrom($declaredSource); + + throw_if($source === null || ! in_array($declaredAi, ['0', '1'], true), InvalidArgumentException::class, 'Malformed payload.'); + + $isAi = $declaredAi === '1'; + $parts[] = $body; + } + if ($withImages) { [$timestamp, $nonce, $replyTo, $signedHashes, $body] = $parts; } else { @@ -76,6 +98,8 @@ public function verify(string $payload, string $signature, ?Closure $imageHashes 'user' => $user, 'reply_to' => $replyTo === '' ? null : $replyTo, 'body' => mb_trim($body), + 'source' => $source, + 'is_ai' => $isAi, ]; } } diff --git a/app/Services/TheAlgorithm.php b/app/Services/TheAlgorithm.php index 9fda9a430..f41901fdc 100644 --- a/app/Services/TheAlgorithm.php +++ b/app/Services/TheAlgorithm.php @@ -120,6 +120,7 @@ public function draw(?User $viewer, int $seed, float $temperature): CommitBuilde ->topLevel() ->where('commits.created_at', '>=', now()->subDays(self::CANDIDATE_MAX_AGE_DAYS)) ->visibleTo($viewer) + ->withoutFilteredChannelsFor($viewer) ->withoutMutedAuthorsFor($viewer) ->select('commits.id as drawn_id') ->selectRaw( diff --git a/database/migrations/2026_10_26_153931_add_origin_to_commits_and_visibility_to_users.php b/database/migrations/2026_10_26_153931_add_origin_to_commits_and_visibility_to_users.php new file mode 100644 index 000000000..fc168388e --- /dev/null +++ b/database/migrations/2026_10_26_153931_add_origin_to_commits_and_visibility_to_users.php @@ -0,0 +1,22 @@ +string('source')->nullable(); + $table->boolean('is_ai')->default(false); + }); + + Schema::table('users', function (Blueprint $table): void { + $table->json('hidden_commit_channels')->default('[]'); + }); + } +}; diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index d0e5a8e93..6307944d9 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -5,6 +5,7 @@ import { useCallback, useContext, useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import CommitLinkPreviewController from '@/actions/App/Http/Controllers/CommitLinkPreviewController'; import { FoldedCommitBody } from '@/components/commit-body'; +import { CommitChannelBadges } from '@/components/commit-channel-badges'; import { CommitImages } from '@/components/commit-images'; import { CommitPastelButton } from '@/components/commit-pastel-button'; import { CommitPoll } from '@/components/commit-poll'; @@ -19,6 +20,10 @@ import { useThread, } from '@/components/commit-thread'; import { useCommitTranslation } from '@/components/commit-translation'; +import { + FilteredCommit, + useCommitVisibility, +} from '@/components/filtered-commit'; import { GraphDimmed, GraphScope, @@ -69,7 +74,7 @@ import { show } from '@/routes/user'; import type { Commit, CursorPaginated, ThreadCommit } from '@/types'; export function CommitCard({ - commit, + commit: originalCommit, previousLane, isDimmed = false, isPreviousDimmed = false, @@ -116,6 +121,8 @@ export function CommitCard({ exceptReplyId?: string; threadPosition?: ThreadPosition; }) { + const visibility = useCommitVisibility(originalCommit); + const commit = visibility.commit; const { props: { auth }, component, @@ -382,6 +389,10 @@ export function CommitCard({ + + @@ -452,47 +463,64 @@ export function CommitCard({ replying to @{commit.parent.user.username} )} -
- -
- {translation.notice} - {showImages && } - {commit.poll && ( - - )} - {commit.quoted_commit_id !== null && ( - void visibility.reveal()} /> - )} - {commit.link_preview && - commit.link_preview.url !== - hiddenLinkPreviewUrl && ( - +
+ +
+ {translation.notice} + {showImages && ( + + )} + {commit.poll && ( + + )} + {commit.quoted_commit_id !== null && ( + + )} + {commit.link_preview && + commit.link_preview.url !== + hiddenLinkPreviewUrl && ( + )} - preview={commit.link_preview} - /> - )} + + )} {isFocused && (

} {commit.replies_count === undefined ? ( -

-
- - - - {translation.button} -
- -
- ) : ( - + !commit.is_filtered && ( +
+
{translation.button} - {threadActions} - +
+ +
+ ) + ) : ( + + + + + {translation.button} + {threadActions} + + ) } /> )} - {actions} + {!commit.is_filtered && actions} {deletion === 'deleting' && (

= { + cli: 'Posted from a terminal', + ci: 'Posted from a workflow', + ai: 'AI-generated content posted on the author’s behalf', + bot: 'An automated reply from a platform bot', +}; + +export function CommitChannelBadges({ + channels = [], +}: { + channels?: CommitChannel[]; +}) { + if (channels.length === 0) { + return null; + } + + return ( + + {channels.map((channel) => ( + + {channel} + + ))} + + ); +} diff --git a/resources/js/components/commit-rules.tsx b/resources/js/components/commit-rules.tsx index ae6015359..c84f47c72 100644 --- a/resources/js/components/commit-rules.tsx +++ b/resources/js/components/commit-rules.tsx @@ -23,7 +23,7 @@ const rules = [ 'One-word commits, "gm", "test", copy-pasted memes, and the same post over and over. Commit something a developer would want to read.', ], [ - 'No AI-generated posts', + 'No undisclosed AI-generated posts', "Write your own commits. Generated threads, hot takes, and summaries passed off as your own don't belong here. Sharing code or output you got from a tool is fine; say so.", ], [ diff --git a/resources/js/components/commit-thread.tsx b/resources/js/components/commit-thread.tsx index 39d084fd9..78fe35fe1 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -22,6 +22,7 @@ import CommitReplyController from '@/actions/App/Http/Controllers/CommitReplyCon import { ActionTooltip } from '@/components/action-tooltip'; import { BodyCounter } from '@/components/body-counter'; import { FoldedCommitBody } from '@/components/commit-body'; +import { CommitChannelBadges } from '@/components/commit-channel-badges'; import { ImageDropOverlay, ImagePickerButton, @@ -40,6 +41,10 @@ import { PreviewToggle, useComposerPreview, } from '@/components/composer-preview'; +import { + FilteredCommit, + useCommitVisibility, +} from '@/components/filtered-commit'; import { graphDash, graphStrokeWidth, @@ -570,6 +575,7 @@ export function ThreadActions({ }) { const { auth } = usePage().props; const { commit } = thread; + const isFiltered = !isDeleted(commit) && commit.is_filtered === true; const hasStash = useHasStash(auth.user?.id ?? null, `reply-${commit.id}`); const hasReplyShortcut = hasKeyboardShortcuts && !thread.isReplying; @@ -579,7 +585,7 @@ export function ThreadActions({

{actions} - {!isDeleted(commit) && ( + {!isDeleted(commit) && !isFiltered && ( - {!isDeleted(commit) && ( + {!isDeleted(commit) && !isFiltered && ( - {thread.isReplying && !isDeleted(commit) && ( + {thread.isReplying && !isDeleted(commit) && !isFiltered && ( void; onDeleted: () => void; }) { + const visibility = useCommitVisibility(originalReply); + const reply = visibility.commit; const { auth } = usePage().props; const now = useNow(); const hiddenLinkPreviewUrl = useContext(HiddenLinkPreviewContext); @@ -1213,6 +1221,7 @@ function CommitReply({ + {isOwn && ( @@ -1243,25 +1252,38 @@ function CommitReply({

)} -
- void visibility.reveal()} /> -
- {translation.notice} - - {reply.link_preview && - reply.link_preview.url !== hiddenLinkPreviewUrl && ( - +
+ +
+ {translation.notice} + + {reply.link_preview && + reply.link_preview.url !== + hiddenLinkPreviewUrl && ( + )} - preview={reply.link_preview} - /> - )} + + )} {isConfirming ? ( @@ -1288,19 +1310,21 @@ function CommitReply({
) : ( - - - {translation.button} - + !reply.is_filtered && ( + <> + + + {translation.button} + + ) } /> )} diff --git a/resources/js/components/filtered-commit.tsx b/resources/js/components/filtered-commit.tsx new file mode 100644 index 000000000..5d8f0ffd3 --- /dev/null +++ b/resources/js/components/filtered-commit.tsx @@ -0,0 +1,76 @@ +import { useHttp } from '@inertiajs/react'; +import { Eye } from 'lucide-react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { show } from '@/actions/App/Http/Controllers/CommitVisibilityController'; +import { Spinner } from '@/components/ui/spinner'; +import type { Commit } from '@/types'; + +export function useCommitVisibility< + T extends Pick, +>(original: T) { + const [revealed, setRevealed] = useState<{ + original: T; + commit: Commit; + } | null>(null); + const request = useHttp, Commit>({}); + + const reveal = async () => { + if (request.processing) { + return; + } + + try { + const commit = await request.submit(show(original.id)); + setRevealed({ original, commit }); + } catch { + toast.error('Could not show this commit. Try again.'); + } + }; + + return { + commit: + original.is_filtered && revealed?.original === original + ? { ...original, ...revealed.commit } + : original, + reveal, + processing: request.processing, + }; +} + +export function FilteredCommit({ + isReply = false, + processing, + onReveal, +}: { + isReply?: boolean; + processing: boolean; + onReveal: () => void; +}) { + return ( +
+

+ {isReply ? 'Reply' : 'Commit'} hidden by your visibility + settings. +

+ +
+ ); +} diff --git a/resources/js/components/page-tabs.tsx b/resources/js/components/page-tabs.tsx index da9c9c774..58604f6e4 100644 --- a/resources/js/components/page-tabs.tsx +++ b/resources/js/components/page-tabs.tsx @@ -9,12 +9,14 @@ export function PageTabs({ prefetch = true, only, reset, + scrollable = false, }: { label: string; preserveScroll?: boolean; prefetch?: boolean; only?: InertiaLinkProps['only']; reset?: string[]; + scrollable?: boolean; tabs: { title: string; href: NonNullable; @@ -24,7 +26,21 @@ export function PageTabs({ const indicatorName = `tab-indicator-${label.toLowerCase().replaceAll(/[^a-z0-9]+/gu, '-')}`; return ( -