diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 3f0a12f44..ed54b4d18 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -320,7 +320,9 @@ 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. @@ -328,7 +330,7 @@ On phones, **Replies**, **Quotes**, and **Stars** share one row with a **More no 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. diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 95354559f..313e61358 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -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, @@ -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)); diff --git a/app/Actions/CreateFirstCommitNotifications.php b/app/Actions/CreateFirstCommitNotifications.php new file mode 100644 index 000000000..c62ce824a --- /dev/null +++ b/app/Actions/CreateFirstCommitNotifications.php @@ -0,0 +1,83 @@ + $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); + } + } +} diff --git a/app/Actions/UpdateCommitStatuses.php b/app/Actions/UpdateCommitStatuses.php index b8b901c3e..7b4887dbe 100644 --- a/app/Actions/UpdateCommitStatuses.php +++ b/app/Actions/UpdateCommitStatuses.php @@ -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, @@ -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)); diff --git a/app/Enums/NotificationType.php b/app/Enums/NotificationType.php index 88bb1943b..665df2a78 100644 --- a/app/Enums/NotificationType.php +++ b/app/Enums/NotificationType.php @@ -14,4 +14,5 @@ enum NotificationType: string case Quoted = 'quoted'; case Starred = 'starred'; case LeveledUp = 'leveled_up'; + case FirstCommit = 'first_commit'; } diff --git a/app/Queries/NotificationBuilder.php b/app/Queries/NotificationBuilder.php index b2abee21b..3f74ace7b 100644 --- a/app/Queries/NotificationBuilder.php +++ b/app/Queries/NotificationBuilder.php @@ -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 diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx index 8f934db0c..8a517d5ef 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -6,6 +6,7 @@ import { Check, ChevronDown, ChevronsUp, + GitCommitVertical, MessageSquareReply, Quote, Star, @@ -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, @@ -765,6 +767,7 @@ export function NotificationContent({ quoted: Quote, starred: Star, mentioned: AtSign, + first_commit: GitCommitVertical, approved: Check, rejected: X, }[notification.type]; @@ -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}`, diff --git a/resources/js/types/notification.ts b/resources/js/types/notification.ts index afb74b230..74c2256d7 100644 --- a/resources/js/types/notification.ts +++ b/resources/js/types/notification.ts @@ -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; diff --git a/tests/Unit/Actions/CreateCommitTest.php b/tests/Unit/Actions/CreateCommitTest.php index 7ed3b12bf..b6f119c46 100644 --- a/tests/Unit/Actions/CreateCommitTest.php +++ b/tests/Unit/Actions/CreateCommitTest.php @@ -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(); diff --git a/tests/Unit/Actions/CreateFirstCommitNotificationsTest.php b/tests/Unit/Actions/CreateFirstCommitNotificationsTest.php new file mode 100644 index 000000000..abe718299 --- /dev/null +++ b/tests/Unit/Actions/CreateFirstCommitNotificationsTest.php @@ -0,0 +1,144 @@ +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]); +}); diff --git a/tests/Unit/Actions/UpdateCommitStatusesTest.php b/tests/Unit/Actions/UpdateCommitStatusesTest.php index 70519300e..67fc69ae9 100644 --- a/tests/Unit/Actions/UpdateCommitStatusesTest.php +++ b/tests/Unit/Actions/UpdateCommitStatusesTest.php @@ -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(); diff --git a/tests/Unit/Enums/NotificationTypeTest.php b/tests/Unit/Enums/NotificationTypeTest.php index 5aced3bec..864490e37 100644 --- a/tests/Unit/Enums/NotificationTypeTest.php +++ b/tests/Unit/Enums/NotificationTypeTest.php @@ -14,5 +14,6 @@ 'Quoted' => 'quoted', 'Starred' => 'starred', 'LeveledUp' => 'leveled_up', + 'FirstCommit' => 'first_commit', ]); });