From 8bb84096f7385c31df4da63f9f3eb32debf2c51f Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 29 Oct 2026 18:52:34 +0000 Subject: [PATCH] feat: let users follow organizations Following an organization is stored apart from following people, so the people-only behavior of follows (remotes, mutuals, notifications, suggestions) stays as it is. The organization page has a Follow button that opens a Follow and Watch menu once followed, Watch shown as coming soon. Followed organizations are listed above the people on the Following list, count toward the following count, have their own API list, and are in the export and erased with the account. Co-authored-by: Matthieu Amp-Thread-ID: https://ampcode.com/threads/T-01a188a9-96db-7614-a767-d9098d0da72a --- KNOWLEDGE_BASE.md | 6 +- app/Actions/CreateExport.php | 19 ++ app/Actions/CreateOrganizationFollow.php | 19 ++ app/Actions/DeleteOrganization.php | 5 +- app/Actions/DeleteOrganizationFollow.php | 16 ++ app/Actions/DeleteUser.php | 1 + .../Api/OrganizationFollowController.php | 37 +++ .../UserFollowedOrganizationController.php | 23 ++ .../Controllers/OrganizationController.php | 8 +- .../OrganizationFollowController.php | 31 +++ .../Controllers/UserFollowingController.php | 13 +- .../CreateApiOrganizationFollowRequest.php | 40 ++++ .../CreateOrganizationFollowRequest.php | 23 ++ .../DeleteApiOrganizationFollowRequest.php | 40 ++++ .../DeleteOrganizationFollowRequest.php | 23 ++ ...istApiUserFollowedOrganizationsRequest.php | 40 ++++ app/Http/Resources/OrganizationResource.php | 4 +- app/Models/Organization.php | 10 + app/Models/User.php | 14 +- app/Queries/FollowedOrganizationsQuery.php | 27 +++ ...0000_create_organization_follows_table.php | 27 +++ .../components/organization-follow-button.tsx | 214 ++++++++++++++++++ resources/js/pages/organization/show.tsx | 27 ++- resources/js/pages/user/connections.tsx | 98 +++++++- routes/api.php | 12 + routes/web.php | 11 + .../Api/OrganizationFollowControllerTest.php | 39 ++++ ...UserFollowedOrganizationControllerTest.php | 68 ++++++ .../Controllers/ExportControllerTest.php | 19 ++ .../OrganizationControllerTest.php | 20 ++ .../OrganizationFollowControllerTest.php | 58 +++++ .../UserFollowingControllerTest.php | 50 ++++ tests/Unit/Actions/DeleteOrganizationTest.php | 6 +- tests/Unit/Actions/DeleteUserTest.php | 11 + tests/Unit/Models/UserTest.php | 7 +- 35 files changed, 1046 insertions(+), 20 deletions(-) create mode 100644 app/Actions/CreateOrganizationFollow.php create mode 100644 app/Actions/DeleteOrganizationFollow.php create mode 100644 app/Http/Controllers/Api/OrganizationFollowController.php create mode 100644 app/Http/Controllers/Api/UserFollowedOrganizationController.php create mode 100644 app/Http/Controllers/OrganizationFollowController.php create mode 100644 app/Http/Requests/CreateApiOrganizationFollowRequest.php create mode 100644 app/Http/Requests/CreateOrganizationFollowRequest.php create mode 100644 app/Http/Requests/DeleteApiOrganizationFollowRequest.php create mode 100644 app/Http/Requests/DeleteOrganizationFollowRequest.php create mode 100644 app/Http/Requests/ListApiUserFollowedOrganizationsRequest.php create mode 100644 app/Queries/FollowedOrganizationsQuery.php create mode 100644 database/migrations/2026_10_30_180000_create_organization_follows_table.php create mode 100644 resources/js/components/organization-follow-button.tsx create mode 100644 tests/Feature/Controllers/Api/OrganizationFollowControllerTest.php create mode 100644 tests/Feature/Controllers/Api/UserFollowedOrganizationControllerTest.php create mode 100644 tests/Feature/Controllers/OrganizationFollowControllerTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index b179ab6f5f..06ae031d85 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -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). @@ -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. @@ -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; diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 1ee99a21c7..41d90a5e3a 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -48,6 +48,7 @@ * blocking: Generator, * dismissed_suggestions: Generator, * organizations: array, + * followed_organizations: array, * messages: Generator, * reports: Traversable>, * remote_pins: array, @@ -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, @@ -338,6 +340,23 @@ private function organizations(User $user): array ])->all(); } + /** + * @return array + */ + private function followedOrganizations(User $user): array + { + /** @var Collection $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. * diff --git a/app/Actions/CreateOrganizationFollow.php b/app/Actions/CreateOrganizationFollow.php new file mode 100644 index 0000000000..b9f1fcf6ce --- /dev/null +++ b/app/Actions/CreateOrganizationFollow.php @@ -0,0 +1,19 @@ +followedOrganizations()->syncWithoutDetaching([$organization->id]); + } +} diff --git a/app/Actions/DeleteOrganization.php b/app/Actions/DeleteOrganization.php index 491f65f798..4ba6640b00 100644 --- a/app/Actions/DeleteOrganization.php +++ b/app/Actions/DeleteOrganization.php @@ -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 { @@ -23,6 +23,7 @@ public function handle(Organization $organization): void ->delete(); $organization->members()->detach(); + $organization->followers()->detach(); $organization->delete(); $organization->profile->delete(); }); diff --git a/app/Actions/DeleteOrganizationFollow.php b/app/Actions/DeleteOrganizationFollow.php new file mode 100644 index 0000000000..74f04994fa --- /dev/null +++ b/app/Actions/DeleteOrganizationFollow.php @@ -0,0 +1,16 @@ +followedOrganizations()->detach($organization->id); + } +} diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 121e4bb310..4c68afba9d 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -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(); diff --git a/app/Http/Controllers/Api/OrganizationFollowController.php b/app/Http/Controllers/Api/OrganizationFollowController.php new file mode 100644 index 0000000000..0a1483b8a1 --- /dev/null +++ b/app/Http/Controllers/Api/OrganizationFollowController.php @@ -0,0 +1,37 @@ +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(); + } +} diff --git a/app/Http/Controllers/Api/UserFollowedOrganizationController.php b/app/Http/Controllers/Api/UserFollowedOrganizationController.php new file mode 100644 index 0000000000..a8a705e217 --- /dev/null +++ b/app/Http/Controllers/Api/UserFollowedOrganizationController.php @@ -0,0 +1,23 @@ +builder($user, $currentUser)->cursorPaginate(30)); + } +} diff --git a/app/Http/Controllers/OrganizationController.php b/app/Http/Controllers/OrganizationController.php index 2c17fa078e..c90b5c8026 100644 --- a/app/Http/Controllers/OrganizationController.php +++ b/app/Http/Controllers/OrganizationController.php @@ -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 { @@ -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)]); } } diff --git a/app/Http/Controllers/OrganizationFollowController.php b/app/Http/Controllers/OrganizationFollowController.php new file mode 100644 index 0000000000..181af6ff8f --- /dev/null +++ b/app/Http/Controllers/OrganizationFollowController.php @@ -0,0 +1,31 @@ +handle($user, $organization); + + return back(); + } + + public function destroy(DeleteOrganizationFollowRequest $request, #[CurrentUser] User $user, Organization $organization, DeleteOrganizationFollow $action): RedirectResponse + { + $action->handle($user, $organization); + + return back(); + } +} diff --git a/app/Http/Controllers/UserFollowingController.php b/app/Http/Controllers/UserFollowingController.php index e2325bb784..6d34ddfc43 100644 --- a/app/Http/Controllers/UserFollowingController.php +++ b/app/Http/Controllers/UserFollowingController.php @@ -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, @@ -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; + }), ]); } } diff --git a/app/Http/Requests/CreateApiOrganizationFollowRequest.php b/app/Http/Requests/CreateApiOrganizationFollowRequest.php new file mode 100644 index 0000000000..cb9959f794 --- /dev/null +++ b/app/Http/Requests/CreateApiOrganizationFollowRequest.php @@ -0,0 +1,40 @@ +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(); + } +} diff --git a/app/Http/Requests/CreateOrganizationFollowRequest.php b/app/Http/Requests/CreateOrganizationFollowRequest.php new file mode 100644 index 0000000000..2ab0cfc021 --- /dev/null +++ b/app/Http/Requests/CreateOrganizationFollowRequest.php @@ -0,0 +1,23 @@ +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(); + } +} diff --git a/app/Http/Requests/DeleteOrganizationFollowRequest.php b/app/Http/Requests/DeleteOrganizationFollowRequest.php new file mode 100644 index 0000000000..b8070184ce --- /dev/null +++ b/app/Http/Requests/DeleteOrganizationFollowRequest.php @@ -0,0 +1,23 @@ +route('user'); + assert($user instanceof User); + + $viewer = $this->user(); + assert($viewer instanceof User); + + $blockerId = $viewer->blockerIdWith($user); + + throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer->id, shown: UserResource::make($user))); + + return true; + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Resources/OrganizationResource.php b/app/Http/Resources/OrganizationResource.php index 996d95832c..3982bee0a7 100644 --- a/app/Http/Resources/OrganizationResource.php +++ b/app/Http/Resources/OrganizationResource.php @@ -9,7 +9,8 @@ use Illuminate\Http\Resources\Json\JsonResource; /** - * An organization's GitHub profile, as its page on the web shows it. + * An organization's GitHub profile, as its page on the web shows it. In a list, a signed request also gets whether its + * user follows it. * * @mixin Organization */ @@ -29,6 +30,7 @@ public function toArray(Request $request): array 'website' => $this->website, 'github_url' => "https://github.com/{$this->handle}", 'html_url' => route('user.show', ['user' => $this->handle]), + 'is_followed' => $this->whenHas('is_followed'), ]; } } diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 1273d10c5f..9caa3b15d9 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -120,6 +120,16 @@ public function admins(): BelongsToMany return $this->members()->withPivotValue('role', OrganizationRole::Admin->value); } + /** + * The users who follow the organization, whether they are its members or not. + * + * @return BelongsToMany + */ + public function followers(): BelongsToMany + { + return $this->belongsToMany(User::class, 'organization_follows', 'organization_id')->withTimestamps(updatedAt: false); + } + /** * The user's role in the organization, or null when they aren't a member. */ diff --git a/app/Models/User.php b/app/Models/User.php index 2ee8c1793e..1dd40b6244 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -343,6 +343,16 @@ public function organizations(): BelongsToMany return $this->belongsToMany(Organization::class, 'organization_members', 'user_id', 'organization_id')->withPivot('role')->withTimestamps(updatedAt: false); } + /** + * The organizations the user follows, see `Organization::followers()`. + * + * @return BelongsToMany + */ + public function followedOrganizations(): BelongsToMany + { + return $this->belongsToMany(Organization::class, 'organization_follows', 'user_id', 'organization_id')->withTimestamps(updatedAt: false); + } + /** * @return BelongsToMany */ @@ -450,7 +460,7 @@ public function isAdmin(): bool } /** - * Unlike loadCount(), counts follows on the pivot table alone: the self-referencing + * Unlike loadCount(), counts follows on the pivot tables alone: the self-referencing * relations would otherwise join the users table for every follow. Commits are * counted as the viewer sees them, which is all of them for the user themselves. */ @@ -668,7 +678,7 @@ private function fillCounts(int $commits): self $counts = [ 'commits_count' => $commits, 'followers_count' => $this->followers()->newPivotQuery()->count(), - 'following_count' => $this->following()->newPivotQuery()->count(), + 'following_count' => $this->following()->newPivotQuery()->count() + $this->followedOrganizations()->newPivotQuery()->count(), ]; $this->forceFill($counts)->syncOriginalAttributes(array_keys($counts)); diff --git a/app/Queries/FollowedOrganizationsQuery.php b/app/Queries/FollowedOrganizationsQuery.php new file mode 100644 index 0000000000..74fe936931 --- /dev/null +++ b/app/Queries/FollowedOrganizationsQuery.php @@ -0,0 +1,27 @@ + + */ + public function builder(User $user, User $viewer): BelongsToMany + { + return $user->followedOrganizations() + ->withExists(['followers as is_followed' => fn (Builder $query): Builder => $query->whereKey($viewer->id)]) + ->latest('organization_follows.created_at') + ->latest('organizations.id'); + } +} diff --git a/database/migrations/2026_10_30_180000_create_organization_follows_table.php b/database/migrations/2026_10_30_180000_create_organization_follows_table.php new file mode 100644 index 0000000000..b877eac7f9 --- /dev/null +++ b/database/migrations/2026_10_30_180000_create_organization_follows_table.php @@ -0,0 +1,27 @@ +foreignUuid('user_id')->constrained(); + $table->foreignUuid('organization_id')->constrained(); + $table->timestamp('created_at'); + + $table->primary(['user_id', 'organization_id']); + $table->index('organization_id'); + }); + } +}; diff --git a/resources/js/components/organization-follow-button.tsx b/resources/js/components/organization-follow-button.tsx new file mode 100644 index 0000000000..1ce0387a46 --- /dev/null +++ b/resources/js/components/organization-follow-button.tsx @@ -0,0 +1,214 @@ +import { router, usePage } from '@inertiajs/react'; +import { ChevronDown } from 'lucide-react'; +import type { ComponentProps } from 'react'; +import { useRef } from 'react'; +import OrganizationFollowController from '@/actions/App/Http/Controllers/OrganizationFollowController'; +import { + FollowLabel, + useFollowHover, + useIsFollowing, +} from '@/components/follow-button'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useSharedState } from '@/hooks/use-shared-state'; +import { signIn } from '@/lib/auth'; +import { toastWhenThrottled } from '@/lib/throttle'; +import { cn } from '@/lib/utils'; +import type { Organization } from '@/types'; + +type FollowedOrganization = Pick & { id: string }; + +/** + * Follows an organization, or unfollows it, sharing whether you follow it with every other control for it, like + * following people. Each follow waits for the one before it to answer, so a double click can't follow and then + * unfollow. + */ +function useOrganizationFollow( + organization: FollowedOrganization, + initialIsFollowing: boolean, + only: string[], + onToggled: () => void, +) { + const [isFollowing, setIsFollowing] = useIsFollowing( + organization, + initialIsFollowing, + ); + const [isPending, setIsPending] = useSharedState( + `follow_pending:${organization.id}`, + false, + ); + + const toggle = () => { + if (isPending) { + return; + } + + router.visit( + isFollowing + ? OrganizationFollowController.destroy(organization) + : OrganizationFollowController.store(organization), + { + only, + preserveState: true, + preserveScroll: true, + async: true, + onStart: () => setIsPending(true), + onFinish: () => setIsPending(false), + onSuccess: () => { + setIsFollowing(!isFollowing); + router.flushAll(); + onToggled(); + }, + onHttpException: (response) => !toastWhenThrottled(response), + }, + ); + }; + + return { isFollowing, isPending, toggle }; +} + +/** + * Following an organization shows what it commits. Watching, which also shows the commits that mention it, comes + * later: the menu already offers it, so following doesn't change shape when it does. + */ +export function OrganizationFollowButton({ + organization, + isFollowing: initialIsFollowing, + menu = false, + only = ['auth'], + size, + className, +}: { + organization: FollowedOrganization; + isFollowing: boolean; + /** + * Whether a followed organization opens the Follow and Watch menu, as on its page, instead of unfollowing. + */ + menu?: boolean; + only?: string[]; + size?: ComponentProps['size']; + className?: string; +}) { + const { auth } = usePage().props; + // Both states share it, so unfollowing from the menu keeps keyboard focus on the button that replaces it. + const buttonRef = useRef(null); + const { isFollowing, isPending, toggle } = useOrganizationFollow( + organization, + initialIsFollowing, + only, + () => + requestAnimationFrame(() => { + if (document.activeElement === document.body) { + buttonRef.current?.focus(); + } + }), + ); + const { isJustToggled, markToggled, onMouseLeave } = useFollowHover(); + + if (auth.user === null) { + return ( + + ); + } + + if (isFollowing && menu) { + return ( + + + + + + event.preventDefault()} + className="items-start" + > + + Follow + + What @{organization.handle} commits + + + + + + + Watch + + Coming soon + + + + Its commits, plus every commit mentioning @ + {organization.handle} + + + + + + Unfollow + + + + ); + } + + return ( + + ); +} diff --git a/resources/js/pages/organization/show.tsx b/resources/js/pages/organization/show.tsx index 70c81a5ef7..6bd946df6a 100644 --- a/resources/js/pages/organization/show.tsx +++ b/resources/js/pages/organization/show.tsx @@ -7,12 +7,14 @@ import { BioText } from '@/components/bio-text'; import { CommitFeed, EmptyFeed } from '@/components/commit-feed'; import { GitHubIcon } from '@/components/github-icon'; import { OrganizationAvatar } from '@/components/organization-avatar'; +import { OrganizationFollowButton } from '@/components/organization-follow-button'; import { PageTabs } from '@/components/page-tabs'; import { ProfileShareButton } from '@/components/profile-share-button'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { UserAvatar } from '@/components/user-avatar'; import AppLayout from '@/layouts/app-layout'; +import { exactCountTitle, formatCount } from '@/lib/numbers'; import { organizationLaneColor } from '@/lib/utils'; import type { Commit, @@ -30,6 +32,8 @@ export default function Show({ claimUrl, settingsUrl, admins, + followersCount, + isFollowing, }: { organization: Organization & { id: string }; lane: Lane | null; @@ -38,6 +42,8 @@ export default function Show({ claimUrl: string; settingsUrl: string | null; admins: User[]; + followersCount: number; + isFollowing: boolean; }) { const { props, flash } = usePage(); const { auth } = props; @@ -74,7 +80,7 @@ export default function Show({ } /> -
+
{canClaim && (
@@ -160,6 +173,18 @@ export default function Show({ +

+ + {formatCount(followersCount)} + + + {followersCount === 1 ? 'follower' : 'followers'} + +

+ {admins.length > 0 && (
Admins diff --git a/resources/js/pages/user/connections.tsx b/resources/js/pages/user/connections.tsx index 2b02873322..57f7ad39c2 100644 --- a/resources/js/pages/user/connections.tsx +++ b/resources/js/pages/user/connections.tsx @@ -7,16 +7,24 @@ import UserFollowingController from '@/actions/App/Http/Controllers/UserFollowin import UserMutualsController from '@/actions/App/Http/Controllers/UserMutualsController'; import { EmptyFeed } from '@/components/commit-feed'; import { FollowButton } from '@/components/follow-button'; +import { OrganizationAvatar } from '@/components/organization-avatar'; +import { OrganizationFollowButton } from '@/components/organization-follow-button'; import { PageTabs } from '@/components/page-tabs'; +import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { UserAvatar } from '@/components/user-avatar'; import AppLayout from '@/layouts/app-layout'; import { laneColor } from '@/lib/utils'; import { show } from '@/routes/user'; -import type { CursorPaginated, User } from '@/types'; +import type { CursorPaginated, Organization, User } from '@/types'; type Connection = User & { is_followed: boolean }; +type FollowedOrganization = Organization & { + id: string; + is_followed: boolean; +}; + type ConnectionType = | 'followers' | 'followers_you_know' @@ -48,10 +56,15 @@ export default function Connections({ user, type, users, + organizations, }: { user: User; type: ConnectionType; users: CursorPaginated; + /** + * Only on the Following list, above the people. + */ + organizations?: CursorPaginated; }) { const { auth } = usePage().props; const title = titles[type]; @@ -117,7 +130,81 @@ export default function Connections({ />
- {users.data.length === 0 ? ( + {organizations !== undefined && organizations.data.length > 0 && ( +
+

+ Organizations +

+ + hasMore && ( +
+ +
+ ) + } + > +
    + {organizations.data.map((organization) => ( +
  • + + +
    +

    + {organization.name} +

    +

    + @{organization.handle} +

    + {organization.bio && ( +

    + {organization.bio} +

    + )} +
    + + +
  • + ))} +
+
+
+ )} + + {users.data.length === 0 && !organizations?.data.length ? (
- ) : ( + ) : users.data.length === 0 ? null : ( } > + {Boolean(organizations?.data.length) && ( +

+ People +

+ )}
    {users.data.map((person) => (
  • name('api-user-followers.index'); Route::get('users/{user:handle}/following', [UserFollowingController::class, 'index']) ->name('api-user-following.index'); + Route::get('users/{user:handle}/following/organizations', [UserFollowedOrganizationController::class, 'index']) + ->name('api-user-followed-organization.index'); // Search: people by name or handle, and commits by meaning, as the web's search finds them... Route::get('search/users', [UserSearchController::class, 'index']) @@ -270,6 +274,14 @@ ->middleware('throttle:follows') ->name('api-user-follow.destroy'); + // Organization Follow... + Route::post('organizations/{handle}/follow', [OrganizationFollowController::class, 'store']) + ->middleware('throttle:follows') + ->name('api-organization-follow.store'); + Route::delete('organizations/{handle}/follow', [OrganizationFollowController::class, 'destroy']) + ->middleware('throttle:follows') + ->name('api-organization-follow.destroy'); + // Who to Follow... Route::get('me/suggested-users', [SuggestedUserController::class, 'index'])->name('api-suggested-user.index'); Route::post('users/{user:handle}/dismiss', [SuggestionDismissalController::class, 'store'])->name('api-suggestion-dismissal.store'); diff --git a/routes/web.php b/routes/web.php index 92d6528391..b510edfef8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -80,6 +80,7 @@ use App\Http\Controllers\OperationsEventController; use App\Http\Controllers\OrganizationAdminController; use App\Http\Controllers\OrganizationController; +use App\Http\Controllers\OrganizationFollowController; use App\Http\Controllers\OrganizationLaneController; use App\Http\Controllers\OrganizationMemberController; use App\Http\Controllers\OrganizationMentionController; @@ -645,6 +646,16 @@ ->middleware('throttle:follows') ->name('user-follow.destroy'); + // Organization Follow... + Route::post('organizations/{organization}/follow', [OrganizationFollowController::class, 'store']) + ->whereUuid('organization') + ->middleware('throttle:follows') + ->name('organization-follow.store'); + Route::delete('organizations/{organization}/follow', [OrganizationFollowController::class, 'destroy']) + ->whereUuid('organization') + ->middleware('throttle:follows') + ->name('organization-follow.destroy'); + // User Mute... Route::post('@{user:handle}/mute', [UserMuteController::class, 'store']) ->name('user-mute.store'); diff --git a/tests/Feature/Controllers/Api/OrganizationFollowControllerTest.php b/tests/Feature/Controllers/Api/OrganizationFollowControllerTest.php new file mode 100644 index 0000000000..8ead56d2fa --- /dev/null +++ b/tests/Feature/Controllers/Api/OrganizationFollowControllerTest.php @@ -0,0 +1,39 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('may follow and unfollow an organization by its handle', function (): void { + $organization = Organization::factory()->create(['handle' => 'laravel']); + + signedApiRequest($this->keyPair, 'POST', route('api-organization-follow.store', ['handle' => 'laravel']))->assertNoContent(); + + expect($this->user->followedOrganizations()->pluck('organizations.id')->all())->toBe([$organization->id]); + + signedApiRequest($this->keyPair, 'DELETE', route('api-organization-follow.destroy', ['handle' => 'laravel']))->assertNoContent(); + + expect($this->user->followedOrganizations()->count())->toBe(0); +}); + +it('returns not found for handles without an organization page', function (string $method, string $route): void { + User::factory()->create(['handle' => 'octocat']); + + signedApiRequest($this->keyPair, $method, route($route, ['handle' => 'octocat']))->assertNotFound(); + signedApiRequest($this->keyPair, $method, route($route, ['handle' => 'missing']))->assertNotFound(); +})->with([ + ['POST', 'api-organization-follow.store'], + ['DELETE', 'api-organization-follow.destroy'], +]); + +it('requires a signed request', function (): void { + Organization::factory()->create(['handle' => 'laravel']); + + $this->postJson(route('api-organization-follow.store', ['handle' => 'laravel']))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/UserFollowedOrganizationControllerTest.php b/tests/Feature/Controllers/Api/UserFollowedOrganizationControllerTest.php new file mode 100644 index 0000000000..6f20cb5787 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserFollowedOrganizationControllerTest.php @@ -0,0 +1,68 @@ +create(['handle' => 'octocat']); + $user->followedOrganizations()->attach(Organization::factory()->create()); + + $this->getJson('/api/v1/users/octocat/following/organizations')->assertUnauthorized(); +}); + +it('lists the organizations a user follows, newest follow first, with whether the signed-in user follows each one', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $laravel = Organization::factory()->create(['handle' => 'laravel']); + $bref = Organization::factory()->create(['handle' => 'brefphp']); + $user->followedOrganizations()->attach($bref, ['created_at' => now()->subMinute()]); + $user->followedOrganizations()->attach($laravel); + $user->following()->attach(User::factory()->create()); + $viewer->followedOrganizations()->attach($bref); + + signedApiRequest($keyPair, 'GET', route('api-user-followed-organization.index', $user)) + ->assertOk() + ->assertJsonPath('data.*.handle', ['laravel', 'brefphp']) + ->assertJsonPath('data.0.html_url', route('user.show', ['user' => 'laravel'])) + ->assertJsonPath('data.*.is_followed', [false, true]) + ->assertJsonPath('meta.next_cursor', null); +}); + +it('pages through the organizations 30 at a time', function (): void { + $user = User::factory()->create(); + $organizations = Organization::factory()->count(31)->create(); + + foreach ($organizations as $index => $organization) { + $user->followedOrganizations()->attach($organization, ['created_at' => now()->subMinutes($index)]); + } + + $keyPair = apiKeyPairFor(User::factory()->create()); + + $first = signedApiRequest($keyPair, 'GET', route('api-user-followed-organization.index', $user)) + ->assertOk() + ->assertJsonCount(30, 'data'); + + signedApiRequest($keyPair, 'GET', $first->json('links.next')) + ->assertOk() + ->assertJsonPath('data.*.handle', [$organizations->last()->handle]); +}); + +it('lists none across a block for a signed request', function (Closure $block, string $code): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $user->followedOrganizations()->attach(Organization::factory()->create()); + + $block($viewer, $user); + + signedApiRequest($keyPair, 'GET', route('api-user-followed-organization.index', $user)) + ->assertForbidden() + ->assertJsonPath('code', $code) + ->assertJsonMissingPath('data'); +})->with([ + 'blocking' => [fn (User $viewer, User $user) => $viewer->blocking()->attach($user), 'user_blocked'], + 'blocked' => [fn (User $viewer, User $user) => $user->blocking()->attach($viewer), 'user_unavailable'], +]); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 409a08249d..77c3e47862 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -137,6 +137,7 @@ 'blocking' => [], 'dismissed_suggestions' => [], 'organizations' => [], + 'followed_organizations' => [], 'messages' => [], 'reports' => [], 'remote_mutes' => [], @@ -490,6 +491,24 @@ expect($data['active_days'])->toBe(['2026-10-29', '2026-10-30']); }); +it('exports the organizations the user follows, first follow first', function (): void { + $this->freezeSecond(); + $user = User::factory()->create(); + $laravel = Organization::factory()->create(['handle' => 'laravel']); + $bref = Organization::factory()->create(['handle' => 'brefphp']); + $user->followedOrganizations()->attach($laravel, ['created_at' => now()]); + $user->followedOrganizations()->attach($bref, ['created_at' => now()->subDay()]); + User::factory()->create()->followedOrganizations()->attach(Organization::factory()->create()); + + $response = $this->actingAs($user)->get(route('export')); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['followed_organizations'])->toBe([ + ['handle' => 'brefphp', 'followed_at' => now()->subDay()->toIso8601String()], + ['handle' => 'laravel', 'followed_at' => now()->toIso8601String()], + ]); +}); + it('exports the devices approved for the app, oldest first', function (): void { $user = User::factory()->create(); $older = Device::factory()->for($user)->create(['name' => 'devnull on iPad', 'approved_at' => now()->subWeek(), 'last_used_at' => now()->subDay()]); diff --git a/tests/Feature/Controllers/OrganizationControllerTest.php b/tests/Feature/Controllers/OrganizationControllerTest.php index a5e545cf57..5e08d020c8 100644 --- a/tests/Feature/Controllers/OrganizationControllerTest.php +++ b/tests/Feature/Controllers/OrganizationControllerTest.php @@ -81,6 +81,26 @@ ->assertInertia(fn ($page) => $page->where('settingsUrl', null)); }); +it('counts who follows the organization and says whether the viewer does', function (): void { + $organization = Organization::factory()->create(['handle' => 'laravel']); + $viewer = User::factory()->create(); + $organization->followers()->attach(User::factory()->count(2)->create()); + $viewer->followedOrganizations()->attach(Organization::factory()->create()); + + $this->actingAs($viewer)->get('/@laravel') + ->assertInertia(fn ($page) => $page->where('followersCount', 2)->where('isFollowing', false)); + + $organization->followers()->attach($viewer); + + $this->actingAs($viewer)->get('/@laravel') + ->assertInertia(fn ($page) => $page->where('followersCount', 3)->where('isFollowing', true)); + + auth()->logout(); + + $this->get('/@laravel') + ->assertInertia(fn ($page) => $page->where('followersCount', 3)->where('isFollowing', false)); +}); + it('shows guests the link preview of the organization', function (): void { $laravel = Organization::factory()->create([ 'handle' => 'laravel', diff --git a/tests/Feature/Controllers/OrganizationFollowControllerTest.php b/tests/Feature/Controllers/OrganizationFollowControllerTest.php new file mode 100644 index 0000000000..097b6d4394 --- /dev/null +++ b/tests/Feature/Controllers/OrganizationFollowControllerTest.php @@ -0,0 +1,58 @@ +freezeSecond(); + $user = User::factory()->create(); + $organization = Organization::factory()->create(['handle' => 'laravel']); + $user->followedOrganizations()->attach($organization, ['created_at' => now()->subDay()]); + $other = Organization::factory()->create(); + + $this->actingAs($user) + ->from('/@laravel') + ->post(route('organization-follow.store', $other)) + ->assertRedirect('/@laravel'); + + $this->actingAs($user)->post(route('organization-follow.store', $organization)); + + expect($user->followedOrganizations()->oldest('organization_follows.created_at')->get()->map(fn (Organization $followed): array => [$followed->id, $followed->pivot->created_at->toIso8601String()])->all()) + ->toBe([[$organization->id, now()->subDay()->toIso8601String()], [$other->id, now()->toIso8601String()]]); +}); + +it('may unfollow an organization', function (): void { + $user = User::factory()->create(); + $organization = Organization::factory()->create(); + $kept = Organization::factory()->create(); + $user->followedOrganizations()->attach([$organization->id, $kept->id]); + $organization->followers()->attach(User::factory()->create()); + + $this->actingAs($user) + ->delete(route('organization-follow.destroy', $organization)) + ->assertRedirect(); + + expect($user->followedOrganizations()->pluck('organizations.id')->all())->toBe([$kept->id]) + ->and($organization->followers()->count())->toBe(1); +}); + +it('does not follow a user through their profile', function (): void { + $user = User::factory()->create(); + $other = User::factory()->create(); + + $this->actingAs($user) + ->post(route('organization-follow.store', $other->profile)) + ->assertNotFound(); + + expect($user->followedOrganizations()->count())->toBe(0); +}); + +it('sends guests to sign in', function (): void { + $organization = Organization::factory()->create(); + + $this->post(route('organization-follow.store', $organization))->assertRedirect(); + + expect($organization->followers()->count())->toBe(0); +}); diff --git a/tests/Feature/Controllers/UserFollowingControllerTest.php b/tests/Feature/Controllers/UserFollowingControllerTest.php index 1b970507da..06df3aef6c 100644 --- a/tests/Feature/Controllers/UserFollowingControllerTest.php +++ b/tests/Feature/Controllers/UserFollowingControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Models\Organization; use App\Models\User; it('lists who a user is following', function (): void { @@ -22,6 +23,55 @@ ->where('users.data.0.id', $followed->id)); }); +it('lists the organizations a user follows apart, newest follow first, marking those the viewer follows', function (): void { + $viewer = User::factory()->create(); + $user = User::factory()->create(); + $person = User::factory()->create(); + $laravel = Organization::factory()->create(['handle' => 'laravel']); + $bref = Organization::factory()->create(['handle' => 'brefphp']); + $user->following()->attach($person); + $user->followedOrganizations()->attach($bref, ['created_at' => now()->subDay()]); + $user->followedOrganizations()->attach($laravel, ['created_at' => now()]); + $viewer->followedOrganizations()->attach($bref); + + $this->actingAs($viewer)->get(route('user-following.index', $user)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('users.data', 1) + ->where('users.data.0.id', $person->id) + ->has('organizations.data', 2) + ->where('organizations.data.0.id', $laravel->id) + ->where('organizations.data.0.handle', 'laravel') + ->where('organizations.data.0.is_followed', false) + ->where('organizations.data.1.id', $bref->id) + ->where('organizations.data.1.is_followed', true) + ->missing('organizations.data.0.pivot') + ->missing('organizations.data.0.github_id')); +}); + +it('pages through the organizations with a cursor of their own, 10 at a time', function (): void { + $user = User::factory()->create(); + $organizations = Organization::factory()->count(11)->create(); + + foreach ($organizations as $index => $organization) { + $user->followedOrganizations()->attach($organization, ['created_at' => now()->subMinutes($index)]); + } + + $viewer = User::factory()->create(); + + $first = $this->actingAs($viewer)->get(route('user-following.index', $user)); + + $first->assertInertia(fn ($page) => $page->has('organizations.data', 10)); + + $next = $first->viewData('page')['props']['organizations']['next_page_url']; + + expect($next)->toContain('organizations_cursor='); + + $this->get($next)->assertInertia(fn ($page) => $page + ->has('organizations.data', 1) + ->where('organizations.data.0.id', $organizations->last()->id)); +}); + it('marks the users the viewer follows', function (): void { $viewer = User::factory()->create(); $user = User::factory()->create(); diff --git a/tests/Unit/Actions/DeleteOrganizationTest.php b/tests/Unit/Actions/DeleteOrganizationTest.php index 56c95b8bb1..77b5cf3068 100644 --- a/tests/Unit/Actions/DeleteOrganizationTest.php +++ b/tests/Unit/Actions/DeleteOrganizationTest.php @@ -9,17 +9,19 @@ use App\Models\User; use Illuminate\Support\Facades\DB; -it('deletes an organization with its profile and members, keeping the users', function (): void { +it('deletes an organization with its profile, members and followers, keeping the users', function (): void { $user = User::factory()->create(); $organization = Organization::factory()->create(); $kept = Organization::factory()->create(); $organization->members()->attach($user->id, ['role' => OrganizationRole::Member]); $kept->admins()->attach($user->id, ['created_at' => now()]); + $user->followedOrganizations()->attach([$organization->id, $kept->id]); resolve(DeleteOrganization::class)->handle($organization); expect($organization->fresh())->toBeNull() ->and(Profile::query()->find($organization->id))->toBeNull() ->and($user->fresh())->not->toBeNull() - ->and(DB::table('organization_members')->pluck('organization_id')->all())->toBe([$kept->id]); + ->and(DB::table('organization_members')->pluck('organization_id')->all())->toBe([$kept->id]) + ->and(DB::table('organization_follows')->pluck('organization_id')->all())->toBe([$kept->id]); }); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index e34d0ce1d6..49df3dd13f 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -218,6 +218,17 @@ expect(DB::table('user_active_days')->pluck('user_id')->all())->toBe([$other->id]); }); +it('erases the organizations a deleted user followed, keeping their other followers', function (): void { + $user = User::factory()->create(); + $other = User::factory()->create(); + $organization = Organization::factory()->create(); + $organization->followers()->attach([$user->id, $other->id]); + + resolve(DeleteUser::class)->handle($user); + + expect($organization->followers()->pluck('users.id')->all())->toBe([$other->id]); +}); + it('erases the devices of a deleted user', function (): void { $user = User::factory()->create(); Device::factory()->for($user)->create(); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index d1eb7eab3e..f49042815e 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -4,6 +4,7 @@ use App\Models\Commit; use App\Models\Device; +use App\Models\Organization; use App\Models\SshKey; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -110,12 +111,14 @@ Commit::factory()->for($user)->pending()->create(); Commit::factory()->for($user)->rejected()->create(); $user->following()->attach(User::factory()->count(2)->create()); + $user->followedOrganizations()->attach(Organization::factory()->create()); User::factory()->create()->following()->attach($user); + User::factory()->create()->followedOrganizations()->attach(Organization::factory()->create()); $user->loadPublicCounts(); expect($user->commits_count)->toBe(1) - ->and($user->following_count)->toBe(2) + ->and($user->following_count)->toBe(3) ->and($user->followers_count)->toBe(1) ->and($user->isDirty())->toBeFalse(); }); @@ -129,7 +132,7 @@ $queries = array_column(DB::getQueryLog(), 'query'); - expect($queries)->toHaveCount(3) + expect($queries)->toHaveCount(4) ->each->not->toContain('join'); });