Skip to content
Closed
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
4 changes: 2 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,9 @@ A member of an organization can **Claim** its page, which lists them as its admi

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.
Signed-in users can **Follow** an organization from its page. Following puts the commits that mention it in your Following log, with the usual visibility, mutes and blocks. Replies that mention it stay on its Mentions tab, since logs list top-level commits only. Organizations have no follower counts and following one sends no notification.

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).
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), [follows](app/Http/Controllers/OrganizationFollowController.php).

### Going live

Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/OrganizationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public function show(ShowOrganizationRequest $request, #[CurrentUser] ?User $cur
// 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(),
'isFollowing' => $currentUser?->isFollowingOrganization($organization) ?? false,
'tab' => $request->tab(...),
'commits' => Inertia::scroll(fn () => $mentions->builder($organization, $currentUser)
->cursorPaginate(20)
Expand Down
37 changes: 37 additions & 0 deletions app/Http/Controllers/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;

use App\Http\Requests\CreateOrganizationFollowRequest;
use App\Models\Profile;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\RedirectResponse;

final readonly class OrganizationFollowController
{
/**
* Following an organization brings the commits that mention it into the Following log. Inserted unless already
* there, like a follow of a user.
*/
public function store(CreateOrganizationFollowRequest $request, #[CurrentUser] User $user, Profile $organization): RedirectResponse
{
$user->followedOrganizations()->newPivotStatement()->insertOrIgnore([
'user_id' => $user->id,
'organization_id' => $organization->id,
'created_at' => now(),
'updated_at' => now(),
]);

return back();
}

public function destroy(#[CurrentUser] User $user, Profile $organization): RedirectResponse
{
$user->followedOrganizations()->detach($organization->id);

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

declare(strict_types=1);

namespace App\Http\Requests;

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

final class CreateOrganizationFollowRequest extends FormRequest
{
/**
* Profiles are users' too, who are followed as users instead.
*/
public function authorize(): Response
{
$organization = $this->route('organization');
assert($organization instanceof Profile);

return $organization->type === ProfileType::Organization ? Response::allow() : Response::denyAsNotFound();
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}
}
14 changes: 14 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
* @property-read Collection<int, Commit> $starredCommits
* @property-read Collection<int, User> $following
* @property-read Collection<int, User> $followers
* @property-read Collection<int, Profile> $followedOrganizations
* @property-read Collection<int, User> $muting
* @property-read Collection<int, User> $mutedBy
* @property-read Collection<int, User> $blocking
Expand Down Expand Up @@ -364,6 +365,14 @@ public function following(): BelongsToMany
return $this->belongsToMany(self::class, 'follows', 'follower_id', 'followed_id')->withTimestamps();
}

/**
* @return BelongsToMany<Profile, $this>
*/
public function followedOrganizations(): BelongsToMany
{
return $this->belongsToMany(Profile::class, 'organization_follows', 'user_id', 'organization_id')->withTimestamps();
}

/**
* @return BelongsToMany<User, $this>
*/
Expand Down Expand Up @@ -509,6 +518,11 @@ public function isFollowing(self $user): bool
return $this->following()->newPivotQuery()->where('followed_id', $user->id)->exists();
}

public function isFollowingOrganization(Profile $organization): bool
{
return $this->followedOrganizations()->newPivotQuery()->where('organization_id', $organization->id)->exists();
}

public function isMuting(self $user): bool
{
return $this->muting()->newPivotQuery()->where('muted_id', $user->id)->exists();
Expand Down
10 changes: 9 additions & 1 deletion app/Queries/LogQueries/FollowingLogQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
namespace App\Queries\LogQueries;

use App\Contracts\Queries\LogQuery;
use App\Enums\TopicType;
use App\Models\Commit;
use App\Models\User;
use App\Queries\CommitBuilder;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;

final readonly class FollowingLogQuery implements LogQuery
{
Expand All @@ -19,11 +21,17 @@ public function builder(?User $user): CommitBuilder
}

$following = $user->following();
$followedOrganizations = $user->followedOrganizations();

return Commit::query()
->where(fn (Builder $query): Builder => $query
->whereIn('user_id', $following->newPivotQuery()->select($following->getRelatedPivotKeyName()))
->orWhere('user_id', $user->id))
->orWhere('user_id', $user->id)
->orWhereExists(fn (QueryBuilder $query): QueryBuilder => $query
->from('commit_topics')
->whereColumn('commit_topics.commit_id', 'commits.id')
->where('commit_topics.topic_type', TopicType::Organization)
->whereIn('commit_topics.topic_id', $followedOrganizations->newPivotQuery()->select($followedOrganizations->getRelatedPivotKeyName()))))
->visibleTo($user)
->withoutMutedAuthorsFor($user)
->topLevel()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?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
{
/**
* Users follow organizations to read the commits that mention them in their Following log. The primary key finds
* the organizations a user follows, and the other index the followers of an organization.
*/
public function up(): void
{
Schema::create('organization_follows', function (Blueprint $table): void {
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->foreignUuid('organization_id')->constrained('profiles')->cascadeOnDelete();
$table->timestamps();

$table->primary(['user_id', 'organization_id']);
$table->index('organization_id');
});
}
};
2 changes: 2 additions & 0 deletions database/seeders/DatabaseSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ public function run(): void
$this->seedNotifications($localDeveloper, $users);

$this->converse($localDeveloper, $users);

$this->call(OrganizationSeeder::class);
}

/**
Expand Down
50 changes: 50 additions & 0 deletions database/seeders/OrganizationSeeder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Database\Seeders;

use App\Actions\CreateCommitTopics;
use App\Enums\ProfileType;
use App\Models\Commit;
use App\Models\Profile;
use App\Models\User;
use Illuminate\Database\Seeder;

final class OrganizationSeeder extends Seeder
{
public function __construct(private readonly CreateCommitTopics $createCommitTopics)
{
//
}

/**
* The @laravel organization, from its public GitHub profile, with a few commits that mention it to follow.
*/
public function run(): void
{
$laravel = Profile::query()->updateOrCreate(['github_id' => '958072'], [
'type' => ProfileType::Organization,
'handle' => 'laravel',
'name' => 'Laravel',
'avatar_url' => 'https://avatars.githubusercontent.com/u/958072?v=4',
'bio' => 'The PHP framework for web artisans.',
'website' => 'https://laravel.com',
'github_synced_at' => now(),
]);

$users = User::query()->inRandomOrder()->limit(5)->get();

Commit::factory()->count(5)->sequence(fn (): array => [
'user_id' => $users->isEmpty() ? User::factory() : $users->random()->id,
'body' => fake()->randomElement([
'feat: upgrade to the latest @laravel release',
'fix: queue workers restart cleanly, thanks @laravel',
'chore: try Octane from @laravel on the side project',
'docs: everything I learned reading @laravel source today',
'feat: ship it with @laravel Cloud',
]),
'created_at' => fake()->dateTimeBetween('-3 days'),
])->create()->each(fn (Commit $commit) => $this->createCommitTopics->handle($commit, ['laravel' => $laravel->id]));
}
}
66 changes: 66 additions & 0 deletions resources/js/components/follow-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { UserCheck, UserPlus } from 'lucide-react';
import type { ComponentProps } from 'react';
import { useState } from 'react';
import { toast } from 'sonner';
import OrganizationFollowController from '@/actions/App/Http/Controllers/OrganizationFollowController';
import UserFollowController from '@/actions/App/Http/Controllers/UserFollowController';
import { Button } from '@/components/ui/button';
import {
Expand Down Expand Up @@ -327,3 +328,68 @@ export function FollowIconButton({
</Tooltip>
);
}

/**
* Following an organization brings the commits that mention it into the Following log.
*/
export function OrganizationFollowButton({
organization,
isFollowing: initialIsFollowing,
className,
}: {
organization: { id: string; handle: string };
isFollowing: boolean;
className?: string;
}) {
const {
props: { auth },
} = usePage();
const [isFollowing, setIsFollowing] = useState(initialIsFollowing);
const { isJustToggled, markToggled, onMouseLeave } = useFollowHover();

if (auth.user === null) {
return (
<Button
className={className}
onClick={signIn}
aria-label={`Follow @${organization.handle}`}
data-test="organization-follow-button"
>
Follow
</Button>
);
}

return (
<Button
variant={isFollowing ? 'outline' : 'default'}
className={cn('group/follow', className)}
asChild
>
<Link
href={
isFollowing
? OrganizationFollowController.destroy(organization)
: OrganizationFollowController.store(organization)
}
as="button"
only={['auth']}
preserveScroll
onHttpException={(response) => !toastWhenThrottled(response)}
aria-label={`${isFollowing ? 'Unfollow' : 'Follow'} @${organization.handle}`}
data-test="organization-follow-button"
onClick={markToggled}
onSuccess={() => {
setIsFollowing(!isFollowing);
router.flushAll();
}}
onMouseLeave={onMouseLeave}
>
<FollowLabel
isFollowing={isFollowing}
isJustToggled={isJustToggled}
/>
</Link>
</Button>
);
}
8 changes: 8 additions & 0 deletions resources/js/pages/organization/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useState } from 'react';
import { show } from '@/actions/App/Http/Controllers/UserController';
import { BioText } from '@/components/bio-text';
import { CommitFeed, EmptyFeed } from '@/components/commit-feed';
import { OrganizationFollowButton } from '@/components/follow-button';
import { GitHubIcon } from '@/components/github-icon';
import { OrganizationAvatar } from '@/components/organization-avatar';
import { OrganizationLaneDialog } from '@/components/organization-lane-dialog';
Expand All @@ -26,13 +27,15 @@ import type {
export default function Show({
organization,
lane,
isFollowing,
tab,
commits,
claimUrl,
admins,
}: {
organization: Organization & { id: string };
lane: Lane | null;
isFollowing: boolean;
tab: 'mentions';
commits: CursorPaginated<Commit>;
claimUrl: string;
Expand Down Expand Up @@ -105,6 +108,11 @@ export default function Show({
handle={organization.handle}
href={show(organization).url}
/>
<OrganizationFollowButton
organization={organization}
isFollowing={isFollowing}
className="min-w-28"
/>
</div>
</div>

Expand Down
11 changes: 11 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
use App\Http\Controllers\OrganizationAdminController;
use App\Http\Controllers\OrganizationController;
use App\Http\Controllers\OrganizationLaneController;
use App\Http\Controllers\OrganizationFollowController;
use App\Http\Controllers\OrganizationMentionController;
use App\Http\Controllers\OrganizationSitemapController;
use App\Http\Controllers\OrgArchiveController;
Expand Down Expand Up @@ -620,6 +621,16 @@
->middleware('throttle:follows')
->name('user-follow.destroy');

// Organization Follow...
Route::post('organizations/{organization}/follow', [OrganizationFollowController::class, 'store'])
->middleware('throttle:follows')
->whereUuid('organization')
->name('organization-follow.store');
Route::delete('organizations/{organization}/follow', [OrganizationFollowController::class, 'destroy'])
->middleware('throttle:follows')
->whereUuid('organization')
->name('organization-follow.destroy');

// User Mute...
Route::post('@{user:handle}/mute', [UserMuteController::class, 'store'])
->name('user-mute.store');
Expand Down
Loading