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: 2 additions & 0 deletions app/Actions/CreateCommit.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public function __construct(
private CreateReplyNotifications $createReplyNotifications,
private CreateQuoteNotifications $createQuoteNotifications,
private CreateMentionNotifications $createMentionNotifications,
private CreateWatchNotifications $createWatchNotifications,
private CreateCommitImages $createCommitImages,
private CreateWorldReply $createWorldReply,
private UpdateCommitHotScores $updateCommitHotScores,
Expand Down Expand Up @@ -91,6 +92,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->createWatchNotifications->handle($commit);

if ($linkPreview instanceof LinkPreview && $linkPreview->fetched_at === null) {
dispatch(new UnfurlLink($linkPreview));
Expand Down
76 changes: 76 additions & 0 deletions app/Actions/CreateWatchNotifications.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?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\Queries\SilencedActorQuery;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final readonly class CreateWatchNotifications
{
/**
* Notifies everyone watching the author of each new commit, but only once it is approved, so nobody hears about a
* commit they cannot see yet. Replies are left out, as watching is about what someone posts, not every
* conversation they join. Never for the people a commit mentions or quotes, who already hear about it as a mention
* or a quote. Nobody hears from people they mute either. Safe to call more than once.
*/
public function handle(Commit ...$commits): void
{
$commits = Commit::query()
->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);
}
}
}
2 changes: 2 additions & 0 deletions app/Actions/UpdateCommitStatuses.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public function __construct(
private CreateReplyNotifications $createReplyNotifications,
private CreateQuoteNotifications $createQuoteNotifications,
private CreateMentionNotifications $createMentionNotifications,
private CreateWatchNotifications $createWatchNotifications,
private CreateWorldReply $createWorldReply,
private UpdateCommitHotScores $updateCommitHotScores,
private TomQuery $tomQuery,
Expand Down Expand Up @@ -83,6 +84,7 @@ public function handle(array $commitIds, CommitStatus $status, ?string $reason =
$this->createReplyNotifications->handle(...$commits);
$this->createQuoteNotifications->handle(...$commits);
$this->createMentionNotifications->handle(...$commits);
$this->createWatchNotifications->handle(...$commits);

$commits->each(fn (Commit $commit) => $this->createWorldReply->handle($commit));
LinkCommitTopics::dispatchFor(...$commits->all());
Expand Down
20 changes: 20 additions & 0 deletions app/Actions/UpdateUserWatch.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\User;

final readonly class UpdateUserWatch
{
/**
* Only a follow can be watched, so this does nothing for someone the user doesn't follow.
*/
public function handle(User $user, User $watched, bool $watching): void
{
$user->following()->newPivotQuery()
->where('followed_id', $watched->id)
->update(['watching' => $watching, 'updated_at' => now()]);
}
}
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 Committed = 'committed';
}
1 change: 1 addition & 0 deletions app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions app/Http/Controllers/UserWatchController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\UpdateUserWatch;
use App\Http\Requests\CreateUserWatchRequest;
use App\Http\Requests\DeleteUserWatchRequest;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;

final readonly class UserWatchController
{
public function store(CreateUserWatchRequest $request, #[CurrentUser] User $currentUser, User $user, UpdateUserWatch $action): RedirectResponse
{
$action->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();
}
}
30 changes: 30 additions & 0 deletions app/Http/Requests/CreateUserWatchRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;

final class CreateUserWatchRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->user();
assert($user instanceof User);

$watched = $this->route('user');
assert($watched instanceof User);

return $user->isFollowing($watched);
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}
}
23 changes: 23 additions & 0 deletions app/Http/Requests/DeleteUserWatchRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class DeleteUserWatchRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}
}
2 changes: 1 addition & 1 deletion app/Http/Requests/ListNotificationsRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

final class ListNotificationsRequest extends FormRequest
{
public const string TYPE_RULE = 'regex:/\A(?:all|none|(?:replies|quotes|stars|mentions|follows|moderation)(?:,(?:replies|quotes|stars|mentions|follows|moderation))*)\z/';
public const string TYPE_RULE = 'regex:/\A(?:all|none|(?:replies|quotes|stars|mentions|commits|follows|moderation)(?:,(?:replies|quotes|stars|mentions|commits|follows|moderation))*)\z/';

public static function rememberedType(Request $request, User $user): string
{
Expand Down
8 changes: 8 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,14 @@ public function isFollowing(self $user): bool
return $this->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();
Expand Down
2 changes: 1 addition & 1 deletion app/Queries/NotificationBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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::Committed])
->whereHas('commit', fn (CommitBuilder $commit): CommitBuilder => $commit
->where('status', CommitStatus::Approved)
->offset(0)))
Expand Down
1 change: 1 addition & 0 deletions app/Queries/NotificationGroupsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 => [],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Watching lives on the follow, so unfollowing someone, or a block ending the follow, stops watching them too.
*/
public function up(): void
{
Schema::table('follows', function (Blueprint $table): void {
$table->boolean('watching')->default(false);
});
}
};
57 changes: 57 additions & 0 deletions resources/js/components/watch-button.tsx
Original file line number Diff line number Diff line change
@@ -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<User, 'id' | 'handle'>;
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 (
<Button
variant="outline"
size="icon"
className={
isWatching ? 'text-primary hover:text-primary' : undefined
}
asChild
>
<Link
href={
isWatching
? UserWatchController.destroy(user)
: UserWatchController.store(user)
}
as="button"
preserveScroll
only={['isWatching']}
aria-label={label}
aria-pressed={isWatching}
title={label}
data-test="watch-button"
>
{isWatching ? <BellRing /> : <Bell />}
</Link>
</Button>
);
}
Loading