Skip to content
10 changes: 6 additions & 4 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,11 @@ Every developer has a profile at `/@{username}`, with their GitHub identity, bio

Change your name, avatar, bio, company, location, or website on GitHub, not on /dev/null. Settings → Profile offers **Edit on GitHub** and **Sync from GitHub**. Profile details also refresh on sign-in. Manual sync is limited to once a minute. The GitHub Sponsors link is refreshed on sign-in only. A GitHub username rename is picked up on sign-in or sync; if a stale account still holds the new username because its owner also renamed, that account moves to a `_renamed-<github id>` placeholder until its owner signs in or syncs again. Accounts are matched by GitHub id and never merged.

A developer who isn't on /dev/null yet gets an **unclaimed profile** at `/@{username}` as soon as an approved commit mentions them. It shows their GitHub name and avatar, how many developers here follow them on GitHub and will follow them here, and the commits and replies that mention them. **I am @username** signs in with GitHub, which only that account can do. Signing in turns those mentions into regular ones and brings the waiting followers over. Usernames nobody mentioned are not found, so nothing is fetched from GitHub for them, and unclaimed profiles are kept out of search engines.

Profiles also show [achievement badges and an XP-based Unix rank](#achievement-badges-and-xp). Click a badge or rank to inspect unlock requirements and progress.

Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](resources/js/pages/user-profile/edit.tsx), [profile sharing](resources/js/components/profile-share-button.tsx), [identity sync](app/Actions/UpdateOrCreateUser.php).
Sources: [profile page](resources/js/pages/user/show.tsx), [unclaimed profile](resources/js/pages/user/unclaimed.tsx), [profile settings](resources/js/pages/user-profile/edit.tsx), [profile sharing](resources/js/components/profile-share-button.tsx), [identity sync](app/Actions/UpdateOrCreateUser.php).

### Going live

