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
16 changes: 8 additions & 8 deletions KNOWLEDGE_BASE.md

Large diffs are not rendered by default.

7 changes: 2 additions & 5 deletions app/Actions/CreateCommit.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit
$this->updateCommitHotScores->handle([$commit->id]);
}

// An organization's commits notify no one yet, and get no welcome or answer: each would show who posted it.
// An organization's commits notify no one yet: each would show who posted it.
if (! $organization instanceof Organization) {
$this->createReplyNotifications->handle($commit);
$this->createQuoteNotifications->handle($commit);
Expand All @@ -105,12 +105,9 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit
dispatch(new UnfurlLink($linkPreview));
}

if (! $organization instanceof Organization) {
$this->createWorldReply->handle($commit);
}
$this->createWorldReply->handle($commit);

if ($commit->status === CommitStatus::Approved
&& ! $organization instanceof Organization
&& $user->github_id !== CreateTomFriendship::GITHUB_ID
) {
$tom = $this->tomQuery->get();
Expand Down
9 changes: 6 additions & 3 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -408,17 +408,19 @@ private function pendingOrganizationFollows(User $user): array
}

/**
* Moderators stay anonymous, as they are on the notifications page, and level-ups are the user's own doing.
* Moderators stay anonymous, as they are on the notifications page, and level-ups are the user's own doing. An
* organization's notifications name the organization the user heard about it for.
*
* @return Generator<int, array<string, int|string|null>>
*/
private function notifications(User $user): Generator
{
/** @var LazyCollection<int, object{type: string, actor: string, commit_id: string|null, reason: string|null, level: int|string|null, read_at: string|null, created_at: string}> $notifications */
/** @var LazyCollection<int, object{type: string, actor: string, organization: string|null, commit_id: string|null, reason: string|null, level: int|string|null, read_at: string|null, created_at: string}> $notifications */
$notifications = $user->notifications()
->join('profiles as actor_profiles', 'actor_profiles.id', '=', 'notifications.actor_id')
->leftJoin('profiles as organization_profiles', 'organization_profiles.id', '=', 'notifications.organization_id')
->toBase()
->select(['notifications.id', 'notifications.type', 'notifications.commit_id', 'notifications.reason', 'notifications.level', 'notifications.read_at', 'notifications.created_at', 'actor_profiles.handle as actor'])
->select(['notifications.id', 'notifications.type', 'notifications.commit_id', 'notifications.reason', 'notifications.level', 'notifications.read_at', 'notifications.created_at', 'actor_profiles.handle as actor', 'organization_profiles.handle as organization'])
->lazyById(500, 'notifications.id', 'id');

foreach ($notifications as $notification) {
Expand All @@ -427,6 +429,7 @@ private function notifications(User $user): Generator
yield [
'type' => $notification->type,
'actor' => $isModeration ? null : $notification->actor,
'organization' => $notification->organization,
'commit_id' => $notification->commit_id,
'reason' => $notification->reason,
'level' => $notification->level === null ? null : (int) $notification->level,
Expand Down
18 changes: 16 additions & 2 deletions app/Actions/CreateFirstCommitNotifications.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,28 @@

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

final readonly class CreateFirstCommitNotifications
{
public function __construct(private ProfileRecipientsQuery $recipients)
{
//
}

/**
* 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
* or quotes, or the members of the organizations it does, 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
Expand Down Expand Up @@ -59,7 +67,13 @@ public function handle(Commit ...$commits): void
->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])])
->whereNotIn('users.id', DB::query()->fromSub($this->recipients->builder(), 'recipients')
->whereIn('profile_id', [
...array_values($commit->mentions ?? []),
...array_filter([$commit->quotedCommit?->author_id]),
...DB::table('commit_topics')->where('commit_id', $commit->id)->where('topic_type', TopicType::Organization)->pluck('topic_id'),
])
->select('user_id'))
->pluck('users.id');

foreach ($followerIds as $followerId) {
Expand Down
65 changes: 49 additions & 16 deletions app/Actions/CreateMentionNotifications.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,70 @@

use App\Enums\CommitStatus;
use App\Enums\NotificationType;
use App\Enums\TopicType;
use App\Models\Commit;
use App\Models\Notification;
use App\Models\User;
use App\Queries\ProfileRecipientsQuery;
use Closure;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final readonly class CreateMentionNotifications
{
public function __construct(private ProfileRecipientsQuery $recipients)
{
//
}

/**
* Notifies everyone mentioned in each commit, but only once the commit is approved, so nobody hears about a
* mention they cannot see yet. Never for mentioning yourself, the parent's author of a reply or the quoted
* author of a quote, who already hear about it as a reply or a quote. Nobody hears from people they mute or
* from across a block either. Safe to call more than once.
* Notifies everyone mentioned in each commit, and every member of the organizations it mentions, but only once the
* commit is approved, so nobody hears about a mention they cannot see yet. Someone mentioned both ways hears about
* it once, for themselves. Never for mentioning yourself, or for whoever already hears about it as a reply or a
* quote. Nobody hears from people they mute or from across a block 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)
->whereNotNull('user_id')
->with(['parent', 'quotedCommit'])
->get();

$recipientsByCommit = $commits->mapWithKeys(fn (Commit $commit): array => [
$commit->id => array_values($commit->mentions ?? []),
])->filter();
$organizations = DB::table('commit_topics')
->whereIn('commit_id', $commits->modelKeys())
->where('topic_type', TopicType::Organization)
->get(['commit_id', 'topic_id'])
->groupBy('commit_id');

/** @var Collection<string, list<string>> $mentionedByCommit */
$mentionedByCommit = $commits->mapWithKeys(fn (Commit $commit): array => [$commit->id => [
...array_values($commit->mentions ?? []),
...$organizations->get($commit->id, new Collection())->pluck('topic_id'),
]])->filter();

if ($recipientsByCommit->isEmpty()) {
if ($mentionedByCommit->isEmpty()) {
return;
}

/** @var Collection<string, Collection<int, object{profile_id: string, user_id: string, organization_id: string|null}>> $recipients */
$recipients = DB::query()->fromSub($this->recipients->builder(), 'recipients')
->whereIn('profile_id', $mentionedByCommit->flatten()
->concat($commits->pluck('parent.author_id'))
->concat($commits->pluck('quotedCommit.author_id'))
->filter()->unique()->values()->all())
->get()
->groupBy('profile_id');
/** @var Closure(?string): Collection<int, object{profile_id: string, user_id: string, organization_id: string|null}> $recipientsOf */
$recipientsOf = fn (?string $profileId): Collection => $recipients->get((string) $profileId, new Collection());

$authorIds = $commits->pluck('user_id')->unique()->values()->all();
$silencedAuthors = fn (BelongsToMany $authors): BelongsToMany => $authors->whereKey($authorIds)->without('profile')->select('users.id');

$users = User::query()
->whereKey($recipientsByCommit->flatten()->unique()->values()->all())
->whereKey($recipients->flatten()->pluck('user_id')->unique()->values()->all())
->without('profile')
->with(['muting' => $silencedAuthors, 'blocking' => $silencedAuthors, 'blockers' => $silencedAuthors])
->get(['id'])
Expand All @@ -50,22 +79,26 @@ public function handle(Commit ...$commits): void

foreach ($commits as $commit) {
$authorId = $commit->user_id;
assert($authorId !== null);
$hearsOtherwise = $recipientsOf($commit->parent?->author_id)->concat($recipientsOf($commit->quotedCommit?->author_id))->pluck('user_id');

if ($authorId === null) {
continue;
}
$mentioned = collect($mentionedByCommit->get($commit->id, []))
->flatMap(fn (string $profileId): Collection => $recipientsOf($profileId))
->unique('user_id')
->reject(fn (object $recipient): bool => $recipient->user_id === $authorId || $hearsOtherwise->contains($recipient->user_id));

foreach ($recipientsByCommit->get($commit->id, []) as $id) {
$user = $users->get($id);
foreach ($mentioned as $recipient) {
$user = $users->get($recipient->user_id);

if (! $user instanceof User || $user->id === $authorId || $user->id === $commit->parent?->user_id || $user->id === $commit->quotedCommit?->user_id || $this->isSilenced($user, $authorId)) {
if (! $user instanceof User || $this->isSilenced($user, $authorId)) {
continue;
}

$notifications[] = [
'id' => Str::uuid7()->toString(),
'recipient_id' => $user->id,
'actor_id' => $authorId,
'organization_id' => $recipient->organization_id,
'type' => NotificationType::Mentioned->value,
'commit_id' => $commit->id,
'key' => 'mentioned:'.$commit->id,
Expand Down
58 changes: 28 additions & 30 deletions app/Actions/CreateQuoteNotifications.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,22 @@
use App\Enums\NotificationType;
use App\Models\Commit;
use App\Models\Notification;
use App\Queries\ProfileRecipientsQuery;
use App\Queries\SilencedActorQuery;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;

final readonly class CreateQuoteNotifications
{
public function __construct(private ProfileRecipientsQuery $recipients)
{
//
}

/**
* Notifies the author of each quoted commit, but only once the quote is approved, so
* nobody hears about a quote they cannot see yet. Nobody hears from people they mute or
* from across a block either. Safe to call more than once.
* Notifies the author of each quoted commit, every member of it for an organization's, but only once the quote
* is approved, so nobody hears about a quote they cannot see yet. Nobody hears about their own quote, from people
* they mute or from across a block either. Safe to call more than once.
*/
public function handle(Commit ...$commits): void
{
Expand All @@ -29,41 +36,32 @@ public function handle(Commit ...$commits): void
return;
}

$silenced = new SilencedActorQuery('quoted.user_id', 'commits.user_id');
$silenced = new SilencedActorQuery('recipients.user_id', 'commits.user_id');

/** @var Collection<int, object{id: string, user_id: string, recipient_id: string, organization_id: string|null}> $quotes */
$quotes = Commit::query()
->select('commits.*')
->join('commits as quoted', 'quoted.id', '=', 'commits.quoted_commit_id')
->joinSub($this->recipients->builder(), 'recipients', 'recipients.profile_id', '=', 'quoted.author_id')
->whereKey($quoteIds)
->whereNotNull('quoted.user_id')
->whereNull('quoted.deleted_at')
->where('commits.status', CommitStatus::Approved)
->whereColumn('recipients.user_id', '<>', 'commits.user_id')
->whereNotExists($silenced->mutes(...))
->whereNotExists($silenced->blocking(...))
->whereNotExists($silenced->blockers(...))
->with('quotedCommit')
->get();

$notifications = [];

foreach ($quotes as $quote) {
$quoted = $quote->quotedCommit;

if (! $quoted instanceof Commit || $quoted->user_id === $quote->user_id) {
continue;
}

$notifications[] = [
'id' => Str::uuid7()->toString(),
'recipient_id' => $quoted->user_id,
'actor_id' => $quote->user_id,
'type' => NotificationType::Quoted->value,
'commit_id' => $quote->id,
'key' => 'quoted:'.$quote->id,
'created_at' => now(),
'updated_at' => now(),
];
}
->toBase()
->get(['commits.id', 'commits.user_id', 'recipients.user_id as recipient_id', 'recipients.organization_id']);

Notification::query()->insertOrIgnore($notifications);
Notification::query()->insertOrIgnore($quotes->map(fn (object $quote): array => [
'id' => Str::uuid7()->toString(),
'recipient_id' => $quote->recipient_id,
'actor_id' => $quote->user_id,
'organization_id' => $quote->organization_id,
'type' => NotificationType::Quoted->value,
'commit_id' => $quote->id,
'key' => 'quoted:'.$quote->id,
'created_at' => now(),
'updated_at' => now(),
])->all());
}
}
60 changes: 29 additions & 31 deletions app/Actions/CreateReplyNotifications.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,22 @@
use App\Enums\NotificationType;
use App\Models\Commit;
use App\Models\Notification;
use App\Queries\ProfileRecipientsQuery;
use App\Queries\SilencedActorQuery;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;

final readonly class CreateReplyNotifications
{
public function __construct(private ProfileRecipientsQuery $recipients)
{
//
}

/**
* Notifies the author of each reply's parent, but only once the reply is approved, so
* nobody hears about a reply they cannot see yet. Nobody hears from people they mute or
* from across a block either. Safe to call more than once.
* Notifies the author of each reply's parent, every member of it for an organization's, but only once the reply
* is approved, so nobody hears about a reply they cannot see yet. Nobody hears about their own reply, from people
* they mute or from across a block either. Safe to call more than once.
*/
public function handle(Commit ...$commits): void
{
Expand All @@ -29,42 +36,33 @@ public function handle(Commit ...$commits): void
return;
}

$silenced = new SilencedActorQuery('parents.user_id', 'commits.user_id');
$silenced = new SilencedActorQuery('recipients.user_id', 'commits.user_id');

/** @var Collection<int, object{id: string, user_id: string, parent_id: string, recipient_id: string, organization_id: string|null}> $replies */
$replies = Commit::query()
->select('commits.*')
->join('commits as parents', 'parents.id', '=', 'commits.parent_id')
->joinSub($this->recipients->builder(), 'recipients', 'recipients.profile_id', '=', 'parents.author_id')
->whereKey($replyIds)
->whereNotNull('parents.user_id')
->whereNull('parents.deleted_at')
->where('commits.status', CommitStatus::Approved)
->whereColumn('recipients.user_id', '<>', 'commits.user_id')
->whereNotExists($silenced->mutes(...))
->whereNotExists($silenced->blocking(...))
->whereNotExists($silenced->blockers(...))
->with('parent')
->get();

$notifications = [];

foreach ($replies as $reply) {
$parent = $reply->parent;

if (! $parent instanceof Commit || $parent->user_id === $reply->user_id) {
continue;
}

$notifications[] = [
'id' => Str::uuid7()->toString(),
'recipient_id' => $parent->user_id,
'actor_id' => $reply->user_id,
'type' => NotificationType::Replied->value,
'commit_id' => $reply->id,
'subject_id' => $parent->id,
'key' => 'replied:'.$reply->id,
'created_at' => now(),
'updated_at' => now(),
];
}
->toBase()
->get(['commits.id', 'commits.user_id', 'commits.parent_id', 'recipients.user_id as recipient_id', 'recipients.organization_id']);

Notification::query()->insertOrIgnore($notifications);
Notification::query()->insertOrIgnore($replies->map(fn (object $reply): array => [
'id' => Str::uuid7()->toString(),
'recipient_id' => $reply->recipient_id,
'actor_id' => $reply->user_id,
'organization_id' => $reply->organization_id,
'type' => NotificationType::Replied->value,
'commit_id' => $reply->id,
'subject_id' => $reply->parent_id,
'key' => 'replied:'.$reply->id,
'created_at' => now(),
'updated_at' => now(),
])->all());
}
}
Loading
Loading