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
10 changes: 6 additions & 4 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,21 @@ Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](re

### Organization pages

GitHub organizations, like `@laravel`, have a page at `/@{handle}` too, with their GitHub avatar (square, so they never pass for a person), name, description, location, website, and a link to GitHub. An organization gets its page once an approved commit mentions it, from its public GitHub profile, and the profile refreshes when commits keep mentioning it, at most once a day. Nobody signs in as an organization, and everything on its page comes from GitHub, except its color. Shared links to an organization's page preview as a card with its square avatar, name, handle, and description, like a developer's profile.
GitHub organizations, like `@laravel`, have a page at `/@{handle}` too, with their GitHub avatar (square, so they never pass for a person), name, description, location, website, and a link to GitHub. An organization gets its page once an approved commit mentions it, from its public GitHub profile, and the profile refreshes when commits keep mentioning it, at most once a day. Nobody signs in as an organization: its members post for it. Everything on its page comes from GitHub, except its color and its commits. Shared links to an organization's page preview as a card with its square avatar, name, handle, and description, like a developer's profile.

The **Mentions** tab lists the commits and replies that mention the organization, newest first, a reply with the commit it answers. A commit stays listed after the organization is renamed, since it is linked to the organization rather than to the handle. It follows the usual visibility: guests see approved commits only, you also see your own pending ones, and commits across a block or by people you muted are left out.
The **Commits** tab lists the organization's own commits, newest first. The **Mentions** tab lists the commits and replies that mention the organization, newest first, a reply with the commit it answers. A commit stays listed after the organization is renamed, since it is linked to the organization rather than to the handle. It follows the usual visibility: guests see approved commits only, you also see your own pending ones, and commits across a block or by people you muted are left out.

A member of an organization can **Claim** its page, which lists them as its admin. Only the first member to claim it becomes its admin: once a page has one, it can't be claimed again, and the Claim button is gone. Claiming asks for no extra GitHub permission: it checks that GitHub lists you among the organization's public members. GitHub keeps membership private by default, so if yours is, the page links to the organization's People page on GitHub, where you make your membership public, then claim again. You can make it private again once you're the admin. GitHub is only the way in: leaving the organization on GitHub doesn't remove you as an admin on /dev/null.

After that, members are managed on /dev/null, not mirrored from GitHub. An organization's members open its **Settings** from its page, where the **Members** tab lists everyone, each an **admin** or a **member**. Admins add people by their /dev/null handle, searching by handle or name as they type, except people they blocked or who blocked them, make them admins or members, and remove them. Any member can leave. Its last admin can't leave or become a member. Only its members see who its members are; its page lists its admins to everyone. Your organizations and your role in each are in your data export and go when you delete your account.

Its admins and members can **post as the organization**: in the composer, and in a reply's, they open their avatar and pick the organization, then switch back to themselves the same way. Every commit and reply starts as yourself. A commit from an organization shows the organization alone, with its square avatar and color, at `/@{handle}/commits/{id}`: nobody else sees which member posted it. It belongs to the organization: any of its members can delete it, and none can star it, report it, vote in its poll or give it pastéis. It counts toward no one's level, and blocking or muting the person who posted it doesn't hide it. Moderation still holds the person who posted it to account, and if they delete their account, the organization's commits they posted stay. Organizations can't schedule commits yet, and their commits don't notify anyone yet. Apps can post as an organization through the API too, by sending its handle as `author`. Posting as an organization is still being rolled out: until administrators switch it on for everyone, the **Post as** menu doesn't show and the API refuses `author`.

Every organization has a color, /dev/null's blue until an admin chooses another in the **Profile** tab of its **Settings**, where its admins land: one of the same six lane colors as developers, or a custom one. It rings the organization's avatar, leads its handle, and colors its link preview.

Signed in, you can **Follow** an organization from its page, which shows how many people follow it. Once you follow it, the button opens a menu where you can unfollow. Following will put the organization's own commits in your Following log once organizations can commit, which isn't possible yet. **Watch**, which will also show the commits that mention the organization, is planned and shown as coming soon. The organizations you follow are listed on your Following list, above the people, a few at a time with **Show more organizations**, and count toward your following count. They are in your data export and go when you delete your account.
Signed in, you can **Follow** an organization from its page, which shows how many people follow it. Once you follow it, the button opens a menu where you can unfollow. Following puts the organization's own commits in your Following log, and ranks them higher in Trending, like the people you follow. **Watch**, which will also show the commits that mention the organization, is planned and shown as coming soon. The organizations you follow are listed on your Following list, above the people, a few at a time with **Show more organizations**, and count toward your following count. They are in your data export and go when you delete your account.

Sources: [organization page](resources/js/pages/organization/show.tsx), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php), [claims](app/Http/Controllers/OrganizationAdminController.php), [color](app/Http/Controllers/OrganizationLaneController.php), [members](app/Http/Controllers/OrganizationMemberController.php).
Sources: [organization page](resources/js/pages/organization/show.tsx), [commits](app/Queries/OrganizationCommitsQuery.php), [posting as an organization](app/Rules/OrganizationToPostAs.php), [mentions](app/Queries/OrganizationMentionsQuery.php), [organization lookups](app/Actions/UpdateCommitTopics.php), [claims](app/Http/Controllers/OrganizationAdminController.php), [color](app/Http/Controllers/OrganizationLaneController.php), [members](app/Http/Controllers/OrganizationMemberController.php).