Expand Down Expand Up @@ -138,7 +140,7 @@ Sources: [badge definitions](app/Enums/Achievement.php), [unlock conditions and

### Following developers and suggestions

Follow or unfollow from a developer's profile or a follow control elsewhere in the app. Following puts their commits in your Following log. You cannot follow yourself or follow across a block. GitHub follows are imported once: connections to developers already on /dev/null are added in both directions, with up to 1,000 GitHub connections checked each way. This is not continuous synchronization, so unfollowing someone on /dev/null sticks.
Follow or unfollow from a developer's profile or a follow control elsewhere in the app. Following puts their commits in your Following log. You cannot follow yourself or follow across a block. GitHub follows are imported once: connections to developers already on /dev/null are added in both directions, with up to 1,000 GitHub connections checked each way. This is not continuous synchronization, so unfollowing someone on /dev/null sticks. The GitHub accounts you follow that aren't on /dev/null yet are remembered, and you follow them when they sign in.

**Who to follow** suggests developers in the side column or within the log on smaller screens, each with the first line of their bio and why they are suggested (how many people you follow follow them, or their follower count). Someone who already follows you gets **Follow back**. Show or hide these suggestions in Settings → Appearance. You can also follow back from the notifications list.

Expand Down Expand Up @@ -188,7 +190,7 @@ Sources: [translation endpoint](app/Http/Controllers/CommitTranslationController

Commit and reply text supports inline code in backticks and fenced code blocks with syntax highlighting and a copy control. Text outside code supports emoji shortcodes, clickable URLs, and `@username` mentions. This is developer-oriented text formatting, not a full rich-text editor.

Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In an org's messages, suggestions are limited to the org's members. Mentions link to profiles and can notify the mentioned developer after approval. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions.
Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In an org's messages, suggestions are limited to the org's members. Mentions link to profiles and can notify the mentioned developer after approval. A mention of someone who isn't on /dev/null yet links to their unclaimed profile. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions.

Sources: [body renderer](resources/js/components/commit-body.tsx), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php).

Expand Down Expand Up @@ -444,7 +446,7 @@ Sources: [issue command UI](resources/js/components/issue-command.tsx), [issue v

### Exporting your data

Choose **Download JSON** in Settings → Profile to export your account data. The export includes profile details, your commits and replies (including pending, rejected, and deleted entries still stored), polls and your votes, stars, saved commits, follows, mutes, blocks, messages you sent, notifications, SSH key fingerprints, the devices you approved for an app, and active-session metadata. Other people's replies and received messages are not included as your authored data. Message images, message reactions, reports you filed, and scheduled commits are not included either. Image references are URLs, not an archive of the image files.
Choose **Download JSON** in Settings → Profile to export your account data. The export includes profile details, your commits and replies (including pending, rejected, and deleted entries still stored), polls and your votes, stars, saved commits, follows (including the GitHub accounts you follow that aren't on /dev/null yet), mutes, blocks, messages you sent, notifications, SSH key fingerprints, the devices you approved for an app, and active-session metadata. Other people's replies and received messages are not included as your authored data. Message images, message reactions, reports you filed, and scheduled commits are not included either. Image references are URLs, not an archive of the image files.

The export does not expose session IDs, authentication tokens, private SSH keys, or other people's poll choices. Keep the downloaded JSON private because it includes personal account information. Exports are limited to **three per hour**.

Expand Down
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ What exists, one line each (details in the knowledge base):
- **Commits:** text with code, images, link cards and embedded players, or a poll; replies, quotes, threads, scheduling, translation.
- **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.
- **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube; a developer mentioned before joining has an unclaimed profile to claim by signing in.
- **Remotes:** private one-to-one and group messages (orgs).
- **Notifications:** in-app only.
- **Search:** developers by name, commits by meaning.
Expand Down
2 changes: 2 additions & 0 deletions app/Actions/CreateCommit.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public function __construct(
private CreateQuoteNotifications $createQuoteNotifications,
private CreateMentionNotifications $createMentionNotifications,
private CreateCommitImages $createCommitImages,
private CreatePendingMentions $createPendingMentions,
private CreateWorldReply $createWorldReply,
private UpdateCommitHotScores $updateCommitHotScores,
private TheAlgorithm $theAlgorithm,
Expand Down Expand Up @@ -69,6 +70,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit
]);

$this->createCommitImages->handle($commit, $images, $imageDescriptions);
$this->createPendingMentions->handle($commit);

if ($poll instanceof NewPoll) {
$this->createPoll($commit, $poll);
Expand Down
15 changes: 14 additions & 1 deletion app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
/**
* Everything stored about the user, deleted commits included, but never secrets like the remember token or session ids.
*
* @return array{schema_version: int, exported_at: string, profile: array<string, bool|int|string|null>, commits: Generator<int, array<string, mixed>>, polls: Generator<int, array{commit_id: string, duration_minutes: int, opened_at: string|null, options: list<string>, created_at: string}>, poll_votes: Generator<int, array{commit_id: string, option: string, created_at: string}>, stars: Generator<int, string>, bookmarks: Generator<int, string>, following: Generator<int, array{username: string}>, followers: Generator<int, array{username: string}>, muting: Generator<int, array{username: string}>, blocking: Generator<int, array{username: string}>, messages: Generator<int, array{to: string|null, body: string, created_at: string}>, notifications: Generator<int, array<string, string|null>>, ssh_keys: array<mixed>, devices: array<int, array{name: string, fingerprint: string, approved_at: string, last_used_at: string|null}>, sessions: Collection<int, stdClass>}
* @return array{schema_version: int, exported_at: string, profile: array<string, bool|int|string|null>, commits: Generator<int, array<string, mixed>>, polls: Generator<int, array{commit_id: string, duration_minutes: int, opened_at: string|null, options: list<string>, created_at: string}>, poll_votes: Generator<int, array{commit_id: string, option: string, created_at: string}>, stars: Generator<int, string>, bookmarks: Generator<int, string>, following: Generator<int, array{username: string}>, followers: Generator<int, array{username: string}>, waiting_to_follow: Generator<int, string>, muting: Generator<int, array{username: string}>, blocking: Generator<int, array{username: string}>, messages: Generator<int, array{to: string|null, body: string, created_at: string}>, notifications: Generator<int, array<string, string|null>>, ssh_keys: array<mixed>, devices: array<int, array{name: string, fingerprint: string, approved_at: string, last_used_at: string|null}>, sessions: Collection<int, stdClass>}
*/
public function handle(User $user): array
{
Expand Down Expand Up @@ -72,6 +72,7 @@ public function handle(User $user): array
'bookmarks' => $this->bookmarks($user),
'following' => $this->usernames($user->following()),
'followers' => $this->usernames($user->followers()),
'waiting_to_follow' => $this->waitingToFollow($user),
'muting' => $this->usernames($user->muting()),
'blocking' => $this->usernames($user->blocking()),
'messages' => $this->messages($user),
Expand Down Expand Up @@ -220,6 +221,18 @@ private function usernames(BelongsToMany $users): Generator
}
}

/**
* The GitHub accounts the user follows there that aren't on /dev/null yet, by GitHub ID, which is all that is kept.
*
* @return Generator<int, string>
*/
private function waitingToFollow(User $user): Generator
{
foreach ($user->pendingFollows()->select(['id', 'github_id'])->lazyById(500) as $pending) {
yield $pending->github_id;
}
}

/**
* Moderators stay anonymous, as they are on the notifications page.
*
Expand Down
33 changes: 33 additions & 0 deletions app/Actions/CreatePendingFollows.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\User;

final readonly class CreatePendingFollows
{
/**
* Remembers the GitHub accounts the user follows that aren't on /dev/null yet, so they follow them here once they
* sign in. Accounts already here are left out.
*
* @param list<string> $githubIds
*/
public function handle(User $user, array $githubIds): void
{
$githubIds = array_values(array_diff(
$githubIds,
User::query()->whereIn('github_id', $githubIds)->get(['github_id'])->map(fn (User $registered): string => $registered->github_id)->all(),
));

if ($githubIds === []) {
return;
}

$user->pendingFollows()->insertOrIgnore(array_map(
fn (string $githubId): array => ['user_id' => $user->id, 'github_id' => $githubId],
$githubIds,
));
}
}
33 changes: 33 additions & 0 deletions app/Actions/CreatePendingMentions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Commit;
use App\Services\Mentions;

final readonly class CreatePendingMentions
{
public function __construct(private Mentions $mentions)
{
//
}

/**
* Keeps the mentions of developers who aren't on /dev/null yet, which give them a profile to claim.
*/
public function handle(Commit $commit): void
{
$usernames = array_diff($this->mentions->usernames($commit->body), array_keys($commit->mentions ?? []));

if ($usernames === []) {
return;
}

$commit->pendingMentions()->insertOrIgnore(array_map(
fn (string $username): array => ['commit_id' => $commit->id, 'username' => $username],
array_values($usernames),
));
}
}
26 changes: 20 additions & 6 deletions app/Actions/CreateUserGitHubFollows.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

namespace App\Actions;

use App\Models\PendingFollow;
use App\Models\User;
use App\Services\GitHub;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;

Expand All @@ -18,11 +20,12 @@
/**
* Up to 1,000 accounts each way, so a popular account can't use up the rate limit on its own.
*/
private const int MaxPages = 10;
public const int MaxPages = 10;

public function __construct(
private GitHub $github,
private CreateFollowNotification $createFollowNotification,
private CreatePendingFollows $createPendingFollows,
) {
//
}
Expand All @@ -41,10 +44,11 @@ public function handle(User $user, bool $notify = true): void
return;
}

