diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index fe8a0259e..57e00078d 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -32,6 +32,7 @@ public function __construct( private CreateQuoteNotifications $createQuoteNotifications, private CreateMentionNotifications $createMentionNotifications, private CreateFirstCommitNotifications $createFirstCommitNotifications, + private CreateWatchNotifications $createWatchNotifications, private CreateCommitImages $createCommitImages, private CreateWorldReply $createWorldReply, private UpdateCommitHotScores $updateCommitHotScores, @@ -93,6 +94,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $this->createQuoteNotifications->handle($commit); $this->createMentionNotifications->handle($commit); $this->createFirstCommitNotifications->handle($commit); + $this->createWatchNotifications->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 index c62ce824a..b64176d02 100644 --- a/app/Actions/CreateFirstCommitNotifications.php +++ b/app/Actions/CreateFirstCommitNotifications.php @@ -19,8 +19,8 @@ * 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. + * or quotes, nor to those watching the author, 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 { @@ -56,6 +56,7 @@ public function handle(Commit ...$commits): void assert($author instanceof User); $followerIds = $author->followers() + ->wherePivot('watching', false) ->whereNotIn('users.github_id', User::BOT_GITHUB_IDS) ->whereNotIn('users.id', $author->mutedBy()->newPivotQuery()->select('muter_id')) ->whereNotIn('users.id', $author->idsAcrossBlocks()) diff --git a/app/Actions/CreateWatchNotifications.php b/app/Actions/CreateWatchNotifications.php new file mode 100644 index 000000000..815f563b3 --- /dev/null +++ b/app/Actions/CreateWatchNotifications.php @@ -0,0 +1,76 @@ +whereKey(array_map(fn (Commit $commit): string => $commit->id, $commits)) + ->where('status', CommitStatus::Approved) + ->whereNull('parent_id') + ->whereNotNull('user_id') + ->with('quotedCommit') + ->get(); + + if ($commits->isEmpty()) { + return; + } + + $silenced = new SilencedActorQuery('follows.follower_id', 'follows.followed_id'); + + $watchersByAuthor = DB::table('follows') + ->whereIn('followed_id', $commits->pluck('user_id')->unique()->values()->all()) + ->where('watching', true) + ->whereNotExists($silenced->mutes(...)) + ->whereNotExists($silenced->blocking(...)) + ->whereNotExists($silenced->blockers(...)) + ->get(['follower_id', 'followed_id']) + ->groupBy('followed_id'); + + $notifications = []; + + foreach ($commits as $commit) { + $alreadyNotified = [...array_values($commit->mentions ?? []), $commit->quotedCommit?->user_id]; + + foreach ($watchersByAuthor->get($commit->user_id, []) as $follow) { + if (in_array($follow->follower_id, $alreadyNotified, true)) { + continue; + } + + $notifications[] = [ + 'id' => Str::uuid7()->toString(), + 'recipient_id' => $follow->follower_id, + 'actor_id' => $commit->user_id, + 'type' => NotificationType::Committed->value, + 'commit_id' => $commit->id, + 'key' => 'committed:'.$commit->id, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + } + + // A popular author can have more watchers than one statement can bind. + foreach (array_chunk($notifications, 500) as $chunk) { + Notification::query()->insertOrIgnore($chunk); + } + } +} diff --git a/app/Actions/UpdateCommitStatuses.php b/app/Actions/UpdateCommitStatuses.php index 162615799..34788a403 100644 --- a/app/Actions/UpdateCommitStatuses.php +++ b/app/Actions/UpdateCommitStatuses.php @@ -25,6 +25,7 @@ public function __construct( private CreateQuoteNotifications $createQuoteNotifications, private CreateMentionNotifications $createMentionNotifications, private CreateFirstCommitNotifications $createFirstCommitNotifications, + private CreateWatchNotifications $createWatchNotifications, private CreateWorldReply $createWorldReply, private UpdateCommitHotScores $updateCommitHotScores, private TomQuery $tomQuery, @@ -85,6 +86,7 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason = $this->createQuoteNotifications->handle(...$commits); $this->createMentionNotifications->handle(...$commits); $this->createFirstCommitNotifications->handle(...$commits); + $this->createWatchNotifications->handle(...$commits); $commits->each(fn (Commit $commit) => $this->createWorldReply->handle($commit)); LinkCommitTopics::dispatchFor(...$commits->all()); diff --git a/app/Actions/UpdateUserWatch.php b/app/Actions/UpdateUserWatch.php new file mode 100644 index 000000000..5bdf6ef35 --- /dev/null +++ b/app/Actions/UpdateUserWatch.php @@ -0,0 +1,20 @@ +following()->newPivotQuery() + ->where('followed_id', $watched->id) + ->update(['watching' => $watching, 'updated_at' => now()]); + } +} diff --git a/app/Enums/NotificationType.php b/app/Enums/NotificationType.php index 665df2a78..819c36ebf 100644 --- a/app/Enums/NotificationType.php +++ b/app/Enums/NotificationType.php @@ -15,4 +15,5 @@ enum NotificationType: string case Starred = 'starred'; case LeveledUp = 'leveled_up'; case FirstCommit = 'first_commit'; + case Committed = 'committed'; } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index f54a2dd86..591d874fe 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -60,6 +60,7 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser 'pasteisReceived' => $currentUser?->pasteis_mode ? $user->pasteis_received : null, 'isFollowing' => fn (): bool => $currentUser?->isFollowing($user) ?? false, 'followsYou' => fn (): bool => $currentUser instanceof User && $user->isFollowing($currentUser), + 'isWatching' => fn (): bool => $currentUser?->isWatching($user) ?? false, 'isMuting' => fn (): bool => $currentUser?->isMuting($user) ?? false, 'followersYouKnow' => fn (): ?array => $currentUser instanceof User && $currentUser->isNot($user) ? $followersYouKnow->preview($user, $currentUser) diff --git a/app/Http/Controllers/UserWatchController.php b/app/Http/Controllers/UserWatchController.php new file mode 100644 index 000000000..2fa8832a1 --- /dev/null +++ b/app/Http/Controllers/UserWatchController.php @@ -0,0 +1,40 @@ +handle($currentUser, $user, watching: true); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => "You'll be notified when {$user->mention()} commits.", + ]); + + return back(); + } + + public function destroy(DeleteUserWatchRequest $request, #[CurrentUser] User $currentUser, User $user, UpdateUserWatch $action): RedirectResponse + { + $action->handle($currentUser, $user, watching: false); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => "You won't be notified when {$user->mention()} commits.", + ]); + + return back(); + } +} diff --git a/app/Http/Requests/CreateUserWatchRequest.php b/app/Http/Requests/CreateUserWatchRequest.php new file mode 100644 index 000000000..51d7983d1 --- /dev/null +++ b/app/Http/Requests/CreateUserWatchRequest.php @@ -0,0 +1,30 @@ +user(); + assert($user instanceof User); + + $watched = $this->route('user'); + assert($watched instanceof User); + + return $user->isFollowing($watched); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/DeleteUserWatchRequest.php b/app/Http/Requests/DeleteUserWatchRequest.php new file mode 100644 index 000000000..774e4f2ee --- /dev/null +++ b/app/Http/Requests/DeleteUserWatchRequest.php @@ -0,0 +1,23 @@ +following()->newPivotQuery()->where('followed_id', $user->id)->exists(); } + /** + * Whether the user hears about each new commit of someone they follow. + */ + public function isWatching(self $user): bool + { + return $this->following()->newPivotQuery()->where('followed_id', $user->id)->where('watching', true)->exists(); + } + public function isMuting(self $user): bool { return $this->muting()->newPivotQuery()->where('muted_id', $user->id)->exists(); diff --git a/app/Queries/NotificationBuilder.php b/app/Queries/NotificationBuilder.php index 38c841ebc..6b4bc8589 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, NotificationType::FirstCommit]) + ->whereIn('type', [NotificationType::Replied, NotificationType::Quoted, NotificationType::Mentioned, NotificationType::Starred, NotificationType::FirstCommit, NotificationType::Committed]) ->whereHas('commit', fn (CommitBuilder $commit): CommitBuilder => $commit ->where('status', CommitStatus::Approved) ->offset(0))) diff --git a/app/Queries/NotificationGroupsQuery.php b/app/Queries/NotificationGroupsQuery.php index 927458fb8..a1113aec5 100644 --- a/app/Queries/NotificationGroupsQuery.php +++ b/app/Queries/NotificationGroupsQuery.php @@ -137,6 +137,7 @@ private function group(NotificationBuilder $visible, string $type = 'all', ?Noti 'quotes' => ['quoted'], 'stars' => ['starred'], 'mentions' => ['mentioned'], + 'commits' => ['committed'], 'follows' => ['followed'], 'moderation' => ['approved', 'rejected'], default => [], diff --git a/database/migrations/2026_10_28_120000_add_watching_to_follows_table.php b/database/migrations/2026_10_28_120000_add_watching_to_follows_table.php new file mode 100644 index 000000000..3bc49448e --- /dev/null +++ b/database/migrations/2026_10_28_120000_add_watching_to_follows_table.php @@ -0,0 +1,20 @@ +boolean('watching')->default(false); + }); + } +}; diff --git a/resources/js/components/watch-button.tsx b/resources/js/components/watch-button.tsx new file mode 100644 index 000000000..8f63d30e9 --- /dev/null +++ b/resources/js/components/watch-button.tsx @@ -0,0 +1,57 @@ +import { Link } from '@inertiajs/react'; +import { Bell, BellRing } from 'lucide-react'; +import UserWatchController from '@/actions/App/Http/Controllers/UserWatchController'; +import { useIsFollowing } from '@/components/follow-button'; +import { Button } from '@/components/ui/button'; +import type { User } from '@/types'; + +/** + * Watching is part of a follow, so the bell only shows for someone you follow, and goes away with the follow. + */ +export function WatchButton({ + user, + isFollowing: initialIsFollowing, + isWatching, +}: { + user: Pick; + isFollowing: boolean; + isWatching: boolean; +}) { + const [isFollowing] = useIsFollowing(user, initialIsFollowing); + + if (!isFollowing) { + return null; + } + + const label = isWatching + ? `Stop notifications for @${user.handle}'s commits` + : `Get notified when @${user.handle} commits`; + + return ( + + ); +} diff --git a/resources/js/pages/notification/index.tsx b/resources/js/pages/notification/index.tsx index 88cc4fefd..50253518b 100644 --- a/resources/js/pages/notification/index.tsx +++ b/resources/js/pages/notification/index.tsx @@ -3,6 +3,7 @@ import { Head, InfiniteScroll, Link, router, usePoll } from '@inertiajs/react'; import { ArrowUp, AtSign, + BellRing, Check, ChevronDown, ChevronsUp, @@ -71,7 +72,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, commits from people you watch, level-ups, and moderation updates for your commits will show up here.', }, { type: 'replies', @@ -93,6 +94,11 @@ const filterOptions: { label: 'Mentions', empty: 'No mentions yet. When someone mentions you in a commit, it shows up here.', }, + { + type: 'commits', + label: 'Commits', + empty: 'No commits yet. Ring the bell on the profile of someone you follow to hear about each of their commits here.', + }, { type: 'follows', label: 'Follows', @@ -758,7 +764,8 @@ export function NotificationContent({ notification.type === 'replied' || notification.type === 'quoted' || notification.type === 'mentioned' || - notification.type === 'first_commit'; + notification.type === 'first_commit' || + notification.type === 'committed'; const level = notification.type === 'leveled_up' ? notification : null; const Icon = { followed: UserPlus, @@ -768,6 +775,7 @@ export function NotificationContent({ starred: Star, mentioned: AtSign, first_commit: GitCommitVertical, + committed: BellRing, approved: Check, rejected: X, }[notification.type]; @@ -808,6 +816,7 @@ export function NotificationContent({ : 'starred your commit', mentioned: 'mentioned you', first_commit: 'made their first commit', + committed: 'committed', 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/pages/user/show.tsx b/resources/js/pages/user/show.tsx index df299334b..419a1b681 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -42,6 +42,7 @@ import { UserModerationStatus, } from '@/components/user-moderation-menu'; import { UserSafetyMenu } from '@/components/user-safety-menu'; +import { WatchButton } from '@/components/watch-button'; import { refreshSharedState, useSharedState } from '@/hooks/use-shared-state'; import AppLayout from '@/layouts/app-layout'; import { commitStateKey } from '@/lib/commit-state'; @@ -65,6 +66,7 @@ export default function Show({ bioProfiles, pasteisReceived, isFollowing, + isWatching, followsYou, isMuting, followersYouKnow, @@ -82,6 +84,7 @@ export default function Show({ bioProfiles: Record; pasteisReceived: number | null; isFollowing: boolean; + isWatching: boolean; followsYou: boolean; isMuting: boolean; followersYouKnow: FollowersYouKnowPreview | null; @@ -213,6 +216,13 @@ export default function Show({ )} + {auth.user && ( + + )} ; diff --git a/routes/web.php b/routes/web.php index 4cf729966..bfb56657f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -132,6 +132,7 @@ use App\Http\Controllers\UserTimeoutController; use App\Http\Controllers\UserTimezoneController; use App\Http\Controllers\UserTranslationLanguageController; +use App\Http\Controllers\UserWatchController; use App\Http\Middleware\PreventPostingInTimeout; use App\Http\Middleware\RedirectToHandleCase; use App\Http\Middleware\RememberIntendedUrl; @@ -586,6 +587,14 @@ ->middleware('throttle:follows') ->name('user-follow.destroy'); + // User Watch... + Route::post('@{user:handle}/watch', [UserWatchController::class, 'store']) + ->middleware('throttle:follows') + ->name('user-watch.store'); + Route::delete('@{user:handle}/watch', [UserWatchController::class, 'destroy']) + ->middleware('throttle:follows') + ->name('user-watch.destroy'); + // User Mute... Route::post('@{user:handle}/mute', [UserMuteController::class, 'store']) ->name('user-mute.store'); diff --git a/tests/Feature/Controllers/UserWatchControllerTest.php b/tests/Feature/Controllers/UserWatchControllerTest.php new file mode 100644 index 000000000..9c9fca3eb --- /dev/null +++ b/tests/Feature/Controllers/UserWatchControllerTest.php @@ -0,0 +1,100 @@ +create(); + $watched = User::factory()->create(['handle' => 'octocat']); + $user->following()->attach($watched); + + $response = $this->actingAs($user) + ->fromRoute('user.show', $watched) + ->post(route('user-watch.store', $watched)); + + $response->assertRedirectToRoute('user.show', $watched) + ->assertInertiaFlash('toast', [ + 'type' => 'success', + 'message' => "You'll be notified when @octocat commits.", + ]); + + expect($user->isWatching($watched))->toBeTrue(); +}); + +it('may not watch someone they do not follow', function (): void { + $user = User::factory()->create(); + $watched = User::factory()->create(); + + $response = $this->actingAs($user) + ->post(route('user-watch.store', $watched)); + + $response->assertForbidden(); + + expect($user->isWatching($watched))->toBeFalse(); +}); + +it('may stop watching someone', function (): void { + $user = User::factory()->create(); + $watched = User::factory()->create(['handle' => 'octocat']); + $user->following()->attach($watched, ['watching' => true]); + + $response = $this->actingAs($user) + ->fromRoute('user.show', $watched) + ->delete(route('user-watch.destroy', $watched)); + + $response->assertRedirectToRoute('user.show', $watched) + ->assertInertiaFlash('toast', [ + 'type' => 'success', + 'message' => "You won't be notified when @octocat commits.", + ]); + + expect($user->isWatching($watched))->toBeFalse() + ->and($user->isFollowing($watched))->toBeTrue(); +}); + +it('stops watching when unfollowing', function (): void { + $user = User::factory()->create(); + $watched = User::factory()->create(); + $user->following()->attach($watched, ['watching' => true]); + + $this->actingAs($user)->delete(route('user-follow.destroy', $watched)); + $this->actingAs($user)->post(route('user-follow.store', $watched)); + + expect($user->isWatching($watched))->toBeFalse(); +}); + +it('requires authentication to watch', function (): void { + $user = User::factory()->create(); + + $this->post(route('user-watch.store', $user))->assertRedirectToRoute('home'); +}); + +it('tells the profile whether the viewer watches it', function (): void { + $user = User::factory()->create(); + $watched = User::factory()->create(); + $user->following()->attach($watched, ['watching' => true]); + + $this->actingAs($user) + ->get(route('user.show', $watched)) + ->assertInertia(fn ($page) => $page->where('isWatching', true)); +}); + +it('notifies watchers once the commit of the author is approved', function (): void { + $author = User::factory()->create(); + $watcher = User::factory()->create(); + $watcher->following()->attach($author, ['watching' => true]); + $commit = Commit::factory()->for($author)->pending()->create(); + + resolve(UpdateCommitStatuses::class)->handle([$commit->id], CommitStatus::Approved); + + $this->actingAs($watcher) + ->get(route('notification.index', ['type' => 'commits'])) + ->assertInertia(fn ($page) => $page + ->where('notifications.data.0.type', NotificationType::Committed->value) + ->where('notifications.data.0.commit_id', $commit->id)); +}); diff --git a/tests/Unit/Actions/CreateFirstCommitNotificationsTest.php b/tests/Unit/Actions/CreateFirstCommitNotificationsTest.php index abe718299..74f81e6ce 100644 --- a/tests/Unit/Actions/CreateFirstCommitNotificationsTest.php +++ b/tests/Unit/Actions/CreateFirstCommitNotificationsTest.php @@ -142,3 +142,15 @@ expect(Notification::query()->pluck('recipient_id')->all())->toBe([$follower->id]); }); + +it('leaves out followers watching the author, who already hear about it', function (): void { + $author = User::factory()->create(); + [$follower, $watcher] = User::factory()->count(2)->create()->all(); + $author->followers()->attach($follower); + $author->followers()->attach($watcher, ['watching' => true]); + $commit = Commit::factory()->for($author)->create(); + + resolve(CreateFirstCommitNotifications::class)->handle($commit); + + expect(Notification::query()->pluck('recipient_id')->all())->toBe([$follower->id]); +}); diff --git a/tests/Unit/Actions/CreateWatchNotificationsTest.php b/tests/Unit/Actions/CreateWatchNotificationsTest.php new file mode 100644 index 000000000..a11ebabd3 --- /dev/null +++ b/tests/Unit/Actions/CreateWatchNotificationsTest.php @@ -0,0 +1,89 @@ +create(); + $watcher = User::factory()->create(); + $follower = User::factory()->create(); + $watcher->following()->attach($author, ['watching' => true]); + $follower->following()->attach($author); + $commit = Commit::factory()->for($author)->create(); + + resolve(CreateWatchNotifications::class)->handle($commit); + + $notification = Notification::query()->sole(); + + expect($notification->recipient_id)->toBe($watcher->id) + ->and($notification->type)->toBe(NotificationType::Committed) + ->and($notification->actor_id)->toBe($author->id) + ->and($notification->commit_id)->toBe($commit->id) + ->and($watcher->notifications()->relevant()->count())->toBeOne(); +}); + +it('does not notify about commits that are not approved', function (): void { + $author = User::factory()->create(); + User::factory()->create()->following()->attach($author, ['watching' => true]); + + resolve(CreateWatchNotifications::class)->handle( + Commit::factory()->for($author)->pending()->create(), + Commit::factory()->for($author)->rejected()->create(), + ); + + expect(Notification::query()->count())->toBe(0); +}); + +it('leaves replies out', function (): void { + $author = User::factory()->create(); + User::factory()->create()->following()->attach($author, ['watching' => true]); + $reply = Commit::factory()->for($author)->for(Commit::factory(), 'parent')->create(); + + resolve(CreateWatchNotifications::class)->handle($reply); + + expect(Notification::query()->where('type', NotificationType::Committed)->count())->toBe(0); +}); + +it('leaves people the commit mentions or quotes to those notifications', function (): void { + $author = User::factory()->create(); + $mentioned = User::factory()->create(['handle' => 'nunomaduro']); + $quoted = Commit::factory()->create(); + $mentioned->following()->attach($author, ['watching' => true]); + $quoted->user->following()->attach($author, ['watching' => true]); + $commit = Commit::factory()->for($author)->for($quoted, 'quotedCommit')->create([ + 'body' => 'Hi @nunomaduro', + 'mentions' => ['nunomaduro' => $mentioned->id], + ]); + + resolve(CreateWatchNotifications::class)->handle($commit); + + expect(Notification::query()->where('type', NotificationType::Committed)->count())->toBe(0); +}); + +it('does not notify watchers who muted the author', function (): void { + $author = User::factory()->create(); + $watcher = User::factory()->create(); + $watcher->following()->attach($author, ['watching' => true]); + $watcher->muting()->attach($author); + + resolve(CreateWatchNotifications::class)->handle(Commit::factory()->for($author)->create()); + + expect(Notification::query()->count())->toBe(0); +}); + +it('is idempotent', function (): void { + $author = User::factory()->create(); + User::factory()->create()->following()->attach($author, ['watching' => true]); + $commit = Commit::factory()->for($author)->create(); + + $action = resolve(CreateWatchNotifications::class); + $action->handle($commit); + $action->handle($commit); + + expect(Notification::query()->count())->toBeOne(); +}); diff --git a/tests/Unit/Actions/UpdateCommitStatusesTest.php b/tests/Unit/Actions/UpdateCommitStatusesTest.php index 67fc69ae9..da3d5ab8d 100644 --- a/tests/Unit/Actions/UpdateCommitStatusesTest.php +++ b/tests/Unit/Actions/UpdateCommitStatusesTest.php @@ -6,6 +6,7 @@ use App\Actions\UpdateCommitStatuses; use App\Dto\ThreadEngagement; use App\Enums\CommitStatus; +use App\Enums\NotificationType; use App\Jobs\CheckUserLevel; use App\Jobs\ReplyAsTom; use App\Models\Commit; @@ -108,6 +109,17 @@ expect($follower->notifications()->sole()->commit_id)->toBe($commit->id); }); +it('tells someone watching the author about their first commit only once', function (): void { + $author = User::factory()->create(); + $watcher = User::factory()->create(); + $author->followers()->attach($watcher, ['watching' => true]); + $commit = Commit::factory()->for($author)->pending()->create(); + + resolve(UpdateCommitStatuses::class)->handle([$commit->id], CommitStatus::Approved); + + expect($watcher->notifications()->sole()->type)->toBe(NotificationType::Committed); +}); + 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 864490e37..4cc6f5b69 100644 --- a/tests/Unit/Enums/NotificationTypeTest.php +++ b/tests/Unit/Enums/NotificationTypeTest.php @@ -15,5 +15,6 @@ 'Starred' => 'starred', 'LeveledUp' => 'leveled_up', 'FirstCommit' => 'first_commit', + 'Committed' => 'committed', ]); }); diff --git a/tests/Unit/NotificationInbox.test.tsx b/tests/Unit/NotificationInbox.test.tsx index b20028dd1..f8d502cb6 100644 --- a/tests/Unit/NotificationInbox.test.tsx +++ b/tests/Unit/NotificationInbox.test.tsx @@ -120,7 +120,7 @@ function categories(html: string, pressed: boolean): Set { ); } - test('renders quote notifications with their label and quoting commit destination', async () => { +test('renders quote notifications with their label and quoting commit destination', async () => { const html = await renderInbox([quote]); assert.match(html, /quoted your commit/); @@ -129,7 +129,7 @@ function categories(html: string, pressed: boolean): Set { assert.match(html, /]*aria-pressed="true"[^>]*>Quotes<\/button>/); }); - test('shows the quoted commit under a quote, or a tombstone once it is deleted', async () => { +test('shows the quoted commit under a quote, or a tombstone once it is deleted', async () => { const html = await renderInbox([ { ...quote, @@ -164,11 +164,26 @@ function categories(html: string, pressed: boolean): Set { ); }); - test('includes quotes in the unfiltered empty inbox message', async () => { +test('renders a commit from someone the recipient watches, linking to it', async () => { + const html = await renderInbox( + [{ ...quote, type: 'committed', commit: quoteCommit }], + 'commits', + ); + + assert.match(html, /]*data-notification-link[^>]*>committed<\/a>/); + assert.match(html, /href="\/@quoter\/commits\/quote-commit"/); + assert.match(html, /My quote commentary/); + assert.match( + html, + /]*aria-pressed="true"[^>]*>Commits<\/button>/, + ); +}); + +test('includes quotes in the unfiltered empty inbox message', async () => { assert.match(await renderInbox([]), /replies, quotes, mentions/); }); - test('renders a follow-back control outside the follower profile link', async () => { +test('renders a follow-back control outside the follower profile link', async () => { const follow: NotificationGroup = { ...quote, type: 'followed', @@ -192,7 +207,7 @@ function categories(html: string, pressed: boolean): Set { ); }); - test('leaves follow-back out of a group of followers until it is expanded', async () => { +test('leaves follow-back out of a group of followers until it is expanded', async () => { const html = await renderInbox([ { ...quote, @@ -213,7 +228,7 @@ function categories(html: string, pressed: boolean): Set { assert.doesNotMatch(html, /aria-label="(Follow|Unfollow) @/); }); - test('renders notification code without altering emoji shortcodes or nesting interactive controls', async () => { +test('renders notification code without altering emoji shortcodes or nesting interactive controls', async () => { const html = await renderInbox([ { ...quote, @@ -229,7 +244,7 @@ function categories(html: string, pressed: boolean): Set { assert.doesNotMatch(html, /Copy code/); }); - test('renders highlighted notification blocks without a nested copy button', async () => { +test('renders highlighted notification blocks without a nested copy button', async () => { const html = await renderInbox([ { ...quote, @@ -255,18 +270,18 @@ function categories(html: string, pressed: boolean): Set { assert.doesNotMatch(html, /Copy code/); }); - test('renders all notification categories as enabled toggle buttons by default', async () => { +test('renders all notification categories as enabled toggle buttons by default', async () => { const body = await renderInbox([]); - assert.equal(categories(body, true).size, 6); + assert.equal(categories(body, true).size, 7); assert.doesNotMatch(body, /aria-current="page"|role="tab"/); }); - test('renders independent category selections and the all-off empty state', async () => { +test('renders independent category selections and the all-off empty state', async () => { const selected = await renderInbox([], 'replies,mentions'); assert.deepEqual([...categories(selected, true)], ['Replies', 'Mentions']); - assert.equal(categories(selected, false).size, 4); + assert.equal(categories(selected, false).size, 5); const none = await renderInbox([], 'none'); - assert.equal(categories(none, false).size, 6); + assert.equal(categories(none, false).size, 7); assert.match(none, /All categories are turned off/); assert.match(none, /Show all notifications/); }); diff --git a/tests/Unit/WatchButton.test.tsx b/tests/Unit/WatchButton.test.tsx new file mode 100644 index 000000000..5037d8d3c --- /dev/null +++ b/tests/Unit/WatchButton.test.tsx @@ -0,0 +1,58 @@ +/// + +import assert from 'node:assert/strict'; +import { createInertiaApp } from '@inertiajs/react'; +import { renderToString } from 'react-dom/server'; +import { test } from 'vite-plus/test'; +import { WatchButton } from '../../resources/js/components/watch-button'; + +const user = { id: 'octocat', handle: 'octocat' }; + +async function render(isFollowing: boolean, isWatching: boolean) { + const result = await createInertiaApp({ + page: { + component: 'user/show', + props: { auth: { user: { id: 'me' } }, errors: {} }, + url: '/@octocat', + version: null, + clearHistory: false, + encryptHistory: false, + rescuedProps: [], + rememberedState: {}, + flash: {}, + }, + resolve: () => () => ( + + ), + render: renderToString, + setup: ({ App, props }) => , + }); + + assert.ok(result); + return result.body; +} + +test('offers to watch someone you follow', async () => { + const html = await render(true, false); + + assert.match(html, /aria-label="Get notified when @octocat commits"/); + assert.match(html, /aria-pressed="false"/); +}); + +test('offers to stop watching someone you watch', async () => { + const html = await render(true, true); + + assert.match( + html, + /aria-label="Stop notifications for @octocat's commits"/, + ); + assert.match(html, /aria-pressed="true"/); +}); + +test('leaves the bell out for someone you do not follow', async () => { + assert.doesNotMatch(await render(false, false), /data-test="watch-button"/); +});