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
2 changes: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ Sources: [Pastéis setting](resources/js/components/pasteis-mode-setting.tsx), [

### Notifications

Open **Notifications** to read in-app activity: follows, replies, quotes, mentions, stars, first commits of people you follow, and moderation messages about rejected commits (approvals do not notify). The navigation shows unread activity and checks for more every 30 seconds. While you have unread notifications or messages, the browser tab's icon shows an orange dot; a tab in the background checks about every five minutes. Related activity is grouped, with expandable details: stars per commit, follows per day, and replies and mentions per thread. Filters (Replies, Quotes, Stars, Mentions, Follows, Moderation) are remembered, and the unread count follows them. Opening the inbox marks all notifications read. You can follow back or unfollow from follower notifications.
Open **Notifications** to read in-app activity: follows, replies, quotes, mentions, stars, first commits of people you follow, and moderation messages about rejected commits (approvals do not notify). The navigation shows unread activity and checks for more every 30 seconds. While you have unread notifications or messages, the browser tab's icon shows an orange dot; a tab in the background checks about every five minutes. Related activity is grouped, with expandable details: stars per commit, follows per day, and replies and mentions per thread. Filters (Replies, Quotes, Stars, Mentions, Follows, Moderation) are remembered, and the unread count follows them. Opening the inbox marks all notifications read. You can follow back or unfollow from follower notifications, and star a reply, quote, or mention straight from its notification.

When someone you follow makes their first commit, you get a notification for it under **All**, so a newcomer's first post gets seen and answered. It comes once per person, after approval, and never for replies.

Expand Down
29 changes: 28 additions & 1 deletion app/Http/Controllers/NotificationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
namespace App\Http\Controllers;

use App\Http\Requests\ListNotificationsRequest;
use App\Models\Notification;
use App\Models\User;
use App\Queries\NotificationGroupsQuery;
use Carbon\CarbonImmutable;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Pagination\CursorPaginator as Paginator;
use Inertia\Inertia;
use Inertia\Response;

Expand All @@ -20,9 +22,34 @@ public function index(ListNotificationsRequest $request, #[CurrentUser] User $us
$type = $request->type($user);

return Inertia::render('notification/index', [
'notifications' => Inertia::scroll(fn (): CursorPaginator => $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 {
/** @var Paginator<int, Notification> $notifications */
$notifications = $query->withStarredCommitImages($query->withQuotedCommits($user, $query->paginate($user, $type, $request->through())));

$stars = $query->commitStars($user, $notifications->items());

// Replies, mentions and quotes can be starred from the inbox, so they say how starred they are.
return $notifications->through(fn (Notification $notification): array => $this->withCommitStars($notification, $stars));
})->matchOn('data.group_id'),
'filters' => ['type' => $type],
'readAt' => CarbonImmutable::now()->startOfSecond()->toJSON(),
]);
}

/**
* The notification as the page gets it, its commit saying how starred it is.
*
* @param array<string, array{stars_count: int, starred: bool}> $stars
* @return array<mixed>
*/
private function withCommitStars(Notification $notification, array $stars): array
{
$commit = $notification->commit;

if ($commit === null || ! isset($stars[$commit->id])) {
return $notification->toArray();
}

return [...$notification->toArray(), 'commit' => [...$commit->toArray(), ...$stars[$commit->id]]];
}
}
26 changes: 24 additions & 2 deletions app/Http/Controllers/NotificationGroupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,29 @@
{
public function show(ShowNotificationGroupRequest $request, #[CurrentUser] User $user, Notification $notification, NotificationGroupsQuery $query): JsonResponse
{
return response()->json($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();

$stars = $query->commitStars($user, $members->items());

// Expanded replies, mentions and quotes can be starred from the inbox too, so they say how starred they are.
return response()->json($members->through(fn (Notification $member): array => $this->withCommitStars($member, $stars)));
}

/**
* The notification as the inbox gets it, its commit saying how starred it is.
*
* @param array<string, array{stars_count: int, starred: bool}> $stars
* @return array<mixed>
*/
private function withCommitStars(Notification $notification, array $stars): array
{
$commit = $notification->commit;

if ($commit === null || ! isset($stars[$commit->id])) {
return $notification->toArray();
}

return [...$notification->toArray(), 'commit' => [...$commit->toArray(), ...$stars[$commit->id]]];
}
}
28 changes: 28 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,33 @@ public function withQuotedCommits(User $user, CursorPaginator $groups): CursorPa
return $groups;
}

/**
* How starred the actor's reply, mention or quote is and whether the user starred it, by the commit's ID: the
* inbox lets the user star it from the notification.
*
* @param iterable<int, Notification> $notifications
* @return array<string, array{stars_count: int, starred: bool}>
*/
public function commitStars(User $user, iterable $notifications): array
{
$commitIds = new Collection($notifications)
->whereIn('type', [NotificationType::Replied, NotificationType::Mentioned, NotificationType::Quoted])
->map(fn (Notification $notification): ?string => $notification->commit?->id)
->filter()
->all();

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' => (bool) $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.
Expand Down
12 changes: 10 additions & 2 deletions resources/js/components/commit-star-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,20 @@ function starLabel(starred: boolean, count: number): string {
return `${verb} commit, ${formatExactCount(count)} ${noun}`;
}

/**
* Only what starring reads, so a commit loaded without its full author, like a notification's, can be starred too.
*/
type StarrableCommit = Pick<
Commit,
'id' | 'status' | 'starred' | 'stars_count'
> & { author: { id: string } };

export function CommitStarButton({
commit,
keyShortcut,
className,
}: {
commit: Commit;
commit: StarrableCommit;
keyShortcut?: string;
className?: string;
}) {
Expand Down Expand Up @@ -211,7 +219,7 @@ function StarCount({
className,
}: {
ref?: Ref<HTMLButtonElement>;
commit: Commit;
commit: StarrableCommit;
count: number;
className?: string;
}) {
Expand Down
13 changes: 13 additions & 0 deletions resources/js/pages/notification/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ 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 { 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 @@ -1288,6 +1289,18 @@ export function NotificationContent({
)}
{excerpt}
{quote}
{isActorsCommit && !grouped && notification.commit && (
// Above the row's link, so starring doesn't open the commit.
<div className="relative z-10 mt-1 -ml-2 flex text-[13px]">
<CommitStarButton
commit={{
...notification.commit,
author: notification.actor,
}}
className="px-2 py-0.5"
/>
</div>
)}
{reason}
</div>
{notification.type === 'followed' &&
Expand Down
9 changes: 9 additions & 0 deletions tests/Feature/Controllers/Api/NotificationControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@
->assertJsonPath('meta.next_cursor', null);
});

it('leaves the stars of the commits out, as only the web inbox stars from a notification', function (): void {
Notification::factory()->for($this->user, 'recipient')->replied()->create();

signedApiRequest($this->keyPair, 'GET', route('api-notification.index'))
->assertOk()
->assertJsonMissingPath('data.0.commit.stars_count')
->assertJsonMissingPath('data.0.commit.starred');
});

it('hides notifications that no longer hold', function (): void {
$notification = Notification::factory()->for($this->user, 'recipient')->create();

Expand Down
30 changes: 30 additions & 0 deletions tests/Feature/Controllers/NotificationControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,36 @@
->where('notifications.data.0.commit.id', $mentioned->commit_id));
});

it('says how starred the reply is and whether the user starred it, so it can be starred from the inbox', function (): void {
$user = User::factory()->create();
$replied = Notification::factory()->for($user, 'recipient')->replied()->create();
$reply = Commit::query()->findOrFail($replied->commit_id);
resolve(CreateCommitStar::class)->handle($user, $reply);
resolve(CreateCommitStar::class)->handle(User::factory()->create(), $reply);

$response = $this->actingAs($user)->get(route('notification.index'));

$response->assertOk()
->assertInertia(fn ($page) => $page
->where('notifications.data.0.commit.id', $reply->id)
->where('notifications.data.0.commit.stars_count', 2)
->where('notifications.data.0.commit.starred', true));
});

it('says how starred an expanded reply is and whether the user starred it', function (): void {
$user = User::factory()->create();
$replied = Notification::factory()->for($user, 'recipient')->replied()->create();
$reply = Commit::query()->findOrFail($replied->commit_id);
resolve(CreateCommitStar::class)->handle($user, $reply);
resolve(CreateCommitStar::class)->handle(User::factory()->create(), $reply);

$this->actingAs($user)->getJson(route('notification-group.show', $replied))
->assertOk()
->assertJsonPath('data.0.commit.id', $reply->id)
->assertJsonPath('data.0.commit.stars_count', 2)
->assertJsonPath('data.0.commit.starred', true);
});

it('hides notifications that no longer hold', function (): void {
$user = User::factory()->create();
$notification = Notification::factory()->for($user, 'recipient')->create();
Expand Down
46 changes: 46 additions & 0 deletions tests/Unit/NotificationIndex.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ async function renderPage(page: () => ReactElement): Promise<string> {
props: {
auth: {
user: { id: 'recipient', handle: 'nunomaduro' },
organizations: [],
log: {},
},
errors: {},
Expand Down Expand Up @@ -490,3 +491,48 @@ test('shows an organization that replied, quoted or mentioned as itself: square,
assert.match(item, /href="\/@laravel"/);
assert.doesNotMatch(item, /Follow/);
});

const starrableReply = {
...notification,
commit: {
...notification.commit,
status: 'approved',
starred: false,
stars_count: 2,
},
} as NotificationGroup;

void test('lets a single reply be starred from the inbox, outside its link', async () => {
const body = await render([starrableReply]);

assert.match(body, /<button\b[^>]*data-test="star-commit-button"/);
assert.doesNotMatch(
body,
/<a\b(?:(?!<\/a>).)*data-test="star-commit-button"/s,
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

for (const type of ['mentioned', 'quoted']) {
void test(`lets a single ${type} commit be starred from the inbox`, async () => {
const body = await render([
{ ...starrableReply, type } as NotificationGroup,
]);

assert.match(body, /data-test="star-commit-button"/);
});
}

void test('offers no star on stars, groups or commits still in review', async () => {
for (const item of [
{ ...starrableReply, type: 'starred' },
{ ...starrableReply, actors_count: 2, event_count: 2 },
{
...starrableReply,
commit: { ...starrableReply.commit, status: 'pending' },
},
]) {
const body = await render([item as NotificationGroup]);

assert.doesNotMatch(body, /data-test="star-commit-button"/);
}
});