af483d9989
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.
133 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|