diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php index ed0dda90e..5016aa424 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 00c2d4ee4..ad004cb5c 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 0054fbfda..fd1492ede 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 febea11a2..7d2e03776 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 notification's commit is, by the notification's 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->id] ?? null))); return $shown; } diff --git a/app/Queries/NotificationGroupsQuery.php b/app/Queries/NotificationGroupsQuery.php index 5bc772c5d..d91e57d0a 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,41 @@ public function withQuotedCommits(User $user, CursorPaginator $groups): CursorPa return $groups; } + /** + * 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 + { + $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 []; + } + + $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; + } + /** * 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 ddacc8d9f..2d0f9db13 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,18 @@ 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; + // 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, leveled_up: ChevronsUp, @@ -1322,6 +1359,7 @@ export function NotificationContent({ {excerpt} + {actions} ); @@ -1666,6 +1704,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 a3a875e6c..9876c90e3 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; @@ -127,6 +128,36 @@ ->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('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' => '']); @@ -138,7 +169,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 {