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
6 changes: 3 additions & 3 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ After that, members are managed on /dev/null, not mirrored from GitHub. An organ

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.

Following organizations is planned, not shipped yet.
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.

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).

Expand Down Expand Up @@ -523,7 +523,7 @@ Sources: [GitHub action](.github/actions/commit/action.yml).

### Public read API and Atom feeds

The read-only JSON API at `/api/v1` requires no authentication or token, except for who follows whom. Read a public profile at `/api/v1/users/{handle}`, that user's commits at `/api/v1/users/{handle}/commits`, or one commit at `/api/v1/commits/{commit}`. `/api/v1/users/{handle}/followers` and `/following` list people the way the profile's people lists do, newest follow first, with each person's lane and whether you follow each other; like on the web, they are for members only and need a [signed request](#signed-api-and-devices). Add `?replies=1` to the user-commits endpoint to read their replies instead of top-level commits. Lists use cursor pagination: 20 commits or 30 people per page. Only public approved, non-deleted content is returned, not private profile data such as email. Unsigned requests are limited to **60 requests per minute per IP** and cannot create posts, follow, or star; terminal posting and the [signed API](#signed-api-and-devices) are separate signed interfaces.
The read-only JSON API at `/api/v1` requires no authentication or token, except for who follows whom. Read a public profile at `/api/v1/users/{handle}`, that user's commits at `/api/v1/users/{handle}/commits`, or one commit at `/api/v1/commits/{commit}`. `/api/v1/users/{handle}/followers` and `/following` list people the way the profile's people lists do, newest follow first, with each person's lane and whether you follow each other, and `/api/v1/users/{handle}/following/organizations` lists the organizations they follow the same way, with whether you follow each one; like on the web, they are for members only and need a [signed request](#signed-api-and-devices). Add `?replies=1` to the user-commits endpoint to read their replies instead of top-level commits. Lists use cursor pagination: 20 commits, or 30 people or organizations, per page. Only public approved, non-deleted content is returned, not private profile data such as email. Unsigned requests are limited to **60 requests per minute per IP** and cannot create posts, follow, or star; terminal posting and the [signed API](#signed-api-and-devices) are separate signed interfaces.

Subscribe in a feed reader to `/@{handle}/feed.atom` for a profile's latest **50 approved top-level commits**, including image enclosures. Feeds are public and can be cached for five minutes.

Expand All @@ -538,7 +538,7 @@ A signed request sees what the web shows you, such as your own pending commits.
- read your account, logs, replies, notifications (including grouped activity), and starred commits;
- commit, reply, and quote, with images, and delete your commits, with the same undo window as the web;
- preview commit drafts without publishing them;
- follow or unfollow, mute or unmute, and block or unblock, and list who you muted and blocked;
- follow or unfollow people and organizations, mute or unmute, and block or unblock, and list who you muted and blocked;
- read who to follow, also as it is suggested beside a profile or a commit, and mark a suggestion not interested;
- star or unstar;
- create polls on commits that are neither replies nor quotes, and vote on polls;
Expand Down
19 changes: 19 additions & 0 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
* blocking: Generator<int, ExportUser>,
* dismissed_suggestions: Generator<int, ExportUser>,
* organizations: array<int, array{handle: string, role: string, joined_at: string}>,
* followed_organizations: array<int, array{handle: string, followed_at: string}>,
* messages: Generator<int, ExportMessage>,
* reports: Traversable<int, array<string, mixed>>,
* remote_pins: array<int, array{to: string|null, pinned_at: string|null}>,
Expand Down Expand Up @@ -117,6 +118,7 @@ public function handle(User $user): array
'blocking' => $this->handles($user->blocking()),
'dismissed_suggestions' => $this->handles($user->dismissedSuggestions()),
'organizations' => $this->organizations($user),
'followed_organizations' => $this->followedOrganizations($user),
'messages' => $this->messages($user),
'reports' => CommitReport::query()->where('reporter_id', $user->id)->oldest('id')->cursor()->map(fn (CommitReport $report): array => [
'commit_id' => $report->commit_id,
Expand Down Expand Up @@ -338,6 +340,23 @@ private function organizations(User $user): array
])->all();
}

/**
* @return array<int, array{handle: string, followed_at: string}>
*/
private function followedOrganizations(User $user): array
{
/** @var Collection<int, object{handle: string, created_at: string}> $follows */
$follows = $user->followedOrganizations()->newPivotQuery()
->join('profiles', 'profiles.id', '=', 'organization_follows.organization_id')
->oldest('organization_follows.created_at')
->get(['profiles.handle', 'organization_follows.created_at']);

return $follows->map(fn (object $follow): array => [
'handle' => $follow->handle,
'followed_at' => CarbonImmutable::parse($follow->created_at)->toIso8601String(),
])->all();
}

/**
* Moderators stay anonymous, as they are on the notifications page, and level-ups are the user's own doing.
*
Expand Down
19 changes: 19 additions & 0 deletions app/Actions/CreateOrganizationFollow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Organization;
use App\Models\User;

final readonly class CreateOrganizationFollow
{
/**
* Following again changes nothing, and keeps when the user first followed.
*/
public function handle(User $user, Organization $organization): void
{
$user->followedOrganizations()->syncWithoutDetaching([$organization->id]);
}
}
5 changes: 3 additions & 2 deletions app/Actions/DeleteOrganization.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
final readonly class DeleteOrganization
{
/**
* Deletes an organization GitHub no longer has, with its members, and unlinks the commits that mention it, so none
* links to a page that is gone.
* 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.
*/
public function handle(Organization $organization): void
{
Expand All @@ -23,6 +23,7 @@ public function handle(Organization $organization): void
->delete();

$organization->members()->detach();
$organization->followers()->detach();
$organization->delete();
$organization->profile->delete();
});
Expand Down
16 changes: 16 additions & 0 deletions app/Actions/DeleteOrganizationFollow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Organization;
use App\Models\User;

final readonly class DeleteOrganizationFollow
{
public function handle(User $user, Organization $organization): void
{
$user->followedOrganizations()->detach($organization->id);
}
}
1 change: 1 addition & 0 deletions app/Actions/DeleteUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public function handle(User $user): void
$this->deleteOrgs->handle($this->emptyOrgs($user));

$user->organizations()->detach();
$user->followedOrganizations()->detach();
DB::table('user_active_days')->where('user_id', $user->id)->delete();

$user->delete();
Expand Down
37 changes: 37 additions & 0 deletions app/Http/Controllers/Api/OrganizationFollowController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\CreateOrganizationFollow;
use App\Actions\DeleteOrganizationFollow;
use App\Http\Requests\CreateApiOrganizationFollowRequest;
use App\Http\Requests\DeleteApiOrganizationFollowRequest;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Response;

final readonly class OrganizationFollowController
{
public function store(CreateApiOrganizationFollowRequest $request, #[CurrentUser] User $user, CreateOrganizationFollow $action): Response
{
$organization = $request->organization();
assert($organization instanceof Organization);

$action->handle($user, $organization);

return response()->noContent();
}

public function destroy(DeleteApiOrganizationFollowRequest $request, #[CurrentUser] User $user, DeleteOrganizationFollow $action): Response
{
$organization = $request->organization();
assert($organization instanceof Organization);

$action->handle($user, $organization);

return response()->noContent();
}
}
23 changes: 23 additions & 0 deletions app/Http/Controllers/Api/UserFollowedOrganizationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Requests\ListApiUserFollowedOrganizationsRequest;
use App\Http\Resources\OrganizationResource;
use App\Models\User;
use App\Queries\FollowedOrganizationsQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

final readonly class UserFollowedOrganizationController
{
/**
* The organizations a user follows, 30 a page, newest follow first, with whether the signed-in user follows each one.
*/
public function index(ListApiUserFollowedOrganizationsRequest $request, #[CurrentUser] User $currentUser, User $user, FollowedOrganizationsQuery $organizations): AnonymousResourceCollection
{
return OrganizationResource::collection($organizations->builder($user, $currentUser)->cursorPaginate(30));
}
}
8 changes: 5 additions & 3 deletions app/Http/Controllers/OrganizationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
{
/**
* Organization pages are public, at the "@handle" of users, and show the organization's GitHub profile and the
* commits that mention it, in the lane its admins chose or /dev/null's blue, with its admins, first one first, and
* the organization's link preview, and its members the way to its settings. Like users' pages, they move for good
* to the handle's own case.
* commits that mention it, in the lane its admins chose or /dev/null's blue, with its admins, first one first, how
* many follow it and whether the viewer does, and the organization's link preview, and its members the way to its
* settings. Like users' pages, they move for good to the handle's own case.
*/
public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $currentUser, OrganizationMentionsQuery $mentions, PageMetadata $metadata): RedirectResponse|Response
{
Expand Down Expand Up @@ -52,6 +52,8 @@ public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $cur
null => null,
},
'admins' => fn (): Collection => $organization->admins()->oldest('organization_members.created_at')->get()->makeHidden('pivot'),
'followersCount' => fn (): int => $organization->followers()->newPivotQuery()->count(),
'isFollowing' => fn (): bool => $currentUser?->followedOrganizations()->newPivotQuery()->where('organization_id', $organization->id)->exists() ?? false,
])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->organization($organization)]);
}
}
31 changes: 31 additions & 0 deletions app/Http/Controllers/OrganizationFollowController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\CreateOrganizationFollow;
use App\Actions\DeleteOrganizationFollow;
use App\Http\Requests\CreateOrganizationFollowRequest;
use App\Http\Requests\DeleteOrganizationFollowRequest;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\RedirectResponse;

final readonly class OrganizationFollowController
{
public function store(CreateOrganizationFollowRequest $request, #[CurrentUser] User $user, Organization $organization, CreateOrganizationFollow $action): RedirectResponse
{
$action->handle($user, $organization);

return back();
}

public function destroy(DeleteOrganizationFollowRequest $request, #[CurrentUser] User $user, Organization $organization, DeleteOrganizationFollow $action): RedirectResponse
{
$action->handle($user, $organization);

return back();
}
}
13 changes: 12 additions & 1 deletion app/Http/Controllers/UserFollowingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@

use App\Enums\ConnectionType;
use App\Http\Requests\ListUserFollowingRequest;
use App\Models\Organization;
use App\Models\User;
use App\Queries\FollowedOrganizationsQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Database\Eloquent\Builder;
use Inertia\Inertia;
use Inertia\Response;

final readonly class UserFollowingController
{
public function index(ListUserFollowingRequest $request, #[CurrentUser] User $currentUser, User $user): Response
public function index(ListUserFollowingRequest $request, #[CurrentUser] User $currentUser, User $user, FollowedOrganizationsQuery $organizations): Response
{
return Inertia::render('user/connections', [
'user' => $user,
Expand All @@ -24,6 +27,14 @@ public function index(ListUserFollowingRequest $request, #[CurrentUser] User $cu
->latest('follows.created_at')
->latest('users.id')
->cursorPaginate(30)),
// Above the people, a few at a time on demand, with a cursor of their own.
'organizations' => Inertia::scroll(function () use ($organizations, $user, $currentUser): CursorPaginator {
$page = $organizations->builder($user, $currentUser)->cursorPaginate(10, cursorName: 'organizations_cursor');

collect($page->items())->each(fn (Organization $organization): Organization => $organization->makeVisible(['id', 'is_followed']));

return $page;
}),
]);
}
}
40 changes: 40 additions & 0 deletions app/Http/Requests/CreateApiOrganizationFollowRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Organization;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class CreateApiOrganizationFollowRequest extends FormRequest
{
private ?Organization $organization = null;

public function authorize(): Response
{
return $this->organization() instanceof Organization ? Response::allow() : Response::denyAsNotFound();
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}

/**
* Only organizations that already have a page, as the app finds them.
*/
public function organization(): ?Organization
{
$handle = $this->route('handle');
assert(is_string($handle));

return $this->organization ??= Organization::query()
->whereRelation('profile', 'handle', $handle)
->first();
}
}
23 changes: 23 additions & 0 deletions app/Http/Requests/CreateOrganizationFollowRequest.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 CreateOrganizationFollowRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

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

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Organization;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class DeleteApiOrganizationFollowRequest extends FormRequest
{
private ?Organization $organization = null;

public function authorize(): Response
{
return $this->organization() instanceof Organization ? Response::allow() : Response::denyAsNotFound();
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}

/**
* Only organizations that already have a page, as the app finds them.
*/
public function organization(): ?Organization
{
$handle = $this->route('handle');
assert(is_string($handle));

return $this->organization ??= Organization::query()
->whereRelation('profile', 'handle', $handle)
->first();
}
}
Loading
Loading