diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 4012c6e8b..05275dd1a 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -124,7 +124,7 @@ While nobody has claimed an organization's page, it says so to signed-in visitor After that, members are managed on /dev/null, not mirrored from GitHub. An organization's members open its **Settings** from its page, where the **Members** tab lists everyone, each an **admin** or a **member**. Admins add people by their /dev/null handle, searching by handle or name as they type, except people they blocked or who blocked them, make them admins or members, and remove them. Any member can leave. Its last admin can't leave or become a member. Only its members see who its members are; its page lists its admins to everyone. Your organizations and your role in each are in your data export and go when you delete your account. -Its admins and members can **post as the organization**: in the composer, and in a reply's, they open their avatar and pick the organization, then switch back to themselves the same way. Every commit and reply starts as yourself. A commit from an organization shows the organization alone, with its square avatar and color, at `/@{handle}/commits/{id}`: nobody else sees which member posted it. It belongs to the organization: any of its members can delete it, and none can star it, report it, vote in its poll or give it pastéis. It counts toward no one's level, and blocking or muting the person who posted it doesn't hide it. Moderators see the person who posted it, in the review queue and in reports, and can time out or ban them from their profile. If they delete their account, the organization's commits they posted stay. Its admins and members hear about the organization in their own notifications, all except whoever did it: replies to its commits, quotes of them, stars on them and commits that mention it, said as the organization's, like **replied to @laravel's commit** or **mentioned @laravel**. They all see the organization's pending and rejected commits, like whoever posted them, and all hear when one is rejected, with the reason. Someone removed from the organization keeps what they already heard and hears nothing new, and no longer sees its rejected commits or what they heard about them. They can schedule commits as the organization too, the same way: if they are no longer a member when it is due, it is canceled. When the organization replies, quotes or mentions someone, they hear it from the organization, never from whoever posted it, so blocking or muting that person doesn't silence it; its members hear nothing of what it does to its own commits or mentioning itself. Its first commit isn't announced to its followers, as a person's is. Apps can post as an organization through the API too, by sending its handle as `author`. Posting as an organization is still being rolled out: until administrators switch it on for everyone, the **Post as** menu doesn't show and the API refuses `author`. +Its admins and members can **post as the organization**: in the composer, and in a reply's, they open their avatar and pick the organization, then switch back to themselves the same way. Every commit and reply starts as yourself. A commit from an organization shows the organization alone, with its square avatar and color, at `/@{handle}/commits/{id}`: nobody else sees which member posted it. On a computer, hovering the organization's avatar or name on its commits opens its hover card, with its bio, how many commits it has published, how many follow it and a **Follow** button. It belongs to the organization: any of its members can delete it, and none can star it, report it, vote in its poll or give it pastéis. It counts toward no one's level, and blocking or muting the person who posted it doesn't hide it. Moderators see the person who posted it, in the review queue and in reports, and can time out or ban them from their profile. If they delete their account, the organization's commits they posted stay. Its admins and members hear about the organization in their own notifications, all except whoever did it: replies to its commits, quotes of them, stars on them and commits that mention it, said as the organization's, like **replied to @laravel's commit** or **mentioned @laravel**. They all see the organization's pending and rejected commits, like whoever posted them, and all hear when one is rejected, with the reason. Someone removed from the organization keeps what they already heard and hears nothing new, and no longer sees its rejected commits or what they heard about them. They can schedule commits as the organization too, the same way: if they are no longer a member when it is due, it is canceled. When the organization replies, quotes or mentions someone, they hear it from the organization, never from whoever posted it, so blocking or muting that person doesn't silence it; its members hear nothing of what it does to its own commits or mentioning itself. Its first commit isn't announced to its followers, as a person's is. Apps can post as an organization through the API too, by sending its handle as `author`. Posting as an organization is still being rolled out: until administrators switch it on for everyone, the **Post as** menu doesn't show and the API refuses `author`. 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. diff --git a/app/Http/Controllers/OrganizationPreviewController.php b/app/Http/Controllers/OrganizationPreviewController.php new file mode 100644 index 000000000..6ca054286 --- /dev/null +++ b/app/Http/Controllers/OrganizationPreviewController.php @@ -0,0 +1,27 @@ +json([ + 'commitsCount' => Commit::query()->where('author_id', $organization->id)->approved()->count(), + 'followersCount' => $organization->followers()->newPivotQuery()->count(), + 'isFollowing' => $currentUser?->followedOrganizations()->newPivotQuery()->where('organization_id', $organization->id)->exists() ?? false, + ]); + } +} diff --git a/resources/js/components/user-preview.tsx b/resources/js/components/user-preview.tsx index 8fb81d545..5f23434d4 100644 --- a/resources/js/components/user-preview.tsx +++ b/resources/js/components/user-preview.tsx @@ -11,6 +11,7 @@ import { followVisitOptions, useFollow, useFollowHover, + useIsFollowing, } from '@/components/follow-button'; import { LiveAvatarMarker, @@ -18,20 +19,24 @@ import { } from '@/components/live-stream-card'; import { LocalTime } from '@/components/local-time'; import { MembersLink } from '@/components/members-link'; +import { OrganizationAvatar } from '@/components/organization-avatar'; +import { OrganizationFollowButton } from '@/components/organization-follow-button'; import { Button } from '@/components/ui/button'; import { UserAvatar } from '@/components/user-avatar'; import { useIsMuting, UserSafetyMenu } from '@/components/user-safety-menu'; import { signIn } from '@/lib/auth'; import { exactCountTitle, formatCount } from '@/lib/numbers'; import { toastWhenThrottled } from '@/lib/throttle'; -import { cn, laneColor } from '@/lib/utils'; +import { cn, laneColor, organizationLaneColor } from '@/lib/utils'; import { preview } from '@/routes/mention'; +import { show as organizationPreview } from '@/routes/organization-preview'; import { show } from '@/routes/user'; import { show as userPreview } from '@/routes/user-preview'; import type { Commit, CommitAuthor, CursorPaginated, + OrganizationAuthor, Profile, User, } from '@/types'; @@ -48,6 +53,12 @@ type PreviewData = { | { isUnavailable: true; user: User } ); +type OrganizationPreviewData = { + commitsCount: number; + followersCount: number; + isFollowing: boolean; +}; + type Position = | { left: number; top: number; bottom?: never } | { left: number; bottom: number; top?: never }; @@ -59,9 +70,11 @@ const previewHeight = 260; const viewportMargin = 8; // Where the centre of the preview's avatar sits: border, padding and half of the size-14 avatar. const avatarCentre = 45; -const cache = new Map(); +const cache = new Map(); -async function fetchPreview(url: string): Promise { +async function fetchPreview< + T extends PreviewData | OrganizationPreviewData = PreviewData, +>(url: string): Promise { const response = await fetch(url, { headers: { Accept: 'application/json', @@ -75,12 +88,18 @@ async function fetchPreview(url: string): Promise { throw new Error(`Unable to load ${url}`); } - const data = (await response.json()) as PreviewData; + const data = (await response.json()) as T; cache.set(url, data); return data; } +function cached( + url: string, +): T | null { + return (cache.get(url) as T | undefined) ?? null; +} + export type Relationship = Pick< PreviewData, 'isFollowing' | 'isMuting' | 'isBlocking' @@ -94,8 +113,8 @@ export function usePreviewRelationship( isEnabled: boolean, ): Relationship | null { const url = userPreview(user).url; - const [relationship, setRelationship] = useState( - () => cache.get(url) ?? null, + const [relationship, setRelationship] = useState(() => + cached(url), ); useEffect(() => { @@ -149,9 +168,6 @@ export function UserPreview({ anchor, onHoverChange, }: { - /** - * An organization's commits have no hover card, so they only report hovering. - */ user: User | Pick | CommitAuthor; userId?: string; children: ReactNode; @@ -159,7 +175,13 @@ export function UserPreview({ anchor?: RefObject; onHoverChange?: (isHovering: boolean) => void; }) { - const previewUrl = userId ? preview(userId).url : userPreview(user).url; + const organization = + 'type' in user && user.type === 'organization' ? user : null; + const previewUrl = organization + ? organizationPreview(organization).url + : userId + ? preview(userId).url + : userPreview(user).url; const triggerRef = useRef(null); const previewRef = useRef(null); const timer = useRef>(undefined); @@ -167,14 +189,16 @@ export function UserPreview({ const isPointerOver = useRef(false); const [position, setPosition] = useState(null); const [isOpen, setIsOpen] = useState(false); - const [data, setData] = useState( - () => cache.get(previewUrl) ?? null, - ); + const [data, setData] = useState< + PreviewData | OrganizationPreviewData | null + >(() => cached(previewUrl)); + const userData = data && 'user' in data ? data : null; const previewUser = - data?.user ?? + userData?.user ?? ('id' in user && !('type' in user && user.type === 'organization') ? user : null); + const previewHandle = organization?.handle ?? previewUser?.handle; const close = () => { setIsOpen(false); @@ -202,7 +226,7 @@ export function UserPreview({ : positionFor(triggerRef.current), ); setIsOpen(true); - setData(cache.get(previewUrl) ?? null); + setData(cached(previewUrl)); fetchPreview(previewUrl).then(setData, () => undefined); }, openDelay); }; @@ -296,10 +320,7 @@ export function UserPreview({ } changeHover(true); - - if (!('type' in user && user.type === 'organization')) { - scheduleOpen(); - } + scheduleOpen(); }} onPointerLeave={(event) => { if (event.pointerType !== 'mouse') { @@ -315,12 +336,12 @@ export function UserPreview({ {position && - previewUser && + previewHandle && createPortal(
{ if (event.pointerType !== 'mouse') { return; @@ -347,24 +368,33 @@ export function UserPreview({ }} data-test="user-preview" > - { - isMenuOpen.current = isOpen; - clearTimeout(timer.current); - - if (!isOpen && !isPointerOver.current) { - scheduleClose(); - } - }} - onBlockChange={() => - fetchPreview(previewUrl).then( - setData, - () => undefined, - ) - } - /> + {organization ? ( + + ) : ( + previewUser && ( + { + isMenuOpen.current = isOpen; + clearTimeout(timer.current); + + if (!isOpen && !isPointerOver.current) { + scheduleClose(); + } + }} + onBlockChange={() => + fetchPreview(previewUrl).then( + setData, + () => undefined, + ) + } + /> + ) + )}
, document.body, )} @@ -600,3 +630,105 @@ function UserPreviewContent({ ); } + +function OrganizationPreviewContent({ + organization, + data, +}: { + organization: OrganizationAuthor; + data: OrganizationPreviewData | null; +}) { + const lane = organizationLaneColor(organization.lane); + const [isFollowing] = useIsFollowing( + organization, + data?.isFollowing ?? false, + ); + const followsDelta = + Number(isFollowing) - Number(data?.isFollowing ?? false); + + return ( +
+
+ + + + + {data ? ( + + ) : ( + + )} +
+ + + {organization.name} + +

+ + @{organization.handle} + + Organization + +

+ + {organization.bio && ( +

+ +

+ )} + +
+ {data === null ? ( + + ) : ( + <> + + + {formatCount(data.commitsCount)} + {' '} + + {data.commitsCount === 1 ? 'commit' : 'commits'} + + + + + {formatCount( + data.followersCount + followsDelta, + )} + {' '} + + {data.followersCount + followsDelta === 1 + ? 'follower' + : 'followers'} + + + + )} +
+
+ ); +} diff --git a/routes/web.php b/routes/web.php index 42cd4ddae..5599031f6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -87,6 +87,7 @@ use App\Http\Controllers\OrganizationLaneController; use App\Http\Controllers\OrganizationMemberController; use App\Http\Controllers\OrganizationMentionController; +use App\Http\Controllers\OrganizationPreviewController; use App\Http\Controllers\OrganizationProfileController; use App\Http\Controllers\OrganizationSitemapController; use App\Http\Controllers\OrgArchiveController; @@ -285,6 +286,10 @@ Route::get('mentions/organizations/{organization}', [OrganizationMentionController::class, 'show']) ->whereUuid('organization') ->name('organization-mention.show'); +Route::get('mentions/organizations/{organization}/preview', [OrganizationPreviewController::class, 'show']) + ->middleware('throttle:people') + ->whereUuid('organization') + ->name('organization-preview.show'); Route::get('mentions/{user}/preview', [UserPreviewController::class, 'show']) ->middleware('throttle:people') ->whereUuid('user') diff --git a/tests/Feature/Controllers/OrganizationPreviewControllerTest.php b/tests/Feature/Controllers/OrganizationPreviewControllerTest.php new file mode 100644 index 000000000..c94c659f9 --- /dev/null +++ b/tests/Feature/Controllers/OrganizationPreviewControllerTest.php @@ -0,0 +1,51 @@ +create(); + $organization = Organization::factory()->create(); + + $organization->followers()->attach([$viewer->id, User::factory()->create()->id]); + + $this->actingAs($viewer) + ->getJson("/mentions/organizations/{$organization->id}/preview") + ->assertOk() + ->assertExactJsonStructure(['commitsCount', 'followersCount', 'isFollowing']) + ->assertJsonPath('followersCount', 2) + ->assertJsonPath('isFollowing', true); +}); + +it('counts only the organization approved commits', function (): void { + $organization = Organization::factory()->create(); + + Commit::factory()->count(2)->create(['author_id' => $organization->id]); + Commit::factory()->pending()->create(['author_id' => $organization->id]); + Commit::factory()->create(['author_id' => $organization->id])->delete(); + Commit::factory()->create(); + + $this->getJson("/mentions/organizations/{$organization->id}/preview") + ->assertOk() + ->assertJsonPath('commitsCount', 2); +}); + +it('reports when the viewer does not follow the organization', function (bool $guest): void { + $organization = Organization::factory()->create(); + + $organization->followers()->attach(User::factory()->create()); + + ($guest ? $this : $this->actingAs(User::factory()->create())) + ->getJson("/mentions/organizations/{$organization->id}/preview") + ->assertOk() + ->assertJsonPath('followersCount', 1) + ->assertJsonPath('isFollowing', false); +})->with(['guest' => true, 'member' => false]); + +it('returns not found for unknown organizations', function (): void { + $this->getJson('/mentions/organizations/'.fake()->uuid().'/preview') + ->assertNotFound(); +}); diff --git a/tests/Unit/AuthorHover.test.tsx b/tests/Unit/AuthorHover.test.tsx index 184fafb52..eaf50de8f 100644 --- a/tests/Unit/AuthorHover.test.tsx +++ b/tests/Unit/AuthorHover.test.tsx @@ -179,6 +179,31 @@ test('profile preview stays open when its menu is dismissed under the mouse', () assert.ok(!stateChanges.includes(false)); }); +test('organization author previews the organization', () => { + showPreview = true; + const preview = UserPreview({ + user: { + type: 'organization', + id: 'laravel', + handle: 'laravel', + name: 'Laravel', + avatar_url: null, + bio: null, + location: null, + website: null, + lane: null, + }, + children: 'Laravel', + }); + showPreview = false; + + const panel = React.Children.toArray(preview.props.children)[1] as + | ReactElement> + | undefined; + + assert.equal(panel?.props['aria-label'], 'About @laravel'); +}); + test('suggested user row wraps the author in a user preview anchored to their avatar', () => { const user = { id: 'author',