$following = $this->users($user, "users/{$login}/following");
$followers = $this->users($user, "users/{$login}/followers");
$followingIds = $this->github->accountIds("users/{$login}/following", self::MaxPages);
$following = $this->users($user, $followingIds);
$followers = $this->users($user, $this->github->accountIds("users/{$login}/followers", self::MaxPages), withWaiting: true);

DB::transaction(function () use ($user, $following, $followers, $notify): void {
DB::transaction(function () use ($user, $following, $followers, $followingIds, $notify): void {
$followedIds = $user->following()->syncWithoutDetaching($following->modelKeys())['attached'];
$followerIds = $user->followers()->syncWithoutDetaching($followers->modelKeys())['attached'];

Expand All @@ -58,17 +62,27 @@ public function handle(User $user, bool $notify = true): void
}
}

$this->createPendingFollows->handle($user, $followingIds);
PendingFollow::query()->where('github_id', $user->github_id)->delete();

$user->update(['github_follows_imported_at' => now()]);
});
}

/**
* The users here with the given GitHub accounts, and with those who followed the user on GitHub before they signed
* in when asked, including those past the 1,000 the import reads.
*
* @param list<string> $githubIds
* @return Collection<int, User>
*/
private function users(User $user, string $path): Collection
private function users(User $user, array $githubIds, bool $withWaiting = false): Collection
{
return User::query()
->whereIn('github_id', $this->github->accountIds($path, self::MaxPages))
->where(fn (Builder $query): Builder => $query
->whereIn('github_id', $githubIds)
->when($withWaiting, fn (Builder $query): Builder => $query
->orWhereIn('id', PendingFollow::query()->where('github_id', $user->github_id)->select('user_id'))))
->whereKeyNot($user->id)
->whereNotIn('id', $user->idsAcrossBlocks())
->get();
Expand Down
43 changes: 43 additions & 0 deletions app/Actions/UpdateCommitMentions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Commit;
use App\Models\PendingMention;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final readonly class UpdateCommitMentions
{
/**
* Turns the mentions of the user's username made before they signed in into mentions of them, which link to their
* profile like any other, a few at a time, since a well-known developer may have been mentioned a lot. Each commit
* is read again under a lock, so two developers mentioned together and signing in at once both keep theirs, and
* keeps its update time, since nobody edited it.
*/
public function handle(User $user, string $username): void
{
$username = Str::lower($username);

PendingMention::query()
->where('username', $username)
->chunkById(200, function (Collection $pending) use ($username, $user): void {
DB::transaction(function () use ($pending, $username, $user): void {
$commits = Commit::query()->withTrashed()->whereKey($pending->pluck('commit_id'))->lockForUpdate()->get();

foreach ($commits as $commit) {
$commit->timestamps = false;

// array_replace keeps the keys of usernames that are numbers, which unpacking would renumber...
$commit->update(['mentions' => array_replace($commit->mentions ?? [], [$username => $user->id])]);
}

PendingMention::query()->whereKey($pending->modelKeys())->delete();
});
});
}
}
24 changes: 24 additions & 0 deletions app/Console/Commands/ImportPendingGitHubFollowsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Jobs\ImportPendingGitHubFollowsJob;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;

#[Signature('follows:pending')]
#[Description('Remember, in the background, for every user whose GitHub follows were imported, the accounts they follow on GitHub that are not on /dev/null yet')]
final class ImportPendingGitHubFollowsCommand extends Command
{
public function handle(): int
{
dispatch(new ImportPendingGitHubFollowsJob);

$this->components->info('Queued. The pending GitHub follows are remembered in the background, leaving GitHub requests to the rest of the app.');

return self::SUCCESS;
}
}
Loading