diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index f08a1cf81..8cb85af04 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -320,13 +320,13 @@ Sources: [pin authorization](app/Http/Requests/UpdateCommitPinRequest.php), [pin Enable **Pastéis mode** in Settings → Profile to give pastry awards to other people's commits. You get **10 pastéis per day**, with the allowance resetting at **00:00 UTC**, and each click gives one. You cannot award your own commit. Unlike stars, repeated clicks can give more than one pastel to the same commit, using your daily allowance. -Pastéis do not notify anyone and do not affect Trending. Turning Pastéis mode off hides its controls and counters, including the pastéis count on profiles; people can still give you pastéis. The pastel emoji and mode commemorate Fernando Andrade bringing Portuguese custard pastries to the livestream team, rather than representing a payment or purchasable currency. +The first pastel a commit wins notifies its author once while the commit is visible; repeated pastéis on the same commit stay quiet. Pastéis do not affect Trending. Turning Pastéis mode off hides its controls and counters, including the pastéis count on profiles; people can still give you pastéis. The pastel emoji and mode commemorate Fernando Andrade bringing Portuguese custard pastries to the livestream team, rather than representing a payment or purchasable currency. -Sources: [Pastéis setting](resources/js/components/pasteis-mode-setting.tsx), [award action](app/Actions/CreateCommitPastel.php), [award authorization](app/Http/Requests/CreateCommitPastelRequest.php), [pastry lore](#pastéis-de-nata). +Sources: [Pastéis setting](resources/js/components/pasteis-mode-setting.tsx), [award action](app/Actions/CreateCommitPastel.php), [award notification](app/Actions/CreatePastelNotification.php), [award authorization](app/Http/Requests/CreateCommitPastelRequest.php), [pastry lore](#pastéis-de-nata). ### 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, pastéis, 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. A pastel notifies its author about a commit once, whoever gives the first one. 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. 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. diff --git a/app/Actions/CreateCommitPastel.php b/app/Actions/CreateCommitPastel.php index 9af862cfe..02ba549f1 100644 --- a/app/Actions/CreateCommitPastel.php +++ b/app/Actions/CreateCommitPastel.php @@ -10,6 +10,14 @@ final readonly class CreateCommitPastel { + public function __construct(private CreatePastelNotification $createPastelNotification) + { + // + } + + /** + * Notifies the author only while the locked count moves from zero, so repeats, restored commits, and old winners stay silent. + */ public function handle(User $user, Commit $commit): bool { return DB::transaction(function () use ($user, $commit): bool { @@ -25,6 +33,7 @@ public function handle(User $user, Commit $commit): bool } $recipientCommit = Commit::query()->lockForUpdate()->findOrFail($commit->id); + $firstPastel = $recipientCommit->pasteis_count === 0; $sender->update([ 'pasteis_given_on' => today()->toDateString(), @@ -34,6 +43,10 @@ public function handle(User $user, Commit $commit): bool $recipientCommit->increment('pasteis_count'); User::query()->whereKey($commit->user_id)->increment('pasteis_received'); + if ($firstPastel) { + $this->createPastelNotification->handle($user, $recipientCommit); + } + return true; }, 3); } diff --git a/app/Actions/CreatePastelNotification.php b/app/Actions/CreatePastelNotification.php new file mode 100644 index 000000000..102b76c08 --- /dev/null +++ b/app/Actions/CreatePastelNotification.php @@ -0,0 +1,37 @@ +user_id === $sender->id) { + return; + } + + Notification::query()->insertOrIgnore([ + 'id' => Str::uuid7()->toString(), + 'recipient_id' => $commit->user_id, + 'actor_id' => $sender->id, + 'type' => NotificationType::Awarded->value, + 'commit_id' => $commit->id, + 'subject_id' => $commit->id, + 'key' => 'awarded:'.$commit->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/app/Enums/NotificationType.php b/app/Enums/NotificationType.php index 88bb1943b..83fc3954c 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 Awarded = 'awarded'; } diff --git a/app/Queries/NotificationBuilder.php b/app/Queries/NotificationBuilder.php index 5bc74085e..95e7b0cc6 100644 --- a/app/Queries/NotificationBuilder.php +++ b/app/Queries/NotificationBuilder.php @@ -31,7 +31,7 @@ public function relevant(): self ->whereColumn('follows.followed_id', 'notifications.recipient_id') ->offset(0))) ->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::Awarded]) ->whereHas('commit', fn (CommitBuilder $commit): CommitBuilder => $commit ->where('status', CommitStatus::Approved) ->offset(0))) diff --git a/database/factories/NotificationFactory.php b/database/factories/NotificationFactory.php index 9fd33bb19..54d2bc33b 100644 --- a/database/factories/NotificationFactory.php +++ b/database/factories/NotificationFactory.php @@ -94,6 +94,21 @@ public function starred(?Commit $commit = null): self ]); } + /** + * The first pastel on one of the recipient's approved commits, from the actor. + */ + public function awarded(): self + { + return $this->state(fn (array $attributes): array => [ + 'type' => NotificationType::Awarded, + 'commit_id' => fn (array $attributes): string => Commit::factory() + ->for(User::query()->whereKey($attributes['recipient_id'])->sole()) + ->create() + ->id, + 'subject_id' => fn (array $attributes): string => Commit::query()->whereKey($attributes['commit_id'])->sole()->id, + ]); + } + /** * An approved quote from the actor of one of the recipient's commits. */ diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx index 6809b7112..fd1a4eb18 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -6,6 +6,7 @@ import { Check, ChevronDown, ChevronsUp, + Cookie, MessageSquareReply, Quote, Star, @@ -70,7 +71,7 @@ const filterOptions: { { type: 'all', label: 'All', - empty: 'Nothing here yet. Follows, stars, replies, quotes, mentions, level-ups, and moderation updates for your commits will show up here.', + empty: 'Nothing here yet. Follows, stars, replies, quotes, mentions, pastéis, level-ups, and moderation updates for your commits will show up here.', }, { type: 'replies', @@ -745,7 +746,10 @@ export function NotificationContent({ ('event_count' in notification && notification.event_count > 1)); const moderation = notification.type === 'approved' || notification.type === 'rejected'; - const isOwnCommit = notification.type === 'starred' || moderation; + const isOwnCommit = + notification.type === 'starred' || + notification.type === 'awarded' || + moderation; const starredImages = notification.type === 'starred' ? (notification.commit.images ?? []) @@ -764,6 +768,7 @@ export function NotificationContent({ replied: MessageSquareReply, quoted: Quote, starred: Star, + awarded: Cookie, mentioned: AtSign, approved: Check, rejected: X, @@ -803,6 +808,7 @@ export function NotificationContent({ starred: isImageOnly ? `starred your ${starredImages.length === 1 ? 'image' : 'images'}` : 'starred your commit', + awarded: 'gave your commit a pastel', mentioned: 'mentioned you', approved: 'Your commit was approved', rejected: 'Your commit was rejected', diff --git a/resources/js/types/notification.ts b/resources/js/types/notification.ts index afb74b230..c225f7ef5 100644 --- a/resources/js/types/notification.ts +++ b/resources/js/types/notification.ts @@ -28,7 +28,7 @@ export type Notification = commit: null; }) | (BaseNotification & { - type: 'replied' | 'starred'; + type: 'replied' | 'starred' | 'awarded'; commit_id: string; subject_id: string; commit: Omit; diff --git a/tests/Feature/Controllers/CommitPastelControllerTest.php b/tests/Feature/Controllers/CommitPastelControllerTest.php index b2546d20d..93c03229a 100644 --- a/tests/Feature/Controllers/CommitPastelControllerTest.php +++ b/tests/Feature/Controllers/CommitPastelControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\NotificationType; use App\Models\Commit; use App\Models\User; @@ -30,6 +31,28 @@ ->where('auth.pasteis', null)); }); +it('notifies the author about a first pastel while repeats stay quiet', function (): void { + $sender = User::factory()->create(['pasteis_mode' => true]); + $recipient = User::factory()->create(); + $commit = Commit::factory()->for($recipient)->create(); + + $this->actingAs($sender)->postJson(route('commit-pastel.store', $commit)) + ->assertOk()->assertJsonPath('pasteis_count', 1); + $this->postJson(route('commit-pastel.store', $commit)) + ->assertOk()->assertJsonPath('pasteis_count', 2); + + $other = User::factory()->create(['pasteis_mode' => true]); + + $this->actingAs($other)->postJson(route('commit-pastel.store', $commit)) + ->assertOk()->assertJsonPath('pasteis_count', 3); + + $notification = $recipient->notifications()->sole(); + + expect($notification->type)->toBe(NotificationType::Awarded) + ->and($notification->actor_id)->toBe($sender->id) + ->and($notification->commit_id)->toBe($commit->id); +}); + it('shares ten awards across posts and restores the allowance at midnight UTC, not after 24 hours', function (): void { $this->travelTo(now()->setDate(2026, 10, 16)->setTime(23, 59, 59)); $sender = User::factory()->create(['pasteis_mode' => true]); @@ -47,7 +70,8 @@ ->assertJsonValidationErrors(['pastel' => 'you ran out of pastéis. wait until tomorrow.']); expect($otherCommit->refresh()->pasteis_count)->toBe(0) - ->and($recipient->refresh()->pasteis_received)->toBe(10); + ->and($recipient->refresh()->pasteis_received)->toBe(10) + ->and($recipient->notifications()->count())->toBeOne(); $this->travel(1)->seconds(); diff --git a/tests/Unit/Actions/CreatePastelNotificationTest.php b/tests/Unit/Actions/CreatePastelNotificationTest.php new file mode 100644 index 000000000..6e89b23f1 --- /dev/null +++ b/tests/Unit/Actions/CreatePastelNotificationTest.php @@ -0,0 +1,57 @@ +create(); + $commit = Commit::factory()->create(); + + resolve(CreatePastelNotification::class)->handle($user, $commit); + + $notification = $commit->user->notifications()->sole(); + + expect($notification->type)->toBe(NotificationType::Awarded) + ->and($notification->actor_id)->toBe($user->id) + ->and($notification->commit_id)->toBe($commit->id); +}); + +it('does not notify authors about their own pastéis', function (): void { + $commit = Commit::factory()->create(); + + resolve(CreatePastelNotification::class)->handle($commit->user, $commit); + + expect($commit->user->notifications()->exists())->toBeFalse(); +}); + +it('notifies about a commit once, whoever gives the pastel', function (): void { + $commit = Commit::factory()->create(); + + $action = resolve(CreatePastelNotification::class); + + $user = User::factory()->create(); + + $action->handle($user, $commit); + $action->handle($user, $commit); + $action->handle(User::factory()->create(), $commit); + + expect($commit->user->notifications()->count())->toBeOne(); +}); + +it('notifies about each commit separately', function (): void { + $user = User::factory()->create(); + $author = User::factory()->create(); + $first = Commit::factory()->for($author)->create(); + $second = Commit::factory()->for($author)->create(); + + $action = resolve(CreatePastelNotification::class); + + $action->handle($user, $first); + $action->handle($user, $second); + + expect($author->notifications()->count())->toBe(2); +}); diff --git a/tests/Unit/Enums/NotificationTypeTest.php b/tests/Unit/Enums/NotificationTypeTest.php index 5aced3bec..3299feac1 100644 --- a/tests/Unit/Enums/NotificationTypeTest.php +++ b/tests/Unit/Enums/NotificationTypeTest.php @@ -14,5 +14,6 @@ 'Quoted' => 'quoted', 'Starred' => 'starred', 'LeveledUp' => 'leveled_up', + 'Awarded' => 'awarded', ]); }); diff --git a/tests/Unit/NotificationInbox.test.tsx b/tests/Unit/NotificationInbox.test.tsx index 635fdfb77..3c4104f89 100644 --- a/tests/Unit/NotificationInbox.test.tsx +++ b/tests/Unit/NotificationInbox.test.tsx @@ -129,6 +129,28 @@ function categories(html: string, pressed: boolean): Set { assert.match(html, /]*aria-pressed="true"[^>]*>Quotes<\/button>/); }); + test('renders pastéis awarded to the recipients own commit, linked to it', async () => { + const awarded: NotificationGroup = { + ...quote, + type: 'awarded', + commit_id: 'own-commit', + subject_id: 'own-commit', + commit: { + ...quoteCommit, + id: 'own-commit', + user_id: 'recipient', + body: 'My awarded commit', + body_segments: [{ type: 'text', text: 'My awarded commit' }], + quoted_commit_id: null, + }, + }; + const html = await renderInbox([awarded]); + + assert.match(html, /gave your commit a pastel/); + assert.match(html, /href="\/@recipient\/commits\/own-commit"/); + assert.match(html, /My awarded commit/); +}); + test('shows the quoted commit under a quote, or a tombstone once it is deleted', async () => { const html = await renderInbox([ { diff --git a/tests/Unit/Queries/NotificationBuilderTest.php b/tests/Unit/Queries/NotificationBuilderTest.php index 112c4b936..5fa64810d 100644 --- a/tests/Unit/Queries/NotificationBuilderTest.php +++ b/tests/Unit/Queries/NotificationBuilderTest.php @@ -149,6 +149,28 @@ expect($notification->fresh())->toBeNull(); }); +it('keeps pastéis on approved commits', function (): void { + $notification = Notification::factory()->awarded()->create(); + + expect(Notification::query()->relevant()->sole()->is($notification))->toBeTrue(); +}); + +it('hides pastéis once the commit is rejected', function (): void { + $notification = Notification::factory()->awarded()->create(); + + resolve(UpdateCommitStatus::class)->handle($notification->commit, CommitStatus::Rejected); + + expect(Notification::query()->where('type', NotificationType::Awarded)->relevant()->exists())->toBeFalse(); +}); + +it('deletes pastéis along with the awarded commit', function (): void { + $notification = Notification::factory()->awarded()->create(); + + resolve(DeleteCommit::class)->handle($notification->commit); + + expect($notification->fresh())->toBeNull(); +}); + it('deletes notifications along with the actor', function (Notification $notification): void { resolve(DeleteUser::class)->handle($notification->actor); @@ -158,6 +180,7 @@ 'replied' => fn (): Notification => Notification::factory()->replied()->create(), 'quoted' => fn (): Notification => Notification::factory()->quoted()->create(), 'starred' => fn (): Notification => Notification::factory()->starred()->create(), + 'awarded' => fn (): Notification => Notification::factory()->awarded()->create(), ]); it('deletes notifications along with the recipient', function (): void { @@ -181,6 +204,7 @@ 'replied' => fn (): Notification => Notification::factory()->replied()->create(), 'mentioned' => fn (): Notification => Notification::factory()->mentioned()->create(), 'starred' => fn (): Notification => Notification::factory()->starred()->create(), + 'awarded' => fn (): Notification => Notification::factory()->awarded()->create(), ]); it('hides replies, mentions and stars across a block in either direction', function (string $state, bool $recipientBlocks): void { @@ -191,4 +215,4 @@ : $notification->actor->blocking()->attach($notification->recipient_id); expect(Notification::query()->relevant()->exists())->toBeFalse(); -})->with(['replied', 'mentioned', 'starred'])->with([true, false]); +})->with(['replied', 'mentioned', 'starred', 'awarded'])->with([true, false]);