Skip to content
Merged
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: 4 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,15 +320,17 @@ Sources: [Pastéis setting](resources/js/components/pasteis-mode-setting.tsx), [

### Notifications

Open **Notifications** to read in-app activity: follows, replies, quotes, mentions, stars, 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.

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.

Level-up notifications announce your new Unix rank under **All**, rather than one of the activity filters. Each level can notify you only once, even if you later lose and regain enough XP to reach it.

On phones, **Replies**, **Quotes**, and **Stars** share one row with a **More notification filters** (`…`) button for **Mentions**, **Follows**, and **Moderation**. The menu stays open while you toggle filters so you can change several at once.

Pending replies, quotes, and mentions wait for approval before notifying others. Self-actions do not notify you; mentioning the parent or quoted author does not duplicate their reply or quote notification. A new follower notifies only once even if they unfollow and follow again. Deleted or unavailable sources, mutes, and blocks affect which notifications remain visible. Rejected commits can produce a moderation message with a reason, without revealing a moderator's identity.

Sources: [notifications page](resources/js/pages/notification/index.tsx), [notification filtering](app/Queries/NotificationBuilder.php), [grouping](app/Queries/NotificationGroupsQuery.php), [moderation notifications](app/Actions/UpdateCommitStatuses.php).
Sources: [notifications page](resources/js/pages/notification/index.tsx), [notification filtering](app/Queries/NotificationBuilder.php), [grouping](app/Queries/NotificationGroupsQuery.php), [moderation notifications](app/Actions/UpdateCommitStatuses.php), [first commit notifications](app/Actions/CreateFirstCommitNotifications.php).

Remotes alerts are controlled under **Settings → Profile → Remotes**. **On-site notifications** and **Notification sound** are on by default. On-site alerts stay inside /dev/null and never ask for browser permission. Sound does not require notification permission and plays only while the site is running; the browser may require interaction before it can play.

Expand Down
2 changes: 2 additions & 0 deletions app/Actions/CreateCommit.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public function __construct(
private CreateReplyNotifications $createReplyNotifications,
private CreateQuoteNotifications $createQuoteNotifications,
private CreateMentionNotifications $createMentionNotifications,
private CreateFirstCommitNotifications $createFirstCommitNotifications,
private CreateCommitImages $createCommitImages,
private CreateWorldReply $createWorldReply,
private UpdateCommitHotScores $updateCommitHotScores,
Expand Down Expand Up @@ -85,6 +86,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit
$this->createReplyNotifications->handle($commit);
$this->createQuoteNotifications->handle($commit);
$this->createMentionNotifications->handle($commit);
$this->createFirstCommitNotifications->handle($commit);

if ($linkPreview instanceof LinkPreview && $linkPreview->fetched_at === null) {
dispatch(new UnfurlLink($linkPreview));
Expand Down
83 changes: 83 additions & 0 deletions app/Actions/CreateFirstCommitNotifications.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\CommitStatus;
use App\Enums\NotificationType;
use App\Models\Commit;
use App\Models\Notification;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Support\Str;

final readonly class CreateFirstCommitNotifications
{
/**
* Tells the author's followers about their first commit, so it gets an answer: a newcomer's followers mostly
* came from GitHub, and never chose to follow them here. Only once the commit is approved, and only for the
* author's first top-level commit that was ever approved. Never from or to bots, nor to the people it mentions
* or quotes, who already hear about it. Nobody hears from people they mute or from across a block. Safe to call
* more than once.
*/
public function handle(Commit ...$commits): void
{
// A reply is never a first commit, so replies, most of what gets committed, cost no query.
$topLevelIds = array_values(array_map(
fn (Commit $commit): string => $commit->id,
array_filter($commits, fn (Commit $commit): bool => $commit->parent_id === null),
));

if ($topLevelIds === []) {
return;
}

$firsts = Commit::query()
->whereKey($topLevelIds)
->where('status', CommitStatus::Approved)
->whereNull('parent_id')
->whereHas('user', fn (Builder $user): Builder => $user->whereNotIn('github_id', User::BOT_GITHUB_IDS))
// Deleted commits count too: whoever committed before isn't making their first commit.
->whereNotExists(fn (QueryBuilder $earlier): QueryBuilder => $earlier
->from('commits as earlier')
->whereColumn('earlier.user_id', 'commits.user_id')
->whereNull('earlier.parent_id')
->where('earlier.status', CommitStatus::Approved)
->whereColumn('earlier.created_at', '<', 'commits.created_at'))
->with(['user', 'quotedCommit'])
->get();

$notifications = [];

foreach ($firsts as $commit) {
$author = $commit->user;
assert($author instanceof User);

$followerIds = $author->followers()
->whereNotIn('users.github_id', User::BOT_GITHUB_IDS)
->whereNotIn('users.id', $author->mutedBy()->newPivotQuery()->select('muter_id'))
->whereNotIn('users.id', $author->idsAcrossBlocks())
->whereNotIn('users.id', [...array_values($commit->mentions ?? []), ...array_filter([$commit->quotedCommit?->user_id])])
->pluck('users.id');

foreach ($followerIds as $followerId) {
$notifications[] = [
'id' => Str::uuid7()->toString(),
'recipient_id' => $followerId,
'actor_id' => $author->id,
'type' => NotificationType::FirstCommit->value,
'commit_id' => $commit->id,
'key' => 'first_commit:'.$author->id,
'created_at' => now(),
'updated_at' => now(),
];
}
}

foreach (array_chunk($notifications, 500) as $chunk) {
Notification::query()->insertOrIgnore($chunk);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
2 changes: 2 additions & 0 deletions app/Actions/UpdateCommitStatuses.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public function __construct(
private CreateReplyNotifications $createReplyNotifications,
private CreateQuoteNotifications $createQuoteNotifications,
private CreateMentionNotifications $createMentionNotifications,
private CreateFirstCommitNotifications $createFirstCommitNotifications,
private CreateWorldReply $createWorldReply,
private UpdateCommitHotScores $updateCommitHotScores,
private TomQuery $tomQuery,
Expand Down Expand Up @@ -82,6 +83,7 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason =
$this->createReplyNotifications->handle(...$commits);
$this->createQuoteNotifications->handle(...$commits);
$this->createMentionNotifications->handle(...$commits);
$this->createFirstCommitNotifications->handle(...$commits);

$commits->each(fn (Commit $commit) => $this->createWorldReply->handle($commit));

Expand Down
1 change: 1 addition & 0 deletions app/Enums/NotificationType.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ enum NotificationType: string
case Quoted = 'quoted';
case Starred = 'starred';
case LeveledUp = 'leveled_up';
case FirstCommit = 'first_commit';
}
2 changes: 1 addition & 1 deletion app/Queries/NotificationBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public function relevant(): self
->whereColumn('follows.follower_id', 'notifications.actor_id')
->whereColumn('follows.followed_id', 'notifications.recipient_id')))
->orWhere(fn (Builder $query): Builder => $query
->whereIn('type', [NotificationType::Replied, NotificationType::Quoted, NotificationType::Mentioned, NotificationType::Starred])
->whereIn('type', [NotificationType::Replied, NotificationType::Quoted, NotificationType::Mentioned, NotificationType::Starred, NotificationType::FirstCommit])
->whereHas('commit', fn (CommitBuilder $commit): CommitBuilder => $commit
->where('status', CommitStatus::Approved)))
->orWhere(fn (Builder $query): Builder => $query
Expand Down
6 changes: 5 additions & 1 deletion resources/js/pages/notification/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Check,
ChevronDown,
ChevronsUp,
GitCommitVertical,
MessageSquareReply,
Quote,
Star,
Expand Down Expand Up @@ -756,7 +757,8 @@ export function NotificationContent({
const isActorsCommit =
notification.type === 'replied' ||
notification.type === 'quoted' ||
notification.type === 'mentioned';
notification.type === 'mentioned' ||
notification.type === 'first_commit';
const level = notification.type === 'leveled_up' ? notification : null;
const Icon = {
followed: UserPlus,
Expand All @@ -765,6 +767,7 @@ export function NotificationContent({
quoted: Quote,
starred: Star,
mentioned: AtSign,
first_commit: GitCommitVertical,
approved: Check,
rejected: X,
}[notification.type];
Expand Down Expand Up @@ -804,6 +807,7 @@ export function NotificationContent({
? `starred your ${starredImages.length === 1 ? 'image' : 'images'}`
: 'starred your commit',
mentioned: 'mentioned you',
first_commit: 'made their first commit',
approved: 'Your commit was approved',
rejected: 'Your commit was rejected',
leveled_up: level && `You reached level ${level.level}: ${level.rank}`,
Expand Down
2 changes: 1 addition & 1 deletion resources/js/types/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export type Notification =
};
})
| (BaseNotification & {
type: 'mentioned' | 'approved' | 'rejected';
type: 'mentioned' | 'first_commit' | 'approved' | 'rejected';
commit_id: string;
subject_id: null;
commit: Omit<Commit, 'user'>;
Expand Down
14 changes: 14 additions & 0 deletions tests/Unit/Actions/CreateCommitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@
expect($parent->user->notifications()->sole()->commit_id)->toBe($reply->id);
});

it("tells the author's followers about their first commit", function (): void {
config(['app.admins' => ['nunomaduro']]);

$author = User::factory()->create(['handle' => 'nunomaduro']);
$follower = User::factory()->create();
$author->followers()->attach($follower);

$commit = resolve(CreateCommit::class)->handle($author, 'Hello world!');

expect($follower->notifications()->sole())
->type->toBe(NotificationType::FirstCommit)
->commit_id->toBe($commit->id);
});

it('does not notify about pending replies', function (): void {
$parent = Commit::factory()->create();

Expand Down
144 changes: 144 additions & 0 deletions tests/Unit/Actions/CreateFirstCommitNotificationsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

declare(strict_types=1);

use App\Actions\CreateFirstCommitNotifications;
use App\Actions\CreateTomFriendship;
use App\Enums\NotificationType;
use App\Models\Commit;
use App\Models\Notification;
use App\Models\User;
use Illuminate\Support\Facades\DB;

it("notifies the author's followers about their first commit", function (): void {
$author = User::factory()->create();
$followers = User::factory()->count(2)->create();
$author->followers()->attach($followers);
$commit = Commit::factory()->for($author)->create();

resolve(CreateFirstCommitNotifications::class)->handle($commit);

foreach ($followers as $follower) {
$notification = $follower->notifications()->sole();

expect($notification->type)->toBe(NotificationType::FirstCommit)
->and($notification->actor_id)->toBe($author->id)
->and($notification->commit_id)->toBe($commit->id);
}
});

it('only notifies about the first commit', function (): void {
$author = User::factory()->create();
$author->followers()->attach(User::factory()->create());
Commit::factory()->for($author)->create(['created_at' => now()->subDay()]);
$second = Commit::factory()->for($author)->create();

resolve(CreateFirstCommitNotifications::class)->handle($second);

expect(Notification::query()->count())->toBe(0);
});

it('counts a first commit that was deleted since', function (): void {
$author = User::factory()->create();
$author->followers()->attach(User::factory()->create());
Commit::factory()->for($author)->create(['created_at' => now()->subDay(), 'deleted_at' => now()]);
$second = Commit::factory()->for($author)->create();

resolve(CreateFirstCommitNotifications::class)->handle($second);

expect(Notification::query()->count())->toBe(0);
});

it('waits for the first commit to be approved', function (): void {
$author = User::factory()->create();
$author->followers()->attach(User::factory()->create());

resolve(CreateFirstCommitNotifications::class)->handle(
Commit::factory()->for($author)->pending()->create(),
Commit::factory()->for($author)->rejected()->create(),
);

expect(Notification::query()->count())->toBe(0);
});

it('notifies about the first commit approved after a rejected one', function (): void {
$author = User::factory()->create();
$follower = User::factory()->create();
$author->followers()->attach($follower);
Commit::factory()->for($author)->rejected()->create(['created_at' => now()->subDay()]);
$commit = Commit::factory()->for($author)->create();

resolve(CreateFirstCommitNotifications::class)->handle($commit);

expect($follower->notifications()->sole()->commit_id)->toBe($commit->id);
});

it('does not count replies as a first commit', function (): void {
$author = User::factory()->create();
$author->followers()->attach(User::factory()->create());
$reply = Commit::factory()->for($author)->for(Commit::factory(), 'parent')->create();

resolve(CreateFirstCommitNotifications::class)->handle($reply);

expect(Notification::query()->count())->toBe(0);
});

it('looks nothing up for replies', function (): void {
$reply = Commit::factory()->for(Commit::factory(), 'parent')->create();

DB::enableQueryLog();
resolve(CreateFirstCommitNotifications::class)->handle($reply);

expect(DB::getQueryLog())->toBeEmpty();
});

it('notifies only once, about the earliest of first commits approved together', function (): void {
$author = User::factory()->create();
$follower = User::factory()->create();
$author->followers()->attach($follower);
$first = Commit::factory()->for($author)->create(['created_at' => now()->subMinute()]);
$second = Commit::factory()->for($author)->create();

$action = resolve(CreateFirstCommitNotifications::class);
$action->handle($second, $first);
$action->handle($first);

expect($follower->notifications()->sole()->commit_id)->toBe($first->id);
});

it('leaves out bots, and followers who mute the author or are across a block', function (): void {
$author = User::factory()->create();
resolve(CreateTomFriendship::class)->handle($author);
[$follower, $muter, $blocker, $blocked] = User::factory()->count(4)->create()->all();
$author->followers()->attach([$follower->id, $muter->id, $blocker->id, $blocked->id]);
$muter->muting()->attach($author);
$blocker->blocking()->attach($author);
$author->blocking()->attach($blocked);

resolve(CreateFirstCommitNotifications::class)->handle(Commit::factory()->for($author)->create());

expect(Notification::query()->pluck('recipient_id')->all())->toBe([$follower->id]);
});

it('does not notify about the first commit of a bot', function (): void {
$author = User::factory()->create(['github_id' => CreateTomFriendship::GITHUB_ID]);
$author->followers()->attach(User::factory()->create());

resolve(CreateFirstCommitNotifications::class)->handle(Commit::factory()->for($author)->create());

expect(Notification::query()->count())->toBe(0);
});

it('leaves out followers it mentions or quotes, who already hear about it', function (): void {
$author = User::factory()->create();
[$follower, $mentioned, $quoted] = User::factory()->count(3)->create()->all();
$author->followers()->attach([$follower->id, $mentioned->id, $quoted->id]);
$commit = Commit::factory()->for($author)->create([
'mentions' => [$mentioned->handle => $mentioned->id],
'quoted_commit_id' => Commit::factory()->for($quoted)->create()->id,
]);

resolve(CreateFirstCommitNotifications::class)->handle($commit);

expect(Notification::query()->pluck('recipient_id')->all())->toBe([$follower->id]);
});
11 changes: 11 additions & 0 deletions tests/Unit/Actions/UpdateCommitStatusesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@
->toEqualCanonicalizing($quotes->modelKeys());
});

it("tells the author's followers about their first commit once it is approved", function (): void {
$author = User::factory()->create();
$follower = User::factory()->create();
$author->followers()->attach($follower);
$commit = Commit::factory()->for($author)->pending()->create();

resolve(UpdateCommitStatuses::class)->handle([$commit->id], CommitStatus::Approved);

expect($follower->notifications()->sole()->commit_id)->toBe($commit->id);
});

it('lifts the thread when a reply is approved', function (): void {
$commit = Commit::factory()->create();
$reply = Commit::factory()->pending()->for($commit, 'parent')->create();
Expand Down
1 change: 1 addition & 0 deletions tests/Unit/Enums/NotificationTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@
'Quoted' => 'quoted',
'Starred' => 'starred',
'LeveledUp' => 'leveled_up',
'FirstCommit' => 'first_commit',
]);
});
Loading