Files
eranos/src/components/discovery/CommunityMiniCard.tsx
T
mkfain af483d9989 Cut Approved from organization moderation, fix Featured load latency
Organizations now use a two-axis moderation model — featured and hidden.
The approval axis is campaign-only. Every Agora-tagged organization is
publicly visible by default; moderators curate by lifting orgs into the
Featured shelf or suppressing them with a Hidden label, nothing in
between.

Concretely:

- CommunityModerationMenu drops the Approve / Unapprove items. The org
  side never publishes those labels; useOrganizationModeration's
  mutate() rejects them defensively so a stray UI bug can't poison the
  label stream with axis-mixed events. The shared ModerationData type
  still tracks approvedCoords for symmetry with the campaign side, but
  the org UI never reads or writes it.

- /communities loses the in-flight 'Approved organizations' grid that
  was never finished and was conceptually wrong here. The moderator-only
  rail formerly called 'Pending review' is renamed 'Needs review' and
  re-derived as t:agora AND not featured AND not hidden.

Two perf fixes that should make Featured paint noticeably faster:

- The 200-event discovery query and the 2000-event label fold that
  power the moderator rails now live INSIDE ModeratorReviewSections.
  Non-moderator viewers (the overwhelming majority on /communities)
  never trigger either query — they used to fire at the page level
  whether they were used or not.

- Every CommunityMiniCard used to subscribe to useOrganizationModeration
  individually so it could overlay the Hidden badge and kebab. With
  18+ cards per page, that meant 18 component subscriptions to a
  query no non-mod cared about. The badge + kebab now live inside a
  single CommunityModerationOverlay that's gated on isMod up front
  and returns null for everyone else; non-mods never subscribe.

- staleTime on useOrganizationModeration and useFeaturedOrganizations
  bumped from 30s to 5m, with a 1-hour gcTime. Moderators feature and
  hide on human timescales, not seconds — repeat visits to /communities
  shouldn't pay a fresh relay round-trip every half-minute. Local
  changes still invalidate the keys explicitly so moderator actions
  show up immediately.

Also reverts two pieces of unrelated scope creep I built by mistake:
the 'Approved campaigns' section on the org detail page and the
moderator kebab on the campaign detail page. Those weren't asked for —
they were chasing a misread of an earlier instruction.

NIP.md updated to reflect the campaign-vs-org axis split and the new
'Needs review' surface name.
2026-05-21 21:43:48 -05:00

133 lines
5.2 KiB
TypeScript

import { Link } from 'react-router-dom';
import { Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { CommunityModerationOverlay } from '@/components/CommunityModerationMenu';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import {
COMMUNITY_DEFINITION_KIND,
type ParsedCommunity,
} from '@/lib/communityUtils';
interface CommunityMiniCardProps {
community: ParsedCommunity;
className?: string;
}
/**
* Compact, fixed-width community card for the Discover page's "Find your
* people" shelf. Whole card is a `<Link>` to the community's naddr-based
* route. Visual hierarchy:
*
* - 16:9 banner image (or warm gradient + Users icon when missing),
* - bold community name,
* - one-line description,
* - founder avatar + display name in a muted row.
*
* Kept narrow enough to fit ~4 cards across a desktop content column.
*
* Moderators (Team Soapbox pack members) see a kebab menu overlaid on the
* banner exposing the Feature / Hide actions plus a Hidden badge when the
* org is currently hidden. Non-moderators see no overlay — the whole
* moderation pipeline (including the heavy `useOrganizationModeration`
* query) is bypassed for them so grids of dozens of cards don't fan out
* a per-card cache subscription on every viewer.
*/
export function CommunityMiniCard({ community, className }: CommunityMiniCardProps) {
const founder = useAuthor(community.founderPubkey);
const banner = sanitizeUrl(community.image);
const founderName =
founder.data?.metadata?.display_name ||
founder.data?.metadata?.name ||
genUserName(community.founderPubkey);
const founderAvatar = sanitizeUrl(founder.data?.metadata?.picture);
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
pubkey: community.founderPubkey,
identifier: community.dTag,
});
return (
<Link
to={`/${naddr}`}
className={cn(
'group block w-64 shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5',
className,
)}
>
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
<div className="relative aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
{banner ? (
<img
src={banner}
alt=""
loading="lazy"
className="absolute inset-0 size-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<Users className="size-10 text-primary/40" />
</div>
)}
{/* Moderator overlay (Hidden badge + kebab). Renders `null` for
non-moderators, which is why this component owns the
`useOrganizationModeration` subscription rather than the
card — keeps non-mod grids free of the heavy label query. */}
<CommunityModerationOverlay coord={community.aTag} organizationName={community.name} />
</div>
<div className="flex flex-col gap-2 p-3.5 flex-1">
<h3 className="font-semibold leading-tight text-sm tracking-tight line-clamp-1">
{community.name}
</h3>
{community.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-snug">
{community.description}
</p>
)}
<div className="flex items-center gap-2 mt-auto pt-1.5">
{founderAvatar ? (
<img
src={founderAvatar}
alt=""
loading="lazy"
className="size-5 rounded-full object-cover"
/>
) : (
<div className="size-5 rounded-full bg-secondary" />
)}
<span className="text-[11px] text-muted-foreground truncate">
by {founderName}
</span>
</div>
</div>
</Card>
</Link>
);
}
/** Skeleton placeholder matching `CommunityMiniCard` dimensions. */
export function CommunityMiniCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('w-64 shrink-0 rounded-xl overflow-hidden', className)}>
<Card className="border-border/70 shadow-sm h-full flex flex-col">
<Skeleton className="aspect-[16/9] w-full rounded-none" />
<div className="flex flex-col gap-2 p-3.5 flex-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<div className="flex items-center gap-2 mt-auto pt-1.5">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
</div>
</Card>
</div>
);
}