Moderator-curated featured organizations
Replace the hardcoded FEATURED_ORGANIZATION_AUTHORS allowlist with the same NIP-32 label flow that curates featured campaigns: Team Soapbox pack members publish kind 1985 labels in the agora.moderation namespace tagging an organization's 34550:<pubkey>:<d> coordinate as featured, hidden, or approved, and useFeaturedOrganizations folds those labels into the /communities Featured shelf. The campaign and organization label streams share a single namespace and a single moderator pack — they're separated purely by which kind prefix the 'a' tag carries. To keep that contract enforced in one place, the constants, types, and folding logic are now in src/lib/agoraModeration.ts; useCampaignModeration and the new useOrganizationModeration both call foldModerationLabels with their respective kind. The campaign hook's external surface (AGORA_MODERATION_NAMESPACE, ModerationLabel, CampaignModerationData) is preserved via re-exports so existing call sites don't move. Moderators see a CommunityModerationMenu kebab overlaid on every CommunityMiniCard exposing approve/unapprove, hide/unhide, and feature/unfeature. Mounting reads moderation state once per page from the shared TanStack cache, mirroring CampaignCard. Non-moderators see no overlay (the menu returns null) and no card affordances change. The 'My organizations' shelf intentionally ignores moderation — a user's own founded, moderated, or followed organizations always render regardless of label state. Only the Featured shelf consumes the curation rollup. The Featured grid is uncapped: moderators control how many orgs surface by labeling, and ordering follows the recency of each 'featured' label so re-publishing bumps an org to the top. NIP.md's 'Campaign Moderation Labels' section is renamed to 'Agora Moderation Labels' and documents the kind-34550 coord form and the 'My organizations ignores moderation' rule. Note: existing surfaced organizations will disappear from the shelf until a moderator publishes featured labels for them.
This commit is contained in:
@@ -463,9 +463,16 @@ The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned
|
||||
- **No category, no topics:** kind 33863 events MUST NOT carry `t` tags or NIP-32 category labels in any `agora.*` namespace. Campaigns are individual stories; discovery happens via search (NIP-50 against title/summary/content), country (`#i`), and moderator curation (below).
|
||||
- **Migration:** kind 33863 has no relationship to any earlier campaign kind. Clients MUST NOT read, merge, or migrate events of any other kind into the kind 33863 namespace.
|
||||
|
||||
### Campaign Moderation Labels
|
||||
### Agora Moderation Labels
|
||||
|
||||
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`) via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The campaign event itself is never modified — surfacing is purely a client-side rollup of label events.
|
||||
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), and which kind 34550 organizations appear in the Featured shelf on `/communities`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
|
||||
|
||||
Campaigns and organizations share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the two streams is the kind prefix on the `a` tag of each label:
|
||||
|
||||
- `33863:<author-pubkey>:<d>` — campaign (kind 33863, see "Open Campaigns" above).
|
||||
- `34550:<author-pubkey>:<d>` — organization (kind 34550, NIP-72 community definition).
|
||||
|
||||
A client surfacing campaigns MUST filter folded labels to those whose `a` tag starts with `33863:`. A client surfacing organizations MUST filter to `34550:`. Mixing the two streams would let a moderator's `featured` label on a campaign appear to feature an unrelated organization with the same `d` tag, or vice versa.
|
||||
|
||||
#### Namespace
|
||||
|
||||
@@ -480,22 +487,30 @@ Each label event carries the namespace twice, per NIP-32:
|
||||
|
||||
#### Label values
|
||||
|
||||
Three independent axes; the newest moderator-signed label per axis per campaign wins.
|
||||
Three independent axes; the newest moderator-signed label per axis per coordinate wins.
|
||||
|
||||
| Axis | Values | Meaning |
|
||||
|----------|---------------------------|-------------------------------------------------------------------------|
|
||||
| approval | `approved`, `unapproved` | `approved` allows the campaign on `/` and Discover. `unapproved` retracts a previous approval. |
|
||||
| hide | `hidden`, `unhidden` | `hidden` suppresses the campaign everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
|
||||
| featured | `featured`, `unfeatured` | `featured` places the campaign in the hand-picked Featured row on `/`. `unfeatured` retracts. |
|
||||
| approval | `approved`, `unapproved` | `approved` allows the campaign/organization on its discovery surfaces. `unapproved` retracts a previous approval. |
|
||||
| hide | `hidden`, `unhidden` | `hidden` suppresses the campaign/organization everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
|
||||
| featured | `featured`, `unfeatured` | `featured` places the campaign in the hand-picked Featured row on `/`, or the organization in the Featured shelf on `/communities`. `unfeatured` retracts. |
|
||||
|
||||
Surfacing rules (hide always wins):
|
||||
|
||||
**Campaigns**
|
||||
|
||||
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first. Featured is independent of Approved at the protocol level; a campaign may be featured without being approved (the home page treats Featured and Approved as deduplicated bins, with Featured taking precedence).
|
||||
- **Community Campaigns grid on `/`** — iff approved, not hidden, and not featured (featured campaigns get their own row above).
|
||||
- **Discover shelf** — iff approved AND not hidden.
|
||||
- **Moderator-only "Pending"** — iff neither approved nor hidden.
|
||||
- **Moderator-only "Hidden"** — iff hidden.
|
||||
|
||||
**Organizations**
|
||||
|
||||
- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first.
|
||||
- **"My organizations" shelf on `/communities`** — intentionally ignores all moderation labels. A user's own founded, moderated, or followed organizations always render regardless of label state.
|
||||
- **Hide enforcement on other organization discovery surfaces** — clients SHOULD suppress `hidden` organizations from any future "All organizations" / browse surface for non-moderators. Moderators MAY see hidden organizations with a "Hidden" treatment so they can unhide.
|
||||
|
||||
#### Event Structure
|
||||
|
||||
```json
|
||||
@@ -511,14 +526,27 @@ Surfacing rules (hide always wins):
|
||||
}
|
||||
```
|
||||
|
||||
A `featured` label has the same shape with `["l", "featured", "agora.moderation"]` and `["alt", "Campaign moderation: featured"]`.
|
||||
An organization label has the same shape with a kind 34550 `a` tag:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "featured", "agora.moderation"],
|
||||
["a", "34550:<author-pubkey>:<organization-d-tag>"],
|
||||
["alt", "Organization moderation: featured"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Required tags:
|
||||
|
||||
- `L` set to `agora.moderation`.
|
||||
- `l` with the label value as the 2nd element and `agora.moderation` as the 3rd.
|
||||
- `a` referencing the campaign coordinate `33863:<pubkey>:<d>`.
|
||||
- `alt` (NIP-31) — clients without label support will display this string.
|
||||
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization).
|
||||
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured` or `Organization moderation: featured`) so non-Agora clients can read it.
|
||||
|
||||
#### Trust Model
|
||||
|
||||
@@ -533,9 +561,11 @@ d-tag: k4p5w0n22suf
|
||||
The pack `p` tags are the authoritative moderator list. Clients MUST pin `authors:` on their label REQ to the pack `p` tags; events from non-pack authors MUST be ignored. This means:
|
||||
|
||||
- Self-approval is impossible unless the pack author has added you.
|
||||
- A moderator removed from the pack immediately loses moderation authority — campaigns kept alive only by their labels return to "pending" until another moderator approves them.
|
||||
- A moderator removed from the pack immediately loses moderation authority — campaigns/organizations kept alive only by their labels return to "pending" until another moderator approves them.
|
||||
- The pack author (single signer) can reset the entire moderator roster by republishing the pack.
|
||||
|
||||
The same moderator set governs both campaign and organization labels. Carving out per-surface moderator subsets is out of scope; clients that need that distinction would have to introduce a second follow pack and a second label namespace.
|
||||
|
||||
#### Querying
|
||||
|
||||
Step 1 — fetch the pack:
|
||||
@@ -560,13 +590,14 @@ Step 2 — fetch label events from pack members in the namespace:
|
||||
}
|
||||
```
|
||||
|
||||
Step 3 — fold by `(campaign-coord, axis)`, latest-`created_at`-wins. Then fetch only the approved-and-not-hidden campaign coordinates with one filter per author (bundled in a single REQ).
|
||||
Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the relevant kind prefix (`33863:` for campaigns or `34550:` for organizations). Then fetch the targeted events themselves — one filter per author (bundled in a single REQ) keyed by their d-tags.
|
||||
|
||||
#### Client Behavior
|
||||
|
||||
- Clients SHOULD render approve/hide controls only for users whose pubkey appears in the pack.
|
||||
- Clients MAY display "Hidden" badges on hidden campaigns when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
|
||||
- Clients SHOULD render approve/hide/feature controls only for users whose pubkey appears in the pack.
|
||||
- Clients MAY display "Hidden" badges on hidden campaigns/organizations when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
|
||||
- Non-moderator authors viewing the homepage SHOULD see their own pending campaigns in a separate explained section so they understand why their campaign isn't yet on the homepage. The campaign URL remains live and donatable regardless of moderation state.
|
||||
- Organization authors are not shown an equivalent "pending" surface today — organizations are visible at their NIP-19 route regardless of moderation, and the only moderation surface is the Featured shelf.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, EyeOff, Eye, Loader2, MoreHorizontal, ShieldCheck, ShieldOff, Sparkles, SparklesIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { ModerationLabel } from '@/lib/agoraModeration';
|
||||
|
||||
interface CommunityModerationMenuProps {
|
||||
/** The organization's `34550:<pubkey>:<d>` coordinate. */
|
||||
coord: string;
|
||||
/** Visible name for the organization (for toast feedback). */
|
||||
organizationName: string;
|
||||
/** Whether the organization is currently approved. */
|
||||
isApproved: boolean;
|
||||
/** Whether the organization is currently hidden. */
|
||||
isHidden: boolean;
|
||||
/** Whether the organization is currently featured. */
|
||||
isFeatured: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-card kebab menu exposing the six moderation actions for an
|
||||
* organization:
|
||||
*
|
||||
* Approve / Unapprove (axis = approval)
|
||||
* Hide / Unhide (axis = hide)
|
||||
* Feature / Unfeature (axis = featured)
|
||||
*
|
||||
* Renders `null` for users who are not Team Soapbox pack members. Sits
|
||||
* inside the clickable `CommunityMiniCard` `<Link>`, so the trigger
|
||||
* swallows its own click and the dropdown content stops propagation —
|
||||
* otherwise every menu interaction would navigate to the organization
|
||||
* detail page.
|
||||
*
|
||||
* Mirrors `CampaignModerationMenu`; the only differences are the coord
|
||||
* prefix (`34550:` vs `33863:`), the toast copy ("organization" vs
|
||||
* "campaign"), and the mutation hook.
|
||||
*/
|
||||
export function CommunityModerationMenu({
|
||||
coord,
|
||||
organizationName,
|
||||
isApproved,
|
||||
isHidden,
|
||||
isFeatured,
|
||||
className,
|
||||
}: CommunityModerationMenuProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const { moderate } = useOrganizationModeration();
|
||||
const { toast } = useToast();
|
||||
const [busy, setBusy] = useState<ModerationLabel | null>(null);
|
||||
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
if (!isMod) return null;
|
||||
|
||||
const runAction = async (action: ModerationLabel, verbPast: string) => {
|
||||
if (busy) return;
|
||||
setBusy(action);
|
||||
try {
|
||||
await moderate.mutateAsync({ coord, action });
|
||||
toast({ title: verbPast, description: organizationName });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
toast({
|
||||
title: `Failed to ${action}`,
|
||||
description: message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Moderate organization"
|
||||
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Moderator actions
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{isApproved ? (
|
||||
<DropdownMenuItem onClick={() => runAction('unapproved', 'Removed approval')}>
|
||||
<ShieldOff className="h-4 w-4 mr-2" />
|
||||
Unapprove
|
||||
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<Check className="h-3 w-3" /> Approved
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => runAction('approved', 'Approved organization')}>
|
||||
<ShieldCheck className="h-4 w-4 mr-2" />
|
||||
Approve
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isHidden ? (
|
||||
<DropdownMenuItem onClick={() => runAction('unhidden', 'Unhidden')}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Unhide
|
||||
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<Check className="h-3 w-3" /> Hidden
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() => runAction('hidden', 'Hidden')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{isFeatured ? (
|
||||
<DropdownMenuItem onClick={() => runAction('unfeatured', 'Removed from featured')}>
|
||||
<SparklesIcon className="h-4 w-4 mr-2" />
|
||||
Unfeature
|
||||
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<Check className="h-3 w-3" /> Featured
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => runAction('featured', 'Featured organization')}>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Feature
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { EyeOff, Users } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { CommunityModerationMenu } from '@/components/CommunityModerationMenu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -29,6 +32,10 @@ interface CommunityMiniCardProps {
|
||||
* - 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 Approve / Hide / Feature actions. Non-moderators see
|
||||
* no overlay at all because `CommunityModerationMenu` returns `null`.
|
||||
*/
|
||||
export function CommunityMiniCard({ community, className }: CommunityMiniCardProps) {
|
||||
const founder = useAuthor(community.founderPubkey);
|
||||
@@ -45,6 +52,15 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
|
||||
identifier: community.dTag,
|
||||
});
|
||||
|
||||
// Per-card moderation state. Reads from the shared TanStack cache; the
|
||||
// underlying query is fetched once per page render no matter how many
|
||||
// cards mount this hook.
|
||||
const { data: moderation } = useOrganizationModeration();
|
||||
const coord = community.aTag;
|
||||
const isApproved = moderation.approvedCoords.has(coord);
|
||||
const isHidden = moderation.hiddenCoords.has(coord);
|
||||
const isFeatured = moderation.featuredCoords.has(coord);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/${naddr}`}
|
||||
@@ -67,6 +83,28 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
|
||||
<Users className="size-10 text-primary/40" />
|
||||
</div>
|
||||
)}
|
||||
{/* Moderator overlay. Mirrors `CampaignCard`: Hidden badge sits to
|
||||
the left of the kebab so moderators can see the state at a
|
||||
glance. Both render `null` for non-moderators or unlabelled
|
||||
orgs. */}
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1.5">
|
||||
{isHidden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30 h-6 px-1.5 text-[10px]"
|
||||
>
|
||||
<EyeOff className="size-3 mr-1" />
|
||||
Hidden
|
||||
</Badge>
|
||||
)}
|
||||
<CommunityModerationMenu
|
||||
coord={coord}
|
||||
organizationName={community.name}
|
||||
isApproved={isApproved}
|
||||
isHidden={isHidden}
|
||||
isFeatured={isFeatured}
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
|
||||
@@ -1,130 +1,31 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { useCampaignModerators } from './useCampaignModerators';
|
||||
import { CAMPAIGN_KIND } from '@/lib/campaign';
|
||||
import {
|
||||
AGORA_MODERATION_NAMESPACE,
|
||||
EMPTY_MODERATION_DATA,
|
||||
LABEL_KIND,
|
||||
type ModerationData,
|
||||
type ModerationLabel,
|
||||
type ModerationState,
|
||||
foldModerationLabels,
|
||||
} from '@/lib/agoraModeration';
|
||||
|
||||
/** NIP-32 label kind. */
|
||||
const LABEL_KIND = 1985;
|
||||
/** Label namespace for Agora's moderation labels. */
|
||||
export const AGORA_MODERATION_NAMESPACE = 'agora.moderation';
|
||||
|
||||
/** The six possible label values in the moderation namespace. */
|
||||
export type ModerationLabel =
|
||||
| 'approved'
|
||||
| 'unapproved'
|
||||
| 'hidden'
|
||||
| 'unhidden'
|
||||
| 'featured'
|
||||
| 'unfeatured';
|
||||
|
||||
/** A single label event narrowed to its decision axis. */
|
||||
interface AxisDecision {
|
||||
/** Latest label observed on this axis. */
|
||||
label: ModerationLabel;
|
||||
/** Author of the latest label. */
|
||||
pubkey: string;
|
||||
/** Created-at of the latest label. */
|
||||
createdAt: number;
|
||||
}
|
||||
// Re-exports for existing import sites. The namespace constant and the
|
||||
// `ModerationLabel` type are imported from this module by the campaign
|
||||
// moderation menu and other surfaces; keep those exports stable so the
|
||||
// shared-module refactor stays a no-op for callers.
|
||||
export { AGORA_MODERATION_NAMESPACE };
|
||||
export type { ModerationLabel };
|
||||
|
||||
/** Per-campaign rollup of approval + hide + featured state. */
|
||||
export interface CampaignModerationState {
|
||||
approval?: AxisDecision; // `approved` or `unapproved`
|
||||
hide?: AxisDecision; // `hidden` or `unhidden`
|
||||
featured?: AxisDecision; // `featured` or `unfeatured`
|
||||
}
|
||||
export type CampaignModerationState = ModerationState;
|
||||
|
||||
export interface CampaignModerationData {
|
||||
/** Map of `30223:<pubkey>:<d>` -> rollup. */
|
||||
byCoord: Map<string, CampaignModerationState>;
|
||||
/** Coordinates where the latest approval label is `approved`. */
|
||||
approvedCoords: Set<string>;
|
||||
/** Coordinates where the latest hide label is `hidden`. */
|
||||
hiddenCoords: Set<string>;
|
||||
/** Coordinates where the latest featured label is `featured`. */
|
||||
featuredCoords: Set<string>;
|
||||
/**
|
||||
* Map of `coord` -> `created_at` of the latest `featured` label.
|
||||
* Used to sort the home-page featured row newest-first.
|
||||
*/
|
||||
featuredOrder: Map<string, number>;
|
||||
/** Pubkeys that were considered moderators when the query ran. */
|
||||
moderators: string[];
|
||||
}
|
||||
|
||||
const EMPTY_DATA: CampaignModerationData = {
|
||||
byCoord: new Map(),
|
||||
approvedCoords: new Set(),
|
||||
hiddenCoords: new Set(),
|
||||
featuredCoords: new Set(),
|
||||
featuredOrder: new Map(),
|
||||
moderators: [],
|
||||
};
|
||||
|
||||
/** True if a label value belongs to the approval axis. */
|
||||
function isApprovalLabel(value: string): value is 'approved' | 'unapproved' {
|
||||
return value === 'approved' || value === 'unapproved';
|
||||
}
|
||||
|
||||
/** True if a label value belongs to the hide axis. */
|
||||
function isHideLabel(value: string): value is 'hidden' | 'unhidden' {
|
||||
return value === 'hidden' || value === 'unhidden';
|
||||
}
|
||||
|
||||
/** True if a label value belongs to the featured axis. */
|
||||
function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' {
|
||||
return value === 'featured' || value === 'unfeatured';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a flat list of label events into per-coordinate rollups by axis.
|
||||
* The newest event per `(coord, axis)` wins. Events not addressing a
|
||||
* campaign coordinate or carrying a value outside the namespace are dropped.
|
||||
*/
|
||||
function foldLabelEvents(events: NostrEvent[], moderators: string[]): CampaignModerationData {
|
||||
const byCoord = new Map<string, CampaignModerationState>();
|
||||
|
||||
for (const event of events) {
|
||||
const value = event.tags.find(([n, , ns]) => n === 'l' && ns === AGORA_MODERATION_NAMESPACE)?.[1];
|
||||
if (!value) continue;
|
||||
const aTag = event.tags.find(([n, v]) => n === 'a' && typeof v === 'string' && v.startsWith(`${CAMPAIGN_KIND}:`))?.[1];
|
||||
if (!aTag) continue;
|
||||
|
||||
const state = byCoord.get(aTag) ?? {};
|
||||
if (isApprovalLabel(value)) {
|
||||
if (!state.approval || event.created_at > state.approval.createdAt) {
|
||||
state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
} else if (isHideLabel(value)) {
|
||||
if (!state.hide || event.created_at > state.hide.createdAt) {
|
||||
state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
} else if (isFeaturedLabel(value)) {
|
||||
if (!state.featured || event.created_at > state.featured.createdAt) {
|
||||
state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
}
|
||||
byCoord.set(aTag, state);
|
||||
}
|
||||
|
||||
const approvedCoords = new Set<string>();
|
||||
const hiddenCoords = new Set<string>();
|
||||
const featuredCoords = new Set<string>();
|
||||
const featuredOrder = new Map<string, number>();
|
||||
for (const [coord, state] of byCoord) {
|
||||
if (state.approval?.label === 'approved') approvedCoords.add(coord);
|
||||
if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
|
||||
if (state.featured?.label === 'featured') {
|
||||
featuredCoords.add(coord);
|
||||
featuredOrder.set(coord, state.featured.createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators };
|
||||
}
|
||||
/** Surface-scoped alias so existing callers keep working. */
|
||||
export type CampaignModerationData = ModerationData;
|
||||
|
||||
/**
|
||||
* Fetches and folds campaign-moderation label events authored by Team
|
||||
@@ -154,7 +55,8 @@ export function useCampaignModeration() {
|
||||
// an empty `authors:` filter (which would return everything matching the
|
||||
// namespace from any author and break our trust model — see AGENTS.md).
|
||||
// Once moderators arrives empty, the query runs and immediately resolves
|
||||
// to EMPTY_DATA — no rendering can promote a campaign without a moderator.
|
||||
// to EMPTY_MODERATION_DATA — no rendering can promote a campaign without
|
||||
// a moderator.
|
||||
const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
|
||||
|
||||
const moderationQuery = useQuery({
|
||||
@@ -162,7 +64,7 @@ export function useCampaignModeration() {
|
||||
enabled: moderators !== undefined,
|
||||
queryFn: async ({ signal }): Promise<CampaignModerationData> => {
|
||||
if (!moderators || moderators.length === 0) {
|
||||
return { ...EMPTY_DATA, moderators: [] };
|
||||
return { ...EMPTY_MODERATION_DATA, moderators: [] };
|
||||
}
|
||||
const events = await nostr.query(
|
||||
[
|
||||
@@ -179,7 +81,7 @@ export function useCampaignModeration() {
|
||||
],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
|
||||
);
|
||||
return foldLabelEvents(events, moderators);
|
||||
return foldModerationLabels(events, moderators, CAMPAIGN_KIND);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@@ -207,7 +109,7 @@ export function useCampaignModeration() {
|
||||
});
|
||||
|
||||
return {
|
||||
data: moderationQuery.data ?? EMPTY_DATA,
|
||||
data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
|
||||
isPending: moderationQuery.isPending,
|
||||
isLoading: moderationQuery.isLoading,
|
||||
isReady: moderationQuery.isSuccess,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import {
|
||||
COMMUNITY_DEFINITION_KIND,
|
||||
parseCommunityEvent,
|
||||
@@ -9,56 +10,122 @@ import {
|
||||
} from '@/lib/communityUtils';
|
||||
import { dedupeAddressableLatest } from '@/lib/addressableEvents';
|
||||
|
||||
/**
|
||||
* Hand-curated list of featured organization authors.
|
||||
*
|
||||
* We query all organizations authored by these accounts in one relay request,
|
||||
* then latest-wins dedupe addressable revisions client-side.
|
||||
*/
|
||||
export const FEATURED_ORGANIZATION_AUTHORS = [
|
||||
'932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
] as const;
|
||||
|
||||
export interface FeaturedOrganization {
|
||||
community: ParsedCommunity;
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch featured organizations by author.
|
||||
* Parse a kind 34550 coordinate string (`34550:<pubkey>:<d>`) into its
|
||||
* pubkey and d-tag components. Returns `null` for malformed coords so the
|
||||
* caller can skip them without crashing the query.
|
||||
*/
|
||||
function parseCoord(coord: string): { pubkey: string; dTag: string } | null {
|
||||
const colon1 = coord.indexOf(':');
|
||||
if (colon1 < 0) return null;
|
||||
const colon2 = coord.indexOf(':', colon1 + 1);
|
||||
if (colon2 < 0) return null;
|
||||
const pubkey = coord.slice(colon1 + 1, colon2);
|
||||
const dTag = coord.slice(colon2 + 1);
|
||||
if (!pubkey || !dTag) return null;
|
||||
return { pubkey, dTag };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the featured organizations selected by Agora moderators.
|
||||
*
|
||||
* One author-filtered query is more reliable than pinning individual event IDs
|
||||
* because kind 34550 definitions are addressable and can be revised.
|
||||
* Featured selection rides the shared `agora.moderation` namespace (kind
|
||||
* 1985 NIP-32 labels): a moderator publishes a `featured` label tagging
|
||||
* the organization's `34550:<pubkey>:<d>` coordinate, and the home/communities
|
||||
* page surfaces it here. A `hidden` label on the same coord always wins —
|
||||
* even if `featured` is set, a hidden org never reaches this list.
|
||||
*
|
||||
* Coords are grouped by author into one filter per unique author, then OR'd
|
||||
* into a single `nostr.query` call. For the typical case (a small handful
|
||||
* of featured orgs across a few authors) this stays one relay round-trip.
|
||||
* Results are sorted by the `created_at` of the latest `featured` label so
|
||||
* moderators control ordering by simply re-publishing the label to bump an
|
||||
* org to the top.
|
||||
*/
|
||||
export function useFeaturedOrganizations() {
|
||||
const { nostr } = useNostr();
|
||||
const { data: moderation, isReady: moderationReady } = useOrganizationModeration();
|
||||
|
||||
// Derive the curated coord set: featured minus hidden, sorted by the
|
||||
// recency of the `featured` label. No cap — the moderator pack controls
|
||||
// how many orgs surface.
|
||||
const featuredCoords = moderationReady
|
||||
? Array.from(moderation.featuredCoords)
|
||||
.filter((coord) => !moderation.hiddenCoords.has(coord))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(moderation.featuredOrder.get(b) ?? 0) -
|
||||
(moderation.featuredOrder.get(a) ?? 0),
|
||||
)
|
||||
: [];
|
||||
|
||||
// Include the coord set in the query key so the cache busts whenever the
|
||||
// curation changes (mutations invalidate `featured-organizations` too, so
|
||||
// there's no one-tick gap).
|
||||
const featuredCoordsKey = featuredCoords.join('|');
|
||||
|
||||
return useQuery<FeaturedOrganization[]>({
|
||||
queryKey: ['featured-organizations', FEATURED_ORGANIZATION_AUTHORS.join(',')],
|
||||
queryKey: ['featured-organizations', featuredCoordsKey],
|
||||
enabled: moderationReady,
|
||||
queryFn: async ({ signal }) => {
|
||||
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
if (featuredCoords.length === 0) return [];
|
||||
|
||||
const events = await nostr.query(
|
||||
[{
|
||||
// Group coord d-tags by author so we can issue one filter per author
|
||||
// instead of one per coord. Most featured orgs cluster around a few
|
||||
// founders, so this typically collapses to a single-digit number of
|
||||
// filters in one round-trip.
|
||||
const dTagsByAuthor = new Map<string, string[]>();
|
||||
for (const coord of featuredCoords) {
|
||||
const parsed = parseCoord(coord);
|
||||
if (!parsed) continue;
|
||||
const bucket = dTagsByAuthor.get(parsed.pubkey);
|
||||
if (bucket) {
|
||||
bucket.push(parsed.dTag);
|
||||
} else {
|
||||
dTagsByAuthor.set(parsed.pubkey, [parsed.dTag]);
|
||||
}
|
||||
}
|
||||
if (dTagsByAuthor.size === 0) return [];
|
||||
|
||||
const filters: NostrFilter[] = Array.from(dTagsByAuthor.entries()).map(
|
||||
([pubkey, dTags]) => ({
|
||||
kinds: [COMMUNITY_DEFINITION_KIND],
|
||||
authors: [...FEATURED_ORGANIZATION_AUTHORS],
|
||||
limit: 60,
|
||||
}],
|
||||
{ signal: combinedSignal },
|
||||
authors: [pubkey],
|
||||
'#d': dTags,
|
||||
}),
|
||||
);
|
||||
|
||||
const entries: FeaturedOrganization[] = [];
|
||||
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
const events = await nostr.query(filters, { signal: combinedSignal });
|
||||
|
||||
// Latest-wins dedupe of addressable revisions, then index by coord so
|
||||
// we can return them in the moderator-controlled `featuredOrder`.
|
||||
const byCoord = new Map<string, FeaturedOrganization>();
|
||||
for (const event of dedupeAddressableLatest(events)) {
|
||||
const community = parseCommunityEvent(event);
|
||||
if (!community) continue;
|
||||
entries.push({ community, event });
|
||||
byCoord.set(community.aTag, { community, event });
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.event.created_at - a.event.created_at);
|
||||
|
||||
return entries;
|
||||
// Preserve the moderator's chosen ordering by walking `featuredCoords`
|
||||
// (already sorted newest-label-first) and emitting entries in that
|
||||
// order. Drops coords whose underlying 34550 event we couldn't fetch
|
||||
// (e.g. it was deleted or never reached the queried relays).
|
||||
const ordered: FeaturedOrganization[] = [];
|
||||
for (const coord of featuredCoords) {
|
||||
const entry = byCoord.get(coord);
|
||||
if (entry) ordered.push(entry);
|
||||
}
|
||||
return ordered;
|
||||
},
|
||||
// 5 minutes — featured list is hand-curated, doesn't churn.
|
||||
staleTime: 5 * 60_000,
|
||||
// Short — featured rows churn whenever moderators act, and the
|
||||
// moderation hook invalidates this key on mutation anyway. Keep the
|
||||
// staleness floor tight so background refetches catch external updates.
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { useCampaignModerators } from './useCampaignModerators';
|
||||
import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
|
||||
import {
|
||||
AGORA_MODERATION_NAMESPACE,
|
||||
EMPTY_MODERATION_DATA,
|
||||
LABEL_KIND,
|
||||
type ModerationData,
|
||||
type ModerationLabel,
|
||||
foldModerationLabels,
|
||||
} from '@/lib/agoraModeration';
|
||||
|
||||
/** Surface-scoped alias so call sites read naturally. */
|
||||
export type OrganizationModerationData = ModerationData;
|
||||
|
||||
/**
|
||||
* Fetches and folds organization-moderation label events authored by Team
|
||||
* Soapbox members. Returns approval / hide / featured rollups per community
|
||||
* coordinate (`34550:<pubkey>:<d>`).
|
||||
*
|
||||
* Organizations ride the same `agora.moderation` namespace and the same
|
||||
* moderator pack as campaigns; we just narrow the fold to labels whose `a`
|
||||
* tag points at a kind 34550 coordinate. The relay-side query is identical
|
||||
* to the campaign side (we fetch every namespace-tagged label authored by
|
||||
* moderators) — the surface separation is purely client-side.
|
||||
*
|
||||
* **Display rule** consumers should follow:
|
||||
* - Featured shelf on `/communities` iff
|
||||
* `featuredCoords.has(coord) && !hiddenCoords.has(coord)`.
|
||||
* - Future "All organizations" / discovery surfaces iff
|
||||
* `!hiddenCoords.has(coord)` for non-moderators; moderators may see hidden
|
||||
* orgs with a dimmed treatment.
|
||||
* - "My organizations" intentionally ignores moderation — a user's own
|
||||
* founded / moderated / followed orgs always render regardless of label.
|
||||
* - Featured is independent of Approved at the protocol level; hide always
|
||||
* wins over both.
|
||||
*
|
||||
* The mutation `moderate({ coord, action })` publishes a single kind 1985
|
||||
* event labeling one organization in the `agora.moderation` namespace.
|
||||
* Callers MUST be in the moderator set or the relay-side `authors:` filter
|
||||
* on read will silently ignore the new event.
|
||||
*/
|
||||
export function useOrganizationModeration() {
|
||||
const { nostr } = useNostr();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
|
||||
// Same gating as the campaign hook: never fire with an empty `authors:`
|
||||
// filter, since that would return labels from any author and break the
|
||||
// trust model.
|
||||
const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
|
||||
|
||||
const moderationQuery = useQuery({
|
||||
queryKey: ['organization-moderation', moderatorsKey],
|
||||
enabled: moderators !== undefined,
|
||||
queryFn: async ({ signal }): Promise<OrganizationModerationData> => {
|
||||
if (!moderators || moderators.length === 0) {
|
||||
return { ...EMPTY_MODERATION_DATA, moderators: [] };
|
||||
}
|
||||
const events = await nostr.query(
|
||||
[
|
||||
{
|
||||
kinds: [LABEL_KIND],
|
||||
authors: moderators,
|
||||
'#L': [AGORA_MODERATION_NAMESPACE],
|
||||
limit: 2000,
|
||||
},
|
||||
],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
|
||||
);
|
||||
return foldModerationLabels(events, moderators, COMMUNITY_DEFINITION_KIND);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const moderate = useMutation({
|
||||
mutationFn: async ({ coord, action }: { coord: string; action: ModerationLabel }) => {
|
||||
if (!coord.startsWith(`${COMMUNITY_DEFINITION_KIND}:`)) {
|
||||
throw new Error(`Coordinate must start with ${COMMUNITY_DEFINITION_KIND}:`);
|
||||
}
|
||||
return publishEvent({
|
||||
kind: LABEL_KIND,
|
||||
content: '',
|
||||
tags: [
|
||||
['L', AGORA_MODERATION_NAMESPACE],
|
||||
['l', action, AGORA_MODERATION_NAMESPACE],
|
||||
['a', coord],
|
||||
['alt', `Organization moderation: ${action}`],
|
||||
],
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate both the moderation rollup and the derived featured
|
||||
// query so the grid reflects the new state immediately.
|
||||
queryClient.invalidateQueries({ queryKey: ['organization-moderation'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['featured-organizations'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
|
||||
isPending: moderationQuery.isPending,
|
||||
isLoading: moderationQuery.isLoading,
|
||||
isReady: moderationQuery.isSuccess,
|
||||
moderate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Shared building blocks for Agora's moderation labels (NIP-32 kind 1985 in
|
||||
* the `agora.moderation` namespace). Both campaigns (kind 33863) and
|
||||
* organizations (kind 34550) ride the same label stream and the same
|
||||
* moderator pack (Team Soapbox); the only thing that varies between them is
|
||||
* the kind prefix on the `a` tag.
|
||||
*
|
||||
* Centralizing the constants, types, and folding logic here keeps the two
|
||||
* per-surface hooks (`useCampaignModeration`, `useOrganizationModeration`)
|
||||
* from drifting apart on namespace strings, axis semantics, or the
|
||||
* surfacing-rule contract documented in NIP.md.
|
||||
*/
|
||||
|
||||
/** NIP-32 label kind. */
|
||||
export const LABEL_KIND = 1985;
|
||||
|
||||
/** Label namespace for Agora's moderation labels. */
|
||||
export const AGORA_MODERATION_NAMESPACE = 'agora.moderation';
|
||||
|
||||
/** The six possible label values in the moderation namespace. */
|
||||
export type ModerationLabel =
|
||||
| 'approved'
|
||||
| 'unapproved'
|
||||
| 'hidden'
|
||||
| 'unhidden'
|
||||
| 'featured'
|
||||
| 'unfeatured';
|
||||
|
||||
/** A single label event narrowed to its decision axis. */
|
||||
export interface AxisDecision {
|
||||
/** Latest label observed on this axis. */
|
||||
label: ModerationLabel;
|
||||
/** Author of the latest label. */
|
||||
pubkey: string;
|
||||
/** Created-at of the latest label. */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** Per-coordinate rollup of approval + hide + featured state. */
|
||||
export interface ModerationState {
|
||||
approval?: AxisDecision; // `approved` or `unapproved`
|
||||
hide?: AxisDecision; // `hidden` or `unhidden`
|
||||
featured?: AxisDecision; // `featured` or `unfeatured`
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-surface rollup. Keys are the addressable coordinates we filtered for
|
||||
* (e.g. `33863:<pubkey>:<d>` for campaigns or `34550:<pubkey>:<d>` for
|
||||
* organizations).
|
||||
*/
|
||||
export interface ModerationData {
|
||||
/** Map of `<kind>:<pubkey>:<d>` -> rollup. */
|
||||
byCoord: Map<string, ModerationState>;
|
||||
/** Coordinates where the latest approval label is `approved`. */
|
||||
approvedCoords: Set<string>;
|
||||
/** Coordinates where the latest hide label is `hidden`. */
|
||||
hiddenCoords: Set<string>;
|
||||
/** Coordinates where the latest featured label is `featured`. */
|
||||
featuredCoords: Set<string>;
|
||||
/**
|
||||
* Map of `coord` -> `created_at` of the latest `featured` label. Used to
|
||||
* sort featured rows newest-first.
|
||||
*/
|
||||
featuredOrder: Map<string, number>;
|
||||
/** Pubkeys that were considered moderators when the query ran. */
|
||||
moderators: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_MODERATION_DATA: ModerationData = {
|
||||
byCoord: new Map(),
|
||||
approvedCoords: new Set(),
|
||||
hiddenCoords: new Set(),
|
||||
featuredCoords: new Set(),
|
||||
featuredOrder: new Map(),
|
||||
moderators: [],
|
||||
};
|
||||
|
||||
function isApprovalLabel(value: string): value is 'approved' | 'unapproved' {
|
||||
return value === 'approved' || value === 'unapproved';
|
||||
}
|
||||
|
||||
function isHideLabel(value: string): value is 'hidden' | 'unhidden' {
|
||||
return value === 'hidden' || value === 'unhidden';
|
||||
}
|
||||
|
||||
function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' {
|
||||
return value === 'featured' || value === 'unfeatured';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a flat list of label events into per-coordinate rollups by axis.
|
||||
* The newest event per `(coord, axis)` wins.
|
||||
*
|
||||
* Events are filtered to only those carrying an `a` tag that starts with
|
||||
* `<coordKind>:` so the campaign and organization label streams never bleed
|
||||
* into each other even though they share a namespace and signer set.
|
||||
*
|
||||
* Events with a value outside the moderation namespace, or with no `l` tag
|
||||
* in that namespace, are dropped.
|
||||
*/
|
||||
export function foldModerationLabels(
|
||||
events: NostrEvent[],
|
||||
moderators: string[],
|
||||
coordKind: number,
|
||||
): ModerationData {
|
||||
const coordPrefix = `${coordKind}:`;
|
||||
const byCoord = new Map<string, ModerationState>();
|
||||
|
||||
for (const event of events) {
|
||||
const value = event.tags.find(
|
||||
([n, , ns]) => n === 'l' && ns === AGORA_MODERATION_NAMESPACE,
|
||||
)?.[1];
|
||||
if (!value) continue;
|
||||
const aTag = event.tags.find(
|
||||
([n, v]) => n === 'a' && typeof v === 'string' && v.startsWith(coordPrefix),
|
||||
)?.[1];
|
||||
if (!aTag) continue;
|
||||
|
||||
const state = byCoord.get(aTag) ?? {};
|
||||
if (isApprovalLabel(value)) {
|
||||
if (!state.approval || event.created_at > state.approval.createdAt) {
|
||||
state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
} else if (isHideLabel(value)) {
|
||||
if (!state.hide || event.created_at > state.hide.createdAt) {
|
||||
state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
} else if (isFeaturedLabel(value)) {
|
||||
if (!state.featured || event.created_at > state.featured.createdAt) {
|
||||
state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
}
|
||||
byCoord.set(aTag, state);
|
||||
}
|
||||
|
||||
const approvedCoords = new Set<string>();
|
||||
const hiddenCoords = new Set<string>();
|
||||
const featuredCoords = new Set<string>();
|
||||
const featuredOrder = new Map<string, number>();
|
||||
for (const [coord, state] of byCoord) {
|
||||
if (state.approval?.label === 'approved') approvedCoords.add(coord);
|
||||
if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
|
||||
if (state.featured?.label === 'featured') {
|
||||
featuredCoords.add(coord);
|
||||
featuredOrder.set(coord, state.featured.createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators };
|
||||
}
|
||||
@@ -386,8 +386,8 @@ function FeaturedOrganizationsShelf() {
|
||||
return (
|
||||
<EmptyShelf
|
||||
icon={<Users className="size-7 text-primary/70" />}
|
||||
title="No featured organizations available"
|
||||
body="The curated list is currently unreachable. Try refreshing in a moment."
|
||||
title="No featured organizations yet"
|
||||
body="Agora moderators feature standout organizations here. Check back soon."
|
||||
action={null}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user