### Going live

Expand Down
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ What exists, one line each (details in the knowledge base):
- **Logs:** Following, Latest, and Trending.
- **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries).
- **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube.
- **Organization pages:** GitHub organizations get a page mirrored from GitHub once an approved commit mentions them, and `@organization` mentions link to it.
- **Organization pages:** GitHub organizations get a page mirrored from GitHub once an approved commit mentions them, and `@organization` mentions link to it. Their members post commits as the organization, which readers see from the organization alone.
- **Remotes:** private one-to-one and group messages (orgs), and calls, one-to-one or in an org.
- **Notifications:** in-app activity and on-site Remotes alerts by default; off-site browser and PWA notifications require a separate opt-in. Sound is controlled independently and plays while the site is open.
- **Search:** developers by name, commits by meaning.
Expand Down
26 changes: 17 additions & 9 deletions app/Actions/CreateCommit.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use App\Jobs\UnfurlLink;
use App\Models\Commit;
use App\Models\LinkPreview;
use App\Models\Organization;
use App\Models\User;
use App\Queries\MentionedOrganizationsQuery;
use App\Queries\MentionedUsersQuery;
Expand Down Expand Up @@ -53,22 +54,23 @@ public function __construct(
* @param array<int, string|null> $imageDescriptions
* @param array<string, string> $replyRecipients
*/
public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, array $replyRecipients = []): Commit
public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, array $replyRecipients = [], ?Organization $organization = null): Commit
{
$url = match ($linkPreview) {
true => $this->urls->first($body),
false => null,
default => $linkPreview,
};

return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $replyRecipients): Commit {
return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $replyRecipients, $organization): Commit {
$linkPreview = $this->linkPreviewFor($url);
$createdAt = now();
$hasGoodReputation = $user->hasGoodReputation();
$mentions = $this->mentionedUsers->get($body);

$commit = $user->commits()->create([
$commit = Commit::query()->create([
'user_id' => $user->id,
'author_id' => $organization->id ?? $user->id,
'body' => $this->emojiShortcodes->replace($body),
'mentions' => [...$replyRecipients, ...$mentions],
'parent_id' => $parent?->id,
Expand All @@ -91,18 +93,24 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit
$this->updateCommitHotScores->handle([$commit->id]);
}

$this->createReplyNotifications->handle($commit);
$this->createQuoteNotifications->handle($commit);
$this->createMentionNotifications->handle($commit);
$this->createFirstCommitNotifications->handle($commit);
// An organization's commits notify no one yet, and get no welcome or answer: each would show who posted it.
if (! $organization instanceof Organization) {
$this->createReplyNotifications->handle($commit);
$this->createQuoteNotifications->handle($commit);
$this->createMentionNotifications->handle($commit);
$this->createFirstCommitNotifications->handle($commit);
}
Comment thread
mnapoli marked this conversation as resolved.
Resolved

if ($linkPreview instanceof LinkPreview && $linkPreview->fetched_at === null) {
dispatch(new UnfurlLink($linkPreview));
}

$this->createWorldReply->handle($commit);
if (! $organization instanceof Organization) {
$this->createWorldReply->handle($commit);
}
Comment thread
mnapoli marked this conversation as resolved.
Resolved

if ($commit->status === CommitStatus::Approved
&& ! $organization instanceof Organization
Comment thread
mnapoli marked this conversation as resolved.
Resolved
&& $user->github_id !== CreateTomFriendship::GITHUB_ID
) {
$tom = $this->tomQuery->get();
Expand All @@ -112,7 +120,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit
}
}

if ($commit->status === CommitStatus::Approved) {
if ($commit->status === CommitStatus::Approved && ! $organization instanceof Organization) {
dispatch(new CheckUserLevel($user))->afterCommit();
}

Expand Down
3 changes: 2 additions & 1 deletion app/Actions/CreateCommitStar.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ public function handle(User $user, Commit $commit): void
$this->updateCommitHotScores->handle([$commit->id]);

if ($commit->user_id !== $user->id) {
$author = $commit->user()->first();
// Stars on an organization's commit count toward no one's level.
$author = $commit->author_id === $commit->user_id ? $commit->user()->first() : null;

DB::afterCommit(function () use ($user, $author): void {
dispatch(new CheckUserLevel($user));
Expand Down
39 changes: 37 additions & 2 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Actions;

use App\Enums\NotificationType;
use App\Models\Commit;
use App\Models\CommitImage;
use App\Models\CommitReport;
use App\Models\Conversation;
Expand Down Expand Up @@ -39,6 +40,7 @@
* profile: array<string, bool|int|string|null>,
* active_days: Generator<int, string>,
* commits: Generator<int, array<string, mixed>>,
* organization_commits: Generator<int, array<string, mixed>>,
* polls: Generator<int, ExportPoll>,
* poll_votes: Generator<int, ExportPollVote>,
* stars: Generator<int, string>,
Expand Down Expand Up @@ -72,7 +74,7 @@
public function handle(User $user): array
{
return [
'schema_version' => 4,
'schema_version' => 5,
'exported_at' => now()->toIso8601String(),
'profile' => [
'github_id' => $user->github_id,
Expand Down Expand Up @@ -110,6 +112,7 @@ public function handle(User $user): array
],
'active_days' => $this->activeDays($user),
'commits' => $this->commits($user),
'organization_commits' => $this->organizationCommits($user),
'polls' => $this->polls($user),
'poll_votes' => $this->pollVotes($user),
'stars' => $this->stars($user),
Expand Down Expand Up @@ -214,12 +217,44 @@ private function commits(User $user): Generator
}

/**
* What the user posted for organizations, which is theirs to read back, though the organizations' own, even once
* GitHub no longer has the organization and its commits lost their author.
*
* @return Generator<int, array<string, mixed>>
*/
private function organizationCommits(User $user): Generator
{
$commits = Commit::query()
->withTrashed()
->where('user_id', $user->id)
->where(fn (CommitBuilder $query): CommitBuilder => $query->whereColumn('author_id', '!=', 'user_id')->orWhereNull('author_id'))
->with(['author', 'images:id,commit_id'])
->lazyById(500);

foreach ($commits as $commit) {
yield [
'id' => $commit->id,
'organization' => $commit->author?->handle,
'parent_id' => $commit->parent_id,
'quoted_commit_id' => $commit->quoted_commit_id,
'body' => $commit->body,
'status' => $commit->status->value,
'images' => $commit->images->map(fn (CommitImage $image): string => $image->url())->all(),
'created_at' => $commit->created_at->toIso8601String(),
'deleted_at' => $commit->deleted_at?->toIso8601String(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
];
}
}

/**
* The polls of every commit the user posted, theirs or an organization's.
*
* @return Generator<int, ExportPoll>
*/
private function polls(User $user): Generator
{
$polls = Poll::query()
->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->where('author_id', $user->id))
->whereHas('commit', fn (CommitBuilder $query): CommitBuilder => $query->where('user_id', $user->id))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
->with('options')
->lazyById(500);

Expand Down
1 change: 1 addition & 0 deletions app/Actions/CreateQuoteNotifications.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public function handle(Commit ...$commits): void
->select('commits.*')
->join('commits as quoted', 'quoted.id', '=', 'commits.quoted_commit_id')
->whereKey($quoteIds)
->whereNotNull('quoted.user_id')
->where('commits.status', CommitStatus::Approved)
->whereNotExists($silenced->mutes(...))
->whereNotExists($silenced->blocking(...))
Expand Down
1 change: 1 addition & 0 deletions app/Actions/CreateReplyNotifications.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public function handle(Commit ...$commits): void
->select('commits.*')
->join('commits as parents', 'parents.id', '=', 'commits.parent_id')
->whereKey($replyIds)
->whereNotNull('parents.user_id')
->where('commits.status', CommitStatus::Approved)
->whereNotExists($silenced->mutes(...))
->whereNotExists($silenced->blocking(...))
Expand Down
3 changes: 2 additions & 1 deletion app/Actions/CreateStarNotification.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
*/
public function handle(User $stargazer, Commit $commit): void
{
if ($commit->user_id === $stargazer->id) {
// Nobody is told about stars on an organization's commit whose poster has since deleted their account.
if ($commit->user_id === null || $commit->user_id === $stargazer->id) {
return;
}

Expand Down
17 changes: 16 additions & 1 deletion app/Actions/DeleteOrganization.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,33 @@
namespace App\Actions;

use App\Enums\TopicType;
use App\Models\Commit;
use App\Models\Organization;
use Illuminate\Support\Facades\DB;

final readonly class DeleteOrganization
{
public function __construct(private DeleteCommit $deleteCommit)
{
//
}

/**
* Deletes an organization GitHub no longer has, with its members and followers, and unlinks the commits that mention
* it, so none links to a page that is gone.
* it, so none links to a page that is gone. Its own commits are deleted as their posters would, leaving tombstones
* for the replies under them, and lose their author with its profile.
*/
public function handle(Organization $organization): void
{
DB::transaction(function () use ($organization): void {
// A commit posted for it meanwhile waits on the profile, and one restored meanwhile on its own row, then
// fails or stays deleted, rather than slipping past the deletion and losing its author while still live.
$organization->profile()->lockForUpdate()->first();
$organization->profile->commits()->withTrashed()->orderBy('id')->lockForUpdate()->get()
->reject(fn (Commit $commit): bool => $commit->trashed())
->each(fn (Commit $commit): Commit => $this->deleteCommit->handle($commit));
$organization->profile->commits()->withTrashed()->update(['author_id' => null]);

DB::table('commit_topics')
->where('topic_type', TopicType::Organization->value)
->where('topic_id', $organization->id)
Expand Down
Loading
Loading