From 8fe35b9cc926d69600a5653a77a64bdc65095b64 Mon Sep 17 00:00:00 2001 From: richardkeep Date: Tue, 20 Oct 2026 13:25:08 +0300 Subject: [PATCH 1/7] New commit author avatars --- app/Http/Controllers/LogController.php | 5 ++++ app/Queries/LatestLogCommitsQuery.php | 20 +++++++++++++ resources/js/pages/log/show.tsx | 40 ++++++++++++++++++++------ tests/Feature/LatestLogAuthorsTest.php | 38 ++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 tests/Feature/LatestLogAuthorsTest.php diff --git a/app/Http/Controllers/LogController.php b/app/Http/Controllers/LogController.php index 35b718e24..878f767eb 100644 --- a/app/Http/Controllers/LogController.php +++ b/app/Http/Controllers/LogController.php @@ -54,6 +54,11 @@ public function show(ShowLogRequest $request, #[CurrentUser] ?User $user, Log $l return $seen instanceof Commit ? $latest->count($log, $user, $seen) : 0; }), + 'newCommitAuthors' => Inertia::optional(function () use ($request, $user, $log, $latest): array { + $seen = $request->since(); + + return $seen instanceof Commit ? $latest->authors($log, $user, $seen) : ['users' => [], 'total' => 0]; + }), 'suggestions' => fn (): Collection => $user instanceof User && $log === Log::Following && ! $user->following()->exists() ? $suggested->get($user) : new Collection(), diff --git a/app/Queries/LatestLogCommitsQuery.php b/app/Queries/LatestLogCommitsQuery.php index 26893bfa9..43a72b3c7 100644 --- a/app/Queries/LatestLogCommitsQuery.php +++ b/app/Queries/LatestLogCommitsQuery.php @@ -7,6 +7,7 @@ use App\Enums\Log; use App\Models\Commit; use App\Models\User; +use Illuminate\Support\Collection; /** * A ranked log does not put new commits on top, so it does not offer them either: they belong to the Latest log. The @@ -25,4 +26,23 @@ public function count(Log $log, ?User $user, Commit $since): int ->when($user, fn (CommitBuilder $query, User $user): CommitBuilder => $query->where($query->qualifyColumn('user_id'), '!=', $user->id)) ->count(); } + + /** + * @return array{users: Collection, total: int} + */ + public function authors(Log $log, ?User $user, Commit $since): array + { + if ($log->isRanked()) { + return ['users' => collect(), 'total' => 0]; + } + + $query = $log->query()->builder($user) + ->newerThan($since) + ->when($user, fn (CommitBuilder $query, User $user): CommitBuilder => $query->where($query->qualifyColumn('user_id'), '!=', $user->id)) + ->reorder()->toBase(); + $total = (clone $query)->distinct()->count('commits.user_id'); + $ids = $query->select('commits.user_id')->distinct()->orderBy('commits.user_id')->limit(5)->pluck('user_id'); + + return ['users' => User::query()->whereIn('id', $ids)->orderBy('id')->get()->toBase(), 'total' => $total]; + } } diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 393a4d866..50b33606d 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -1,6 +1,3 @@ -import { Head, Link, router, usePage, usePoll } from '@inertiajs/react'; -import { ArrowUp } from 'lucide-react'; -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import LogController from '@/actions/App/Http/Controllers/LogController'; import { CommitComposer } from '@/components/commit-composer'; import { useCommitDialogClosedCount } from '@/components/commit-composer-dialog'; @@ -17,19 +14,24 @@ import { useLastVisit } from '@/hooks/use-last-visit'; import AppLayout from '@/layouts/app-layout'; import { backgroundPollOptions } from '@/lib/background-poll'; import { emojify } from '@/lib/emoji'; -import type { Commit, CursorPaginated, Log } from '@/types'; +import type { Commit, CursorPaginated, Log, User } from '@/types'; +import { Head, Link, router, usePage, usePoll } from '@inertiajs/react'; +import { ArrowUp } from 'lucide-react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; export default function Show({ log, commits, isRanked, newCommitsCount = 0, + newCommitAuthors = { users: [], total: 0 }, suggestions, }: { log: Log; commits: CursorPaginated; isRanked: boolean; newCommitsCount?: number; + newCommitAuthors?: { users: User[]; total: number }; suggestions: Commit[]; }) { const { @@ -118,7 +120,7 @@ export default function Show({ const { stop: stopPolling } = usePoll( 30_000, backgroundPollOptions(() => ({ - only: ['newCommitsCount'], + only: ['newCommitsCount', 'newCommitAuthors'], data: { since: newestId }, preserveUrl: true, })), @@ -142,7 +144,7 @@ export default function Show({ const reloadNewestFirst = () => router.reload({ - only: ['commits', 'newCommitsCount'], + only: ['commits', 'newCommitsCount', 'newCommitAuthors'], reset: ['commits'], data: { since: '' }, preserveUrl: true, @@ -195,11 +197,31 @@ export default function Show({ )} diff --git a/tests/Feature/LatestLogAuthorsTest.php b/tests/Feature/LatestLogAuthorsTest.php new file mode 100644 index 000000000..6f8c4fe05 --- /dev/null +++ b/tests/Feature/LatestLogAuthorsTest.php @@ -0,0 +1,38 @@ +create(); + $seen = Commit::factory()->create(['created_at' => now()->subHour()]); + $authors = User::factory()->count(7)->create(); + foreach ($authors as $author) { + Commit::factory()->count(2)->for($author)->create(); + } + Commit::factory()->for($viewer)->create(); + $result = resolve(LatestLogCommitsQuery::class)->authors(Log::Latest, $viewer, $seen); + + expect($result['total'])->toBe(7)->and($result['users'])->toHaveCount(5); + foreach ($result['users'] as $author) { + expect($authors->modelKeys())->toContain($author->id); + } +}); + +it('only shows followed authors in Following and none in Trending', function (): void { + $viewer = User::factory()->create(); + $seen = Commit::factory()->create(['created_at' => now()->subHour()]); + $followed = User::factory()->create(); + $viewer->following()->attach($followed); + Commit::factory()->for($followed)->create(); + Commit::factory()->create(); + $query = resolve(LatestLogCommitsQuery::class); + $result = $query->authors(Log::Following, $viewer, $seen); + + expect($result['total'])->toBe(1)->and($result['users']->pluck('id')->all())->toBe([$followed->id]); + expect($query->authors(Log::Trending, $viewer, $seen)['total'])->toBe(0); +}); From e4fd9a94cab6febcffbc09adf458612ac133a75f Mon Sep 17 00:00:00 2001 From: richardkeep Date: Tue, 20 Oct 2026 13:29:43 +0300 Subject: [PATCH 2/7] fix lint --- tests/Feature/LatestLogAuthorsTest.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/Feature/LatestLogAuthorsTest.php b/tests/Feature/LatestLogAuthorsTest.php index 6f8c4fe05..d660ae873 100644 --- a/tests/Feature/LatestLogAuthorsTest.php +++ b/tests/Feature/LatestLogAuthorsTest.php @@ -11,16 +11,15 @@ $viewer = User::factory()->create(); $seen = Commit::factory()->create(['created_at' => now()->subHour()]); $authors = User::factory()->count(7)->create(); - foreach ($authors as $author) { + $authors->each(function (User $author): void { Commit::factory()->count(2)->for($author)->create(); - } + }); + Commit::factory()->for($viewer)->create(); $result = resolve(LatestLogCommitsQuery::class)->authors(Log::Latest, $viewer, $seen); expect($result['total'])->toBe(7)->and($result['users'])->toHaveCount(5); - foreach ($result['users'] as $author) { - expect($authors->modelKeys())->toContain($author->id); - } + expect($result['users']->pluck('id')->diff($authors->modelKeys()))->toBeEmpty(); }); it('only shows followed authors in Following and none in Trending', function (): void { From b90f75d46b2409836cd05548dc8679d59418f3fe Mon Sep 17 00:00:00 2001 From: richardkeep Date: Wed, 21 Oct 2026 09:32:00 +0300 Subject: [PATCH 3/7] wip --- .../Feature/Controllers/LogControllerTest.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/Feature/Controllers/LogControllerTest.php b/tests/Feature/Controllers/LogControllerTest.php index 9ac50ce2a..f7cf04a4e 100644 --- a/tests/Feature/Controllers/LogControllerTest.php +++ b/tests/Feature/Controllers/LogControllerTest.php @@ -739,3 +739,34 @@ ->getJson(route('log.show', Log::Following)) ->assertUnprocessable()->assertJsonValidationErrors('through'); }); + +it('returns distinct authors when polling for newer commits', function (): void { + $viewer = User::factory()->create(); + $older = Commit::factory()->create(['created_at' => now()->subMinute()]); + $author = User::factory()->create(); + Commit::factory()->count(2)->for($author)->create(); + Commit::factory()->for($viewer)->create(); + + $this->actingAs($viewer)->get(route('log.show', [Log::Latest, 'since' => $older->id]), [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => (string) resolve(HandleInertiaRequests::class)->version(request()), + 'X-Inertia-Partial-Component' => 'log/show', + 'X-Inertia-Partial-Data' => 'newCommitsCount,newCommitAuthors', + ])->assertOk() + ->assertJsonPath('props.newCommitsCount', 2) + ->assertJsonPath('props.newCommitAuthors.total', 1) + ->assertJsonCount(1, 'props.newCommitAuthors.users') + ->assertJsonPath('props.newCommitAuthors.users.0.id', $author->id); +}); + +it('clears author previews when reloading without a since cursor', function (): void { + $this->actingAs(User::factory()->create())->get(route('log.show', Log::Latest), [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => (string) resolve(HandleInertiaRequests::class)->version(request()), + 'X-Inertia-Partial-Component' => 'log/show', + 'X-Inertia-Partial-Data' => 'newCommitsCount,newCommitAuthors', + ])->assertOk() + ->assertJsonPath('props.newCommitsCount', 0) + ->assertJsonPath('props.newCommitAuthors.total', 0) + ->assertJsonPath('props.newCommitAuthors.users', []); +}); From a4195f3d182e4ac5237b6b383a0aebd9f5563649 Mon Sep 17 00:00:00 2001 From: richardkeep Date: Wed, 21 Oct 2026 09:42:28 +0300 Subject: [PATCH 4/7] wip --- resources/js/pages/log/show.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 50b33606d..ee94206af 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -198,9 +198,9 @@ export default function Show({ type="button" onClick={reloadNewestFirst} aria-label={`Show ${newCommitsCount} new ${newCommitsCount === 1 ? 'commit' : 'commits'}`} - className="pointer-events-auto mt-3 flex items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground shadow-[0_4px_14px_-4px_rgb(0_0_0/0.35)] transition-colors hover:bg-primary-hover motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-top-2" + className="pointer-events-auto mt-3 flex items-center gap-1 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground shadow-[0_4px_14px_-4px_rgb(0_0_0/0.35)] transition-colors hover:bg-primary-hover motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-top-2" > - + ))} {newCommitAuthors.total > 5 && ( - + +{newCommitAuthors.total - 5} )} From a2b781af2e31df98f090784cfa896a829b0c0a41 Mon Sep 17 00:00:00 2001 From: richardkeep Date: Wed, 21 Oct 2026 09:47:09 +0300 Subject: [PATCH 5/7] wip --- resources/js/pages/log/show.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index ee94206af..cbcefd9da 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -1,3 +1,6 @@ +import { Head, Link, router, usePage, usePoll } from '@inertiajs/react'; +import { ArrowUp } from 'lucide-react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import LogController from '@/actions/App/Http/Controllers/LogController'; import { CommitComposer } from '@/components/commit-composer'; import { useCommitDialogClosedCount } from '@/components/commit-composer-dialog'; @@ -15,9 +18,6 @@ import AppLayout from '@/layouts/app-layout'; import { backgroundPollOptions } from '@/lib/background-poll'; import { emojify } from '@/lib/emoji'; import type { Commit, CursorPaginated, Log, User } from '@/types'; -import { Head, Link, router, usePage, usePoll } from '@inertiajs/react'; -import { ArrowUp } from 'lucide-react'; -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; export default function Show({ log, @@ -215,12 +215,12 @@ export default function Show({ className="size-6 ring-2 ring-primary" /> ))} + {newCommitAuthors.total > 5 && ( + + +{newCommitAuthors.total - 5} + + )} - {newCommitAuthors.total > 5 && ( - - +{newCommitAuthors.total - 5} - - )} committed From 7ba5038ffd840ba6bfc3caea80dfe579a4996e5c Mon Sep 17 00:00:00 2001 From: richardkeep Date: Wed, 21 Oct 2026 10:03:41 +0300 Subject: [PATCH 6/7] UI tweaks --- resources/js/pages/log/show.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index cbcefd9da..f31bc53a6 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -203,10 +203,10 @@ export default function Show({ {newCommitAuthors.users - .slice(0, 5) + .slice(0, 4) .map((author) => ( ))} - {newCommitAuthors.total > 5 && ( - - +{newCommitAuthors.total - 5} + {newCommitAuthors.total > 4 && ( + + {newCommitAuthors.total - 4}+ )} From d4ba7c906d9f582d8d2c9d0d2ec8bbda25828293 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 24 Oct 2026 02:04:53 +0200 Subject: [PATCH 7/7] fix: keep new commit prompt stable across loading states --- resources/js/pages/log/show.tsx | 97 ++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 43 deletions(-) diff --git a/resources/js/pages/log/show.tsx b/resources/js/pages/log/show.tsx index 3128e4bff..0e50957f7 100644 --- a/resources/js/pages/log/show.tsx +++ b/resources/js/pages/log/show.tsx @@ -439,51 +439,62 @@ export default function Show({ loaded === 'leaving' && newCommitsCount === 0 && '-translate-y-2 opacity-0', - 'pointer-events-auto mt-3 flex min-h-11 max-w-[calc(100vw-2rem)] items-center gap-1.5 rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground shadow-[0_4px_14px_-4px_rgb(0_0_0/0.35)] transition-colors hover:bg-primary-hover motion-safe:animate-in motion-safe:transition-[opacity,translate,background-color] motion-safe:duration-450 motion-safe:fade-in motion-safe:slide-in-from-top-2', + 'pointer-events-auto mt-3 grid min-h-11 max-w-[calc(100vw-2rem)] items-center gap-1.5 rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground shadow-[0_4px_14px_-4px_rgb(0_0_0/0.35)] transition-colors hover:bg-primary-hover motion-safe:animate-in motion-safe:transition-[opacity,translate,background-color] motion-safe:duration-450 motion-safe:fade-in motion-safe:slide-in-from-top-2', )} > - {isFetching ? ( - - ) : ( - - )} - {isFetching ? ( - `Fetching ${announced === 1 ? 'commit' : `${announced} commits`}…` - ) : newCommitsCount === 0 ? ( - `${waiting === 1 ? '1 new commit' : `${waiting} new commits`} loaded${isAway ? ` · ${window.matchMedia('(pointer: coarse)').matches ? 'Tap' : 'Click'} to scroll up` : ''}` - ) : ( - <> - - {newCommitAuthors.users.map((author) => ( - - ))} - {newCommitAuthors.total > 5 && ( - - + - {Math.min( - newCommitAuthors.total - 5, - 99, - )} - {newCommitAuthors.total - 5 > 99 - ? '+' - : ''} - - )} - - committed - - )} + + + {`${Math.max(newCommitsCount, announced, waiting)} new commits loaded · Click to scroll up`} + + + {isFetching ? ( + + ) : ( + + )} + {isFetching ? ( + `Fetching ${announced === 1 ? 'commit' : `${announced} commits`}…` + ) : newCommitsCount === 0 ? ( + `${waiting === 1 ? '1 new commit' : `${waiting} new commits`} loaded${isAway ? ` · ${window.matchMedia('(pointer: coarse)').matches ? 'Tap' : 'Click'} to scroll up` : ''}` + ) : ( + <> + + {newCommitAuthors.users.map( + (author) => ( + + ), + )} + {newCommitAuthors.total > 5 && ( + + + + {Math.min( + newCommitAuthors.total - 5, + 99, + )} + {newCommitAuthors.total - 5 > 99 + ? '+' + : ''} + + )} + + committed + + )} + )}