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'); });