From 40acdea49f3b8d5a2add701b3036a6f89d5600bc Mon Sep 17 00:00:00 2001 From: StivenKN Date: Fri, 30 Oct 2026 04:58:12 +0000 Subject: [PATCH 1/3] feat: star and quote commits from notifications Replies, quotes, mentions and first commits in the inbox get the same star and quote buttons as a commit card. The web inbox and its group expansion now load each commit with whether the user starred it and its star count. The mobile API payload is unchanged. --- .../Controllers/NotificationController.php | 6 +++- .../NotificationGroupController.php | 6 ++-- .../Resources/ShownNotificationResource.php | 13 +++++++- app/Http/Resources/ShownStreams.php | 5 ++-- app/Queries/NotificationGroupsQuery.php | 30 +++++++++++++++++++ resources/js/pages/notification/index.tsx | 30 +++++++++++++++++++ .../NotificationControllerTest.php | 20 ++++++++++++- 7 files changed, 103 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php index ed0dda90ec..5016aa4247 100644 --- a/app/Http/Controllers/NotificationController.php +++ b/app/Http/Controllers/NotificationController.php @@ -21,7 +21,11 @@ public function index(ListNotificationsRequest $request, #[CurrentUser] User $us $type = $request->type($user); return Inertia::render('notification/index', [ - 'notifications' => Inertia::scroll(fn (): CursorPaginator => ShownStreams::notifications($query->withStarredCommitImages($query->withQuotedCommits($user, $query->paginate($user, $type, $request->through())))))->matchOn('data.group_id'), + 'notifications' => Inertia::scroll(function () use ($request, $user, $type, $query): CursorPaginator { + $groups = $query->withStarredCommitImages($query->withQuotedCommits($user, $query->paginate($user, $type, $request->through()))); + + return ShownStreams::notifications($groups, $query->commitStars($user, $groups->items())); + })->matchOn('data.group_id'), 'filters' => ['type' => $type], 'readAt' => CarbonImmutable::now()->startOfSecond()->toJSON(), ]); diff --git a/app/Http/Controllers/NotificationGroupController.php b/app/Http/Controllers/NotificationGroupController.php index 00c2d4ee41..ad004cb5c7 100644 --- a/app/Http/Controllers/NotificationGroupController.php +++ b/app/Http/Controllers/NotificationGroupController.php @@ -16,7 +16,9 @@ { public function show(ShowNotificationGroupRequest $request, #[CurrentUser] User $user, Notification $notification, NotificationGroupsQuery $query): JsonResponse { - return response()->json(ShownStreams::notifications($query->members($user, $notification, $request->type()) - ->with('commit')->latest()->latest('id')->cursorPaginate(20)->withQueryString())); + $members = $query->members($user, $notification, $request->type()) + ->with('commit')->latest()->latest('id')->cursorPaginate(20)->withQueryString(); + + return response()->json(ShownStreams::notifications($members, $query->commitStars($user, $members->items()))); } } diff --git a/app/Http/Resources/ShownNotificationResource.php b/app/Http/Resources/ShownNotificationResource.php index 0054fbfda8..fd1492edee 100644 --- a/app/Http/Resources/ShownNotificationResource.php +++ b/app/Http/Resources/ShownNotificationResource.php @@ -15,6 +15,14 @@ */ final class ShownNotificationResource extends JsonResource implements Arrayable { + /** + * @param array{stars_count: int, starred: bool}|null $commitStars how starred the commit is, when the inbox lets the user star it + */ + public function __construct(Notification $notification, private readonly ?array $commitStars = null) + { + parent::__construct($notification); + } + /** * @return array */ @@ -22,9 +30,12 @@ public function toArray(?Request $request = null): array { $notification = $this->resource; assert($notification instanceof Notification); + $shown = $notification->toArray(); + $commit = $shown['commit'] ?? null; return [ - ...$notification->toArray(), + ...$shown, + ...is_array($commit) && $this->commitStars !== null ? ['commit' => [...$commit, ...$this->commitStars]] : [], 'actor' => ShownStreams::profile($notification->actor), ...$notification->relationLoaded('actors') ? [ 'actors' => $notification->actors->map(fn (Profile $actor): array => ShownStreams::profile($actor))->all(), diff --git a/app/Http/Resources/ShownStreams.php b/app/Http/Resources/ShownStreams.php index febea11a22..3e4eb7bd6a 100644 --- a/app/Http/Resources/ShownStreams.php +++ b/app/Http/Resources/ShownStreams.php @@ -114,12 +114,13 @@ public static function page(CursorPaginator $page): CursorPaginator /** * @param CursorPaginator $page + * @param array $commitStars how starred each commit is, by its ID * @return CursorPaginator */ - public static function notifications(CursorPaginator $page): CursorPaginator + public static function notifications(CursorPaginator $page, array $commitStars): CursorPaginator { $shown = clone $page; - $shown->setCollection($page->getCollection()->map(fn (Notification $notification): ShownNotificationResource => new ShownNotificationResource($notification))); + $shown->setCollection($page->getCollection()->map(fn (Notification $notification): ShownNotificationResource => new ShownNotificationResource($notification, $commitStars[$notification->commit_id ?? ''] ?? null))); return $shown; } diff --git a/app/Queries/NotificationGroupsQuery.php b/app/Queries/NotificationGroupsQuery.php index 5bc772c5d4..bc13aca9b7 100644 --- a/app/Queries/NotificationGroupsQuery.php +++ b/app/Queries/NotificationGroupsQuery.php @@ -5,6 +5,7 @@ namespace App\Queries; use App\Enums\NotificationType; +use App\Models\Commit; use App\Models\Notification; use App\Models\Profile; use App\Models\User; @@ -74,6 +75,35 @@ public function withQuotedCommits(User $user, CursorPaginator $groups): CursorPa return $groups; } + /** + * How many stars each commit an actor wrote has and whether the user starred it, keyed by the commit's ID, so the + * inbox can star and quote it from the notification. Stars, approvals and rejections are about the user's own + * commits, so those are left out. + * + * @param iterable $notifications + * @return array + */ + public function commitStars(User $user, iterable $notifications): array + { + $commitIds = new Collection($notifications) + ->whereIn('type', [NotificationType::Replied, NotificationType::Quoted, NotificationType::Mentioned, NotificationType::FirstCommit]) + ->pluck('commit_id') + ->filter() + ->unique() + ->all(); + + if ($commitIds === []) { + return []; + } + + return Commit::query()->whereKey($commitIds)->select('commits.id')->withStarsFor($user)->get() + ->mapWithKeys(fn (Commit $commit): array => [$commit->id => [ + 'stars_count' => (int) $commit->stars_count, + 'starred' => $commit->starred, + ]]) + ->all(); + } + /** * A star is always on the recipient's own commit, or the organization's they heard about it for, so its images may * show beside it: an image-only commit has nothing else to show. diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx index ddacc8d9f4..2a200151a1 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -39,6 +39,8 @@ import { AuthorAvatar } from '@/components/author-avatar'; import { CommitBodyExcerpt } from '@/components/commit-body'; import { EmptyFeed } from '@/components/commit-feed'; import { replaceFailedImage } from '@/components/commit-images'; +import { CommitQuoteButton } from '@/components/commit-quote-button'; +import { CommitStarButton } from '@/components/commit-star-button'; import { FollowIconButton } from '@/components/follow-button'; import { GraphCanvas } from '@/components/graph-canvas'; import { GraphRail, RailSegment } from '@/components/graph-rail'; @@ -70,6 +72,7 @@ import type { Actor, BodySegment, Commit, + CommitAuthor, CursorPaginated, DeletedCommit, Notification, @@ -1080,6 +1083,28 @@ function QuotedOwnCommit({ return ; } +/** + * Stars and quotes the actor's commit right from the notification, as a commit card would. The actor stands in for + * the commit's author, which the inbox doesn't load: replies, quotes and mentions come from the author, and a first + * commit from whoever posted it. + */ +function CommitActions({ + commit, + author, +}: { + commit: Omit; + author: CommitAuthor; +}) { + const card = { ...commit, author }; + + return ( +
+ + +
+ ); +} + export function ActorAvatar({ user, anchor, @@ -1176,6 +1201,9 @@ export function NotificationContent({ notification.type === 'mentioned' || notification.type === 'first_commit'; const level = notification.type === 'leveled_up' ? notification : null; + const actions = isActorsCommit && ( + + ); const Icon = { followed: UserPlus, leveled_up: ChevronsUp, @@ -1322,6 +1350,7 @@ export function NotificationContent({ {excerpt} + {actions} ); @@ -1666,6 +1695,7 @@ export function NotificationContent({ {excerpt} {quote} {reason} + {actions} {notification.type === 'followed' && actorsCount === 1 && diff --git a/tests/Feature/Controllers/NotificationControllerTest.php b/tests/Feature/Controllers/NotificationControllerTest.php index a3a875e6c1..9921fcd299 100644 --- a/tests/Feature/Controllers/NotificationControllerTest.php +++ b/tests/Feature/Controllers/NotificationControllerTest.php @@ -127,6 +127,23 @@ ->missing('notifications.data.0.position')); }); +it('says how starred the commit of a notification is and whether the user starred it, so they can star it from there', function (bool $starred): void { + $user = User::factory()->create(); + $replied = Notification::factory()->for($user, 'recipient')->replied()->create(); + $replied->commit->stars()->attach([User::factory()->create()->id, ...$starred ? [$user->id] : []]); + $count = $starred ? 2 : 1; + + $this->actingAs($user)->get(route('notification.index')) + ->assertInertia(fn ($page) => $page + ->where('notifications.data.0.commit.starred', $starred) + ->where('notifications.data.0.commit.stars_count', $count)); + + $this->getJson(route('notification-group.show', $replied)) + ->assertOk() + ->assertJsonPath('data.0.commit.starred', $starred) + ->assertJsonPath('data.0.commit.stars_count', $count); +})->with(['starred' => true, 'not starred' => false]); + it('shows the images of a starred commit', function (): void { $user = User::factory()->create(); $commit = Commit::factory()->for($user)->create(['body' => '']); @@ -138,7 +155,8 @@ $response->assertInertia(fn ($page) => $page ->where('notifications.data.0.type', 'starred') ->has('notifications.data.0.commit.images', 1) - ->where('notifications.data.0.commit.images.0.url', route('commit-image.show', $image))); + ->where('notifications.data.0.commit.images.0.url', route('commit-image.show', $image)) + ->missing('notifications.data.0.commit.starred')); }); it('shows the level reached with its rank, in all notifications only', function (): void { From f21497527ab8b5a5952f3b1ece9beeb7999c5f3f Mon Sep 17 00:00:00 2001 From: StivenKN Date: Thu, 5 Nov 2026 17:26:02 +0000 Subject: [PATCH 2/3] fix: quote and star an open notification group's newest commit as its own author --- resources/js/pages/notification/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx index 2a200151a1..4ccf667754 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -1201,8 +1201,11 @@ export function NotificationContent({ notification.type === 'mentioned' || notification.type === 'first_commit'; const level = notification.type === 'leveled_up' ? notification : null; + // An open group keeps naming who it led with, but its commit is still the newest one, from `actor_id`. + const commitAuthor = + actors.find((member) => member.id === notification.actor_id) ?? actor; const actions = isActorsCommit && ( - + ); const Icon = { followed: UserPlus, From 2701de2ced2f1b002c5902f7b17f59f4d7b52836 Mon Sep 17 00:00:00 2001 From: StivenKN Date: Thu, 5 Nov 2026 17:42:42 +0000 Subject: [PATCH 3/3] fix: keep star state on the notification it is for, and a quote on the commit it was started for --- app/Http/Resources/ShownStreams.php | 4 +-- app/Queries/NotificationGroupsQuery.php | 26 ++++++++++++------- resources/js/pages/notification/index.tsx | 8 +++++- .../NotificationControllerTest.php | 14 ++++++++++ 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/app/Http/Resources/ShownStreams.php b/app/Http/Resources/ShownStreams.php index 3e4eb7bd6a..7d2e03776a 100644 --- a/app/Http/Resources/ShownStreams.php +++ b/app/Http/Resources/ShownStreams.php @@ -114,13 +114,13 @@ public static function page(CursorPaginator $page): CursorPaginator /** * @param CursorPaginator $page - * @param array $commitStars how starred each commit is, by its ID + * @param array $commitStars how starred each notification's commit is, by the notification's ID * @return CursorPaginator */ public static function notifications(CursorPaginator $page, array $commitStars): CursorPaginator { $shown = clone $page; - $shown->setCollection($page->getCollection()->map(fn (Notification $notification): ShownNotificationResource => new ShownNotificationResource($notification, $commitStars[$notification->commit_id ?? ''] ?? null))); + $shown->setCollection($page->getCollection()->map(fn (Notification $notification): ShownNotificationResource => new ShownNotificationResource($notification, $commitStars[$notification->id] ?? null))); return $shown; } diff --git a/app/Queries/NotificationGroupsQuery.php b/app/Queries/NotificationGroupsQuery.php index bc13aca9b7..d91e57d0a2 100644 --- a/app/Queries/NotificationGroupsQuery.php +++ b/app/Queries/NotificationGroupsQuery.php @@ -76,32 +76,38 @@ public function withQuotedCommits(User $user, CursorPaginator $groups): CursorPa } /** - * How many stars each commit an actor wrote has and whether the user starred it, keyed by the commit's ID, so the - * inbox can star and quote it from the notification. Stars, approvals and rejections are about the user's own - * commits, so those are left out. + * How many stars the commit an actor wrote has and whether the user starred it, keyed by the notification's ID, so + * the inbox can star and quote it from the notification. Stars, approvals and rejections are about the user's own + * commits, so those are left out, even when they share a commit with a notification that is not. * * @param iterable $notifications * @return array */ public function commitStars(User $user, iterable $notifications): array { - $commitIds = new Collection($notifications) - ->whereIn('type', [NotificationType::Replied, NotificationType::Quoted, NotificationType::Mentioned, NotificationType::FirstCommit]) - ->pluck('commit_id') - ->filter() - ->unique() - ->all(); + $actorsCommits = new Collection($notifications) + ->whereIn('type', [NotificationType::Replied, NotificationType::Quoted, NotificationType::Mentioned, NotificationType::FirstCommit]); + $commitIds = $actorsCommits->pluck('commit_id')->filter()->unique()->all(); if ($commitIds === []) { return []; } - return Commit::query()->whereKey($commitIds)->select('commits.id')->withStarsFor($user)->get() + $stars = Commit::query()->whereKey($commitIds)->select('commits.id')->withStarsFor($user)->get() ->mapWithKeys(fn (Commit $commit): array => [$commit->id => [ 'stars_count' => (int) $commit->stars_count, 'starred' => $commit->starred, ]]) ->all(); + $shown = []; + + foreach ($actorsCommits as $notification) { + if ($notification->commit_id !== null && isset($stars[$notification->commit_id])) { + $shown[$notification->id] = $stars[$notification->commit_id]; + } + } + + return $shown; } /** diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx index 4ccf667754..2d0f9db134 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -1204,8 +1204,14 @@ export function NotificationContent({ // An open group keeps naming who it led with, but its commit is still the newest one, from `actor_id`. const commitAuthor = actors.find((member) => member.id === notification.actor_id) ?? actor; + // Keyed by the commit, so when a newer commit takes over the group, a quote being written closes and its draft + // stays stashed for the commit it was started on. const actions = isActorsCommit && ( - + ); const Icon = { followed: UserPlus, diff --git a/tests/Feature/Controllers/NotificationControllerTest.php b/tests/Feature/Controllers/NotificationControllerTest.php index 9921fcd299..9876c90e3f 100644 --- a/tests/Feature/Controllers/NotificationControllerTest.php +++ b/tests/Feature/Controllers/NotificationControllerTest.php @@ -4,6 +4,7 @@ use App\Actions\CreateCommitStar; use App\Actions\DeleteUserFollow; +use App\Enums\NotificationType; use App\Models\Commit; use App\Models\CommitImage; use App\Models\Notification; @@ -144,6 +145,19 @@ ->assertJsonPath('data.0.commit.stars_count', $count); })->with(['starred' => true, 'not starred' => false]); +it('leaves the stars out of a star notification, even on a commit a reply in the inbox shares', function (): void { + $user = User::factory()->create(); + $replied = Notification::factory()->for($user, 'recipient')->replied()->create(['created_at' => now()->subMinute()]); + Notification::factory()->for($user, 'recipient')->create(['type' => NotificationType::Starred, 'commit_id' => $replied->commit_id]); + + $this->actingAs($user)->get(route('notification.index')) + ->assertInertia(fn ($page) => $page + ->where('notifications.data.0.type', 'starred') + ->missing('notifications.data.0.commit.starred') + ->where('notifications.data.1.type', 'replied') + ->where('notifications.data.1.commit.starred', false)); +}); + it('shows the images of a starred commit', function (): void { $user = User::factory()->create(); $commit = Commit::factory()->for($user)->create(['body' => '']);