Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/Http/Controllers/NotificationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]);
Expand Down
6 changes: 4 additions & 2 deletions app/Http/Controllers/NotificationGroupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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())));
}
}
13 changes: 12 additions & 1 deletion app/Http/Resources/ShownNotificationResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,27 @@
*/
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<mixed>
*/
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(),
Expand Down
5 changes: 3 additions & 2 deletions app/Http/Resources/ShownStreams.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,13 @@ public static function page(CursorPaginator $page): CursorPaginator

/**
* @param CursorPaginator<int, Notification> $page
* @param array<string, array{stars_count: int, starred: bool}> $commitStars how starred each notification's commit is, by the notification's ID
* @return CursorPaginator<int, ShownNotificationResource>
*/
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;
}
Expand Down
36 changes: 36 additions & 0 deletions app/Queries/NotificationGroupsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<int, Notification> $notifications
* @return array<string, array{stars_count: int, starred: bool}>
*/
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.
Expand Down
39 changes: 39 additions & 0 deletions resources/js/pages/notification/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -70,6 +72,7 @@ import type {
Actor,
BodySegment,
Commit,
CommitAuthor,
CursorPaginated,
DeletedCommit,
Notification,
Expand Down Expand Up @@ -1080,6 +1083,28 @@ function QuotedOwnCommit({
return <OwnCommitExcerpt segments={commit.body_segments} />;
}

/**
* 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<Commit, 'author' | 'quoted_commit'>;
author: CommitAuthor;
}) {
const card = { ...commit, author };

return (
<div className="relative z-10 mt-1 -ml-2 flex w-fit items-center gap-0.5 text-[13px] not-has-[button]:hidden">
<CommitStarButton commit={card} className="px-2 py-0.5" />
<CommitQuoteButton commit={card} />
</div>
);
}

export function ActorAvatar({
user,
anchor,
Expand Down Expand Up @@ -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 && (
<CommitActions
key={notification.commit.id}
commit={notification.commit}
author={commitAuthor}
Comment thread
StivenKN marked this conversation as resolved.
Resolved
/>
);
const Icon = {
followed: UserPlus,
leveled_up: ChevronsUp,
Expand Down Expand Up @@ -1322,6 +1359,7 @@ export function NotificationContent({
<Link href={href} prefetch tabIndex={-1} className="block">
{excerpt}
</Link>
{actions}
</div>
</div>
);
Expand Down Expand Up @@ -1666,6 +1704,7 @@ export function NotificationContent({
{excerpt}
{quote}
{reason}
{actions}
</div>
{notification.type === 'followed' &&
actorsCount === 1 &&
Expand Down
34 changes: 33 additions & 1 deletion tests/Feature/Controllers/NotificationControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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' => '']);
Expand All @@ -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 {
Expand Down