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: 4 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,17 @@ 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 or edits its page: everything on it comes from GitHub. 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, 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.

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 GitHub once for permission to read your organizations, then checks that you are an owner or a member there, not just invited. Organizations that restrict third-party apps only share their members once an owner approved /dev/null: the page then links to GitHub, where an owner grants access and a member requests it. GitHub is only the way in: leaving the organization on GitHub doesn't remove you as an admin on /dev/null. Claims are in your data export and go when you delete your account.

Every organization has a color, /dev/null's blue until its admin chooses another with **Color** on its page: 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.

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).
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](resources/js/components/organization-lane-dialog.tsx).

### Going live

Expand Down
2 changes: 1 addition & 1 deletion app/Actions/CreateCommitOpenGraphImage.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public function handle(User $user, Commit $commit): string
private function render(User $user, Commit $commit): string
{
$canvas = new OpenGraphCanvas;
$lane = $canvas->lane($user);
$lane = $canvas->lane($user->lane());
$replyingTo = $commit->parent?->user?->handle;

$headerHeight = self::AVATAR_SIZE + ($replyingTo === null ? 0 : self::REPLYING_TO_HEIGHT) + self::BODY_GAP;
Expand Down
4 changes: 2 additions & 2 deletions app/Actions/CreateOrganizationOpenGraphImage.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ public function handle(Profile $organization): string
}

/**
* Drawn like a profile's card, in the muted color organizations have in the app, since they have no lane.
* Drawn like a profile's card, in the organization's lane, /dev/null's blue until its admins choose one.
*/
private function render(Profile $organization): string
{
$canvas = new OpenGraphCanvas;
$lane = OpenGraphCanvas::INK_MUTED;
$lane = $organization->lane() === null ? OpenGraphCanvas::PRIMARY : $canvas->lane($organization->lane());
$width = OpenGraphCanvas::CONTENT_RIGHT - OpenGraphCanvas::CONTENT_X;

$bio = $canvas->clamp(
Expand Down
2 changes: 1 addition & 1 deletion app/Actions/CreateUserOpenGraphImage.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public function handle(User $user): string
private function render(User $user): string
{
$canvas = new OpenGraphCanvas;
$lane = $canvas->lane($user);
$lane = $canvas->lane($user->lane());
$width = OpenGraphCanvas::CONTENT_RIGHT - OpenGraphCanvas::CONTENT_X;

$bio = $canvas->clamp(
Expand Down
15 changes: 15 additions & 0 deletions app/Actions/UpdateOrganizationLane.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Profile;

final readonly class UpdateOrganizationLane
{
public function handle(Profile $organization, ?int $lane, ?string $color): void
{
$organization->update(['preferred_lane' => $lane, 'lane_color' => $color]);
}
}
5 changes: 3 additions & 2 deletions app/Http/Controllers/OrganizationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
{
/**
* Organization pages are public, at the "@handle" of users, and show the organization's GitHub profile and the
* commits that mention it, with the users who claimed it, first claim first, and the organization's link preview.
* 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 the users who claimed it, first
* claim first, and the organization's link preview. 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 All @@ -37,6 +37,7 @@ public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $cur
return Inertia::render('organization/show', [
// The Mentions tab tells a restored commit belongs to it by the ID, whatever handle the commit mentions.
'organization' => $organization->makeVisible('id'),
'lane' => $organization->lane(),
'tab' => $request->tab(...),
'commits' => Inertia::scroll(fn () => $mentions->builder($organization, $currentUser)
->cursorPaginate(20)
Expand Down
26 changes: 26 additions & 0 deletions app/Http/Controllers/OrganizationLaneController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\UpdateOrganizationLane;
use App\Http\Requests\UpdateOrganizationLaneRequest;
use App\Models\Profile;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;

final readonly class OrganizationLaneController
{
public function update(UpdateOrganizationLaneRequest $request, Profile $organization, UpdateOrganizationLane $action): RedirectResponse
{
$action->handle($organization, $request->lane(), $request->color());

Inertia::flash('toast', [
'type' => 'success',
'message' => 'Color saved.',
]);

return back();
}
}
49 changes: 49 additions & 0 deletions app/Http/Requests/UpdateOrganizationLaneRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

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

final class UpdateOrganizationLaneRequest extends FormRequest
{
/**
* Only the organization's admins choose its color. Users' profiles have no admins, so they look like they do not
* exist.
*/
public function authorize(): Response
{
$user = $this->user();
assert($user instanceof User);

$organization = $this->route('organization');
assert($organization instanceof Profile);

return $organization->admins()->whereKey($user->id)->exists() ? Response::allow() : Response::denyAsNotFound();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we're hardcoding the permission check, that's because we're working on permissions in a separate PR, this should be refactored later to use a gate or policy.

}

/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'lane' => ['present', 'nullable', 'integer', 'between:0,5'],
'color' => ['nullable', 'string', 'regex:/^#[0-9a-f]{6}$/', 'prohibits:lane'],
];
}

public function lane(): ?int
{
return $this->filled('lane') ? $this->integer('lane') : null;
}

public function color(): ?string
{
return $this->filled('color') ? $this->string('color')->toString() : null;
}
}
15 changes: 14 additions & 1 deletion app/Models/Profile.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
* @property-read string|null $bio
* @property-read string|null $location
* @property-read string|null $website
* @property-read int|null $preferred_lane
* @property-read string|null $lane_color
* @property-read CarbonInterface|null $github_synced_at
* @property-read CarbonInterface $created_at
* @property-read CarbonInterface $updated_at
Expand Down Expand Up @@ -76,6 +78,8 @@ public function casts(): array
'bio' => 'string',
'location' => 'string',
'website' => 'string',
'preferred_lane' => 'integer',
'lane_color' => 'string',
'github_synced_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
Expand All @@ -87,7 +91,16 @@ public function casts(): array
*/
public function openGraphImageVersion(): string
{
return hash('xxh3', (string) json_encode([$this->name, $this->handle, $this->avatar_url, $this->bio]));
return hash('xxh3', (string) json_encode([$this->name, $this->handle, $this->avatar_url, $this->bio, $this->lane()]));
}

/**
* The lane the organization's admins chose, like a user's, or null for /dev/null's blue, which organizations have
* until then.
*/
public function lane(): int|string|null
{
return $this->lane_color ?? $this->preferred_lane;
}

/**
Expand Down
9 changes: 6 additions & 3 deletions app/Services/OpenGraphCanvas.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@

public const int INK_MUTED = 0x5B616B;

/**
* /dev/null's blue, the lane of organizations until their admins choose one.
*/
public const int PRIMARY = 0x3B6FE0;

public const int RAIL_X = 112;

public const int CONTENT_X = 184;
Expand Down Expand Up @@ -54,10 +59,8 @@ public function __construct()
$this->image = $this->blank(self::WIDTH * self::SCALE, self::HEIGHT * self::SCALE);
}

public function lane(User $user): int
public function lane(int|string $lane): int
{
$lane = $user->lane();

return is_string($lane) ? (int) hexdec(mb_substr($lane, 1)) : self::LANES[$lane];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?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
{
/**
* The color an organization's admins chose for its lane, like users': one from the palette or a custom one. Both
* empty is /dev/null's blue, which every organization has until its admins choose.
* Everyone sees it on the organization's page. It goes with the organization when GitHub no longer has it.
*/
public function up(): void
{
Schema::table('profiles', function (Blueprint $table): void {
$table->unsignedTinyInteger('preferred_lane')->nullable();
$table->string('lane_color', 7)->nullable();
Comment on lines +19 to +20

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's weird to have 2 fields, but we're staying consistent with how it's implemented for users. Ideally later we refactor to store only the color, and merge both columns into one. but here we stay consistent with users.

});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};
43 changes: 28 additions & 15 deletions resources/js/components/lane-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { useUser } from '@/hooks/use-user';
import { cn, laneColor, withViewTransition } from '@/lib/utils';
import {
cn,
laneColor,
organizationLaneColor,
withViewTransition,
} from '@/lib/utils';
import type { Lane } from '@/types';

const laneNames = ['Orange', 'Green', 'Violet', 'Teal', 'Rose', 'Ochre'];
Expand Down Expand Up @@ -52,7 +57,10 @@ export function LaneOptions({
onSelect,
}: {
selected: Lane | null;
automaticLane: number;
/**
* The lane picked when none is chosen, or null when that is /dev/null's blue, like organizations.
*/
automaticLane: number | null;
onSelect: (lane: Lane | null) => void;
}) {
const colorInputRef = useRef<HTMLInputElement>(null);
Expand All @@ -62,17 +70,24 @@ export function LaneOptions({
const isCustom = typeof selected === 'string';

const options = [
{
value: null,
label: 'Auto',
ariaLabel: `Automatic (${laneNames[automaticLane].toLowerCase()})`,
lane: automaticLane,
},
automaticLane === null
? {
value: null,
label: 'Blue',
ariaLabel: 'Blue (default)',
color: organizationLaneColor(null),
}
: {
value: null,
label: 'Auto',
ariaLabel: `Automatic (${laneNames[automaticLane].toLowerCase()})`,
color: laneColor(automaticLane),
},
...laneNames.map((name, index) => ({
value: index,
label: name,
ariaLabel: undefined,
lane: index,
color: laneColor(index),
})),
];

Expand Down Expand Up @@ -109,7 +124,7 @@ export function LaneOptions({
className={optionClassName}
style={
{
'--lane': laneColor(option.lane),
'--lane': option.color,
} as CSSProperties
}
>
Expand Down Expand Up @@ -156,11 +171,9 @@ export function LaneOptions({
{
'--lane':
customColor ??
laneColor(
typeof selected === 'number'
? selected
: automaticLane,
),
(typeof selected === 'number'
? laneColor(selected)
: options[0].color),
} as CSSProperties
}
data-test="custom-lane-option"
Expand Down
4 changes: 4 additions & 0 deletions resources/js/components/organization-avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CSSProperties } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useInitials } from '@/hooks/use-initials';
import { sizedAvatarUrl } from '@/lib/urls';
Expand All @@ -10,10 +11,12 @@ import type { Organization } from '@/types';
export function OrganizationAvatar({
organization,
className,
style,
pixels = 80,
}: {
organization: Pick<Organization, 'name' | 'avatar_url'>;
className?: string;
style?: CSSProperties;
/**
* The image size to fetch: twice the largest size the avatar is drawn at, for high-density screens.
*/
Expand All @@ -27,6 +30,7 @@ export function OrganizationAvatar({
'size-8 overflow-hidden rounded-md border bg-muted',
className,
)}
style={style}
>
<AvatarImage
src={
Expand Down
Loading
Loading