Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 27 additions & 0 deletions app/Http/Controllers/OrganizationPreviewController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Commit;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;

final readonly class OrganizationPreviewController
{
/**
* What the hover card of an organization's commits adds to the profile they carry: how many commits it has published,
* how many follow it and whether the viewer does.
*/
public function show(#[CurrentUser] ?User $currentUser, Organization $organization): JsonResponse
{
return response()->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,
]);
}
}
210 changes: 171 additions & 39 deletions resources/js/components/user-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,32 @@ import {
followVisitOptions,
useFollow,
useFollowHover,
useIsFollowing,
} from '@/components/follow-button';
import {
LiveAvatarMarker,
LiveStreamCard,
} 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';
Expand All @@ -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 };
Expand All @@ -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<string, PreviewData>();
const cache = new Map<string, PreviewData | OrganizationPreviewData>();

async function fetchPreview(url: string): Promise<PreviewData> {
async function fetchPreview<
T extends PreviewData | OrganizationPreviewData = PreviewData,
>(url: string): Promise<T> {
const response = await fetch(url, {
headers: {
Accept: 'application/json',
Expand All @@ -75,12 +88,18 @@ async function fetchPreview(url: string): Promise<PreviewData> {
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<T extends PreviewData | OrganizationPreviewData = PreviewData>(
url: string,
): T | null {
return (cache.get(url) as T | undefined) ?? null;
}

export type Relationship = Pick<
PreviewData,
'isFollowing' | 'isMuting' | 'isBlocking'
Expand All @@ -94,8 +113,8 @@ export function usePreviewRelationship(
isEnabled: boolean,
): Relationship | null {
const url = userPreview(user).url;
const [relationship, setRelationship] = useState<Relationship | null>(
() => cache.get(url) ?? null,
const [relationship, setRelationship] = useState<Relationship | null>(() =>
cached<PreviewData>(url),
);

useEffect(() => {
Expand Down Expand Up @@ -149,32 +168,37 @@ export function UserPreview({
anchor,
onHoverChange,
}: {
/**
* An organization's commits have no hover card, so they only report hovering.
*/
user: User | Pick<User, 'handle'> | CommitAuthor;
userId?: string;
children: ReactNode;
className?: string;
anchor?: RefObject<HTMLElement | null>;
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<HTMLSpanElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const isMenuOpen = useRef(false);
const isPointerOver = useRef(false);
const [position, setPosition] = useState<Position | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [data, setData] = useState<PreviewData | null>(
() => 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);
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -296,10 +320,7 @@ export function UserPreview({
}

changeHover(true);

if (!('type' in user && user.type === 'organization')) {
scheduleOpen();
}
scheduleOpen();
}}
onPointerLeave={(event) => {
if (event.pointerType !== 'mouse') {
Expand All @@ -315,12 +336,12 @@ export function UserPreview({
</span>

{position &&
previewUser &&
previewHandle &&
createPortal(
<div
ref={previewRef}
role="dialog"
aria-label={`About @${previewUser.handle}`}
aria-label={`About @${previewHandle}`}
onPointerEnter={(event) => {
if (event.pointerType !== 'mouse') {
return;
Expand All @@ -347,24 +368,33 @@ export function UserPreview({
}}
data-test="user-preview"
>
<UserPreviewContent
user={previewUser}
data={data}
onMenuOpenChange={(isOpen) => {
isMenuOpen.current = isOpen;
clearTimeout(timer.current);

if (!isOpen && !isPointerOver.current) {
scheduleClose();
}
}}
onBlockChange={() =>
fetchPreview(previewUrl).then(
setData,
() => undefined,
)
}
/>
{organization ? (
<OrganizationPreviewContent
organization={organization}
data={data && !('user' in data) ? data : null}
/>
) : (
previewUser && (
<UserPreviewContent
user={previewUser}
data={userData}
onMenuOpenChange={(isOpen) => {
isMenuOpen.current = isOpen;
clearTimeout(timer.current);

if (!isOpen && !isPointerOver.current) {
scheduleClose();
}
}}
onBlockChange={() =>
fetchPreview(previewUrl).then(
setData,
() => undefined,
)
}
/>
)
)}
</div>,
document.body,
)}
Expand Down Expand Up @@ -600,3 +630,105 @@ function UserPreviewContent({
</div>
);
}

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 (
<div style={{ '--lane': lane } as CSSProperties}>
<div className="flex items-start justify-between gap-3">
<Link href={show(organization)} prefetch className="rounded-lg">
<OrganizationAvatar
organization={organization}
className="size-14 rounded-lg text-lg ring-2 ring-(--lane) ring-offset-2 ring-offset-popover"
pixels={112}
/>
</Link>

{data ? (
<OrganizationFollowButton
organization={organization}
isFollowing={data.isFollowing}
size="sm"
className="min-w-26"
/>
) : (
<span className="h-8 w-26 rounded-md bg-muted-foreground/15 motion-safe:animate-pulse" />
)}
</div>

<Link
href={show(organization)}
prefetch
className="mt-3 block truncate font-semibold hover:underline"
>
{organization.name}
</Link>
<p className="flex items-center gap-1.5 font-mono text-[13px] text-muted-foreground">
<span
aria-hidden
className="size-2 shrink-0 rounded-full bg-(--lane)"
/>
<span className="truncate">@{organization.handle}</span>
<span className="ml-1 shrink-0 rounded-sm bg-muted px-1.5 py-0.5 font-sans text-xs">
Organization
</span>
</p>

{organization.bio && (
<p className="mt-2 line-clamp-3 text-sm text-pretty">
<BioText bio={organization.bio} />
</p>
)}

<div className="mt-3 flex gap-4 text-sm">
{data === null ? (
<span className="my-0.5 h-4 w-40 rounded-sm bg-muted-foreground/15 motion-safe:animate-pulse" />
) : (
<>
<span>
<span
className="font-mono font-medium"
title={exactCountTitle(data.commitsCount)}
>
{formatCount(data.commitsCount)}
</span>{' '}
<span className="text-muted-foreground">
{data.commitsCount === 1 ? 'commit' : 'commits'}
</span>
</span>
<span>
<span
className="font-mono font-medium"
title={exactCountTitle(
data.followersCount + followsDelta,
)}
>
{formatCount(
data.followersCount + followsDelta,
)}
</span>{' '}
<span className="text-muted-foreground">
{data.followersCount + followsDelta === 1
? 'follower'
: 'followers'}
</span>
</span>
</>
)}
</div>
</div>
);
}
Loading