Drop badge-award member runtime

Removes NIP-58 badge-award membership validation from Agora's
organization model. Authorization collapses to two roles:

- Founder = author of the kind 34550 event (only the founder can edit
  organization metadata, enforced by replaceable-event semantics).
- Moderators = `p` tags with role "moderator" on that event (can hide
  content via kind 1984 content-bans; cannot edit the org).

There is no "member" tier any more. The kind 8 / kind 30009 chain
that previously gated discussion access, the members-only feed
filter, the avatar-stack member count, and member-ban moderation
actions are all gone.

What changes:

- `useCommunityMembers` returns `{ founderPubkey, moderatorPubkeys }`
  read directly from the parsed community event. The kind 8 badge
  query, `resolveMembership`, `isAuthorizedAward`, and the rank-1
  member tier are removed. The hook still queries kind 1984 events
  scoped to the org so content-bans and soft reports keep working.

- `applyCommunityModerationToEvents` keeps content-ban filtering for
  founder/mods. `resolveCommunityModeration` is simplified to a
  single pass — only founder/moderators can publish moderation
  actions, and the parsed report classification drops the
  `member-ban` action since wholesale member bans no longer exist.

- `BanConfirmDialog` drops `mode="member"`. Only event-level content
  bans remain. `NoteMoreMenu` drops the "Ban from community"
  affordance accordingly.

- `ParsedCommunity` loses `memberBadgeATag` and
  `memberBadgeRelayHint`. `parseCommunityEvent` no longer reads the
  `['a', '30009:…', '', 'member']` tag. `CreateCommunityPage` no
  longer mints a "Member of <org>" badge or attaches the member-badge
  tag, and on edit it strips any pre-existing member-badge tag so
  legacy wiring doesn't linger.

- The "Voices from everywhere" cross-organization activity feed is
  deleted from the Organize page. It duplicated per-org activity (now
  served by the official-activity shelves on each org detail page)
  and was the only consumer of `useCommunityActivityFeed`.
  `useFollowingFeed` drops its community-feed leg for the same
  reason. `useMyCommunities`, `useMembersOnlyFilter`, and
  `MembersOnlyToggle` go with it.

- `CommunityDetailPage` loses the MembersOnlyToggle in the hero, the
  "Add members" and "Edit badge" overflow-menu items, and the
  per-member ban affordance in the members dialog. The avatar stack
  now renders founder + moderators with a "Founder + N moderators"
  label and the dialog title becomes "Leadership".

- Deleted: `AddMemberDialog`, `CommunityBadgePanel` (which exported
  `CommunityBadgeEditorDialog`), `useCommunityActivityFeed`,
  `useMyCommunities`, `useMembersOnlyFilter`, `MembersOnlyToggle`,
  plus three already-unused legacy components (`CommunityChatPanel`,
  `CommunityPulsePanel`, `CreateCommunityDialog`) and
  `useCommunityChatMessages`. `PersonSearch` is extracted from the
  deleted `AddMemberDialog` into its own file so the create-campaign
  and create-community forms keep their member-picker UI.

The standalone NIP-58 badge tooling (`BadgesPage`, `GiveBadgeDialog`,
`useBadgeFeed`, etc.) is untouched — it's a separate feature surface
from organization membership and continues to work.

Out of scope: file/symbol renames (Community* → Organization*).
That's the next commit.
This commit is contained in:
lemon
2026-05-20 01:04:10 -07:00
parent 743390edf7
commit a68cad44c3
22 changed files with 452 additions and 3450 deletions
-841
View File
@@ -1,841 +0,0 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { UserPlus, Loader2, X, Search, Crown, Users, PartyPopper } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { ImageUploadField } from '@/components/ImageUploadField';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { useSearchPeopleLists, type PeopleListSearchResult } from '@/hooks/useSearchPeopleLists';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { parseAuthorEvent } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import {
COMMUNITY_DEFINITION_KIND,
BADGE_DEFINITION_KIND,
BADGE_AWARD_KIND,
EMPTY_MODERATION,
type CommunityMember,
type CommunityMembership,
type CommunityModeration,
type ParsedCommunity,
} from '@/lib/communityUtils';
// ── Types ─────────────────────────────────────────────────────────────────────
type MemberRole = 'moderator' | 'member';
interface PendingMember {
profile: SearchProfile;
role: MemberRole;
}
interface BadgeRef {
pubkey: string;
identifier: string;
}
interface CommunityMembersCacheValue {
membership: CommunityMembership;
moderation: CommunityModeration;
rankMap: Map<string, CommunityMember>;
}
interface AddMemberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The raw community definition event. */
communityEvent: NostrEvent;
/** Parsed community data. */
community: ParsedCommunity;
/** Whether the current user is the founder (can add moderators). */
isFounder: boolean;
/** Existing active members and moderators, excluded from duplicate adds. */
existingMemberPubkeys: string[];
}
interface AddMemberPanelProps {
/** The raw community definition event. */
communityEvent: NostrEvent;
/** Parsed community data. */
community: ParsedCommunity;
/** Whether the current user is the founder (can add moderators). */
isFounder: boolean;
/** Existing active members and moderators, excluded from duplicate adds. */
existingMemberPubkeys: string[];
/** Called after a successful publish so the host (dialog/page) can close or refresh. */
onComplete?: () => void;
}
function parseBadgeATag(aTag: string | undefined): BadgeRef | undefined {
if (!aTag) return undefined;
const [kind, pubkey, ...identifierParts] = aTag.split(':');
const identifier = identifierParts.join(':');
if (kind !== String(BADGE_DEFINITION_KIND) || !pubkey || !identifier) return undefined;
return { pubkey, identifier };
}
function isHexPubkey(value: string): boolean {
return /^[0-9a-f]{64}$/i.test(value);
}
function makeFallbackProfile(pubkey: string): SearchProfile {
return {
pubkey,
metadata: {},
event: {
id: '',
pubkey,
created_at: 0,
kind: 0,
tags: [],
content: '{}',
sig: '',
},
};
}
function profileFromEvent(event: NostrEvent): SearchProfile {
const parsed = parseAuthorEvent(event);
return { pubkey: event.pubkey, metadata: parsed.metadata ?? {}, event };
}
// ── Component ─────────────────────────────────────────────────────────────────
export function AddMemberDialog({
open,
onOpenChange,
communityEvent,
community,
isFounder,
existingMemberPubkeys,
}: AddMemberDialogProps) {
const { user } = useCurrentUser();
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent ref={dialogContentRef} className="sm:max-w-md gap-0 p-0 overflow-visible">
<PortalContainerProvider value={portalContainer}>
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<UserPlus className="size-5 text-primary" />
Add Members
</DialogTitle>
<DialogDescription>Add to community</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="px-5 pb-5">
<AddMemberPanel
communityEvent={communityEvent}
community={community}
isFounder={isFounder}
existingMemberPubkeys={existingMemberPubkeys}
onComplete={() => onOpenChange(false)}
/>
</div>
</ScrollArea>
</PortalContainerProvider>
</DialogContent>
</Dialog>
);
}
/**
* Inline form that searches for people and adds them as community members or
* moderators. Pulled out of `AddMemberDialog` so the same flow can be
* embedded inside other surfaces — e.g. the members dialog on
* `CommunityDetailPage` — without nesting a second `Dialog`.
*/
export function AddMemberPanel({
communityEvent,
community,
isFounder,
existingMemberPubkeys,
onComplete,
}: AddMemberPanelProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
// Form state
const [pendingMembers, setPendingMembers] = useState<PendingMember[]>([]);
const [badgeImageUrl, setBadgeImageUrl] = useState('');
const [isBadgeImageUploading, setIsBadgeImageUploading] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Does this community already have a member badge definition?
const existingBadgeATag = community.memberBadgeATag;
const hasBadge = !!existingBadgeATag;
const existingBadgeRef = useMemo(() => parseBadgeATag(existingBadgeATag), [existingBadgeATag]);
const existingBadgeRefs = useMemo(() => existingBadgeRef ? [existingBadgeRef] : [], [existingBadgeRef]);
const { badgeMap, isLoading: isBadgeLoading, isError: isBadgeError } = useBadgeDefinitions(existingBadgeRefs);
const existingBadge = existingBadgeATag ? badgeMap.get(existingBadgeATag) : undefined;
// Are there any pending members with the "member" role?
const hasPendingMembers = pendingMembers.some((m) => m.role === 'member');
// Will we need to create a badge? (members added + no badge exists yet)
const needsBadgeCreation = hasPendingMembers && !hasBadge;
const resetForm = useCallback(() => {
setPendingMembers([]);
setBadgeImageUrl('');
setIsBadgeImageUploading(false);
setIsPublishing(false);
}, []);
// ── People management ─────────────────────────────────────────────────────
const addPerson = useCallback((profile: SearchProfile) => {
if (!user) return;
if (profile.pubkey === community.founderPubkey) {
toast({ title: 'Already the founder' });
return;
}
if (existingMemberPubkeys.includes(profile.pubkey)) {
toast({ title: 'Already in the community' });
return;
}
if (pendingMembers.some((m) => m.profile.pubkey === profile.pubkey)) {
toast({ title: 'Already added' });
return;
}
setPendingMembers((prev) => [...prev, { profile, role: 'member' }]);
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, toast]);
const addPeople = useCallback((profiles: SearchProfile[], sourceTitle?: string) => {
if (!user) return;
const excluded = new Set([
community.founderPubkey,
...existingMemberPubkeys,
...pendingMembers.map((m) => m.profile.pubkey),
]);
const nextProfiles: SearchProfile[] = [];
for (const profile of profiles) {
if (excluded.has(profile.pubkey)) continue;
excluded.add(profile.pubkey);
nextProfiles.push(profile);
}
if (nextProfiles.length === 0) {
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
return;
}
setPendingMembers((prev) => [...prev, ...nextProfiles.map((profile) => ({ profile, role: 'member' as const }))]);
if (sourceTitle) {
toast({ title: `Added ${nextProfiles.length} from ${sourceTitle}` });
}
}, [user, community.founderPubkey, existingMemberPubkeys, pendingMembers, toast]);
const removePerson = useCallback((pubkey: string) => {
setPendingMembers((prev) => prev.filter((m) => m.profile.pubkey !== pubkey));
}, []);
const setRole = useCallback((pubkey: string, role: MemberRole) => {
if (!isFounder) return; // Only founder can appoint moderators
setPendingMembers((prev) => prev.map((m) =>
m.profile.pubkey === pubkey
? { ...m, role }
: m,
));
}, [isFounder]);
const applyOptimisticMembership = useCallback((members: PendingMember[], awardEvents: Map<string, NostrEvent>) => {
queryClient.setQueryData<CommunityMembersCacheValue>(['community-members', community.aTag], (prev) => {
const moderation = prev?.moderation ?? EMPTY_MODERATION;
const rankMap = new Map(prev?.rankMap ?? []);
const membershipByPubkey = new Map(
(prev?.membership.members ?? []).map((member) => [member.pubkey, member] as const),
);
const seedRankZero = (pubkey: string) => {
if (moderation.bannedPubkeys.has(pubkey)) return;
const member: CommunityMember = { pubkey, rank: 0 };
if (!membershipByPubkey.has(pubkey)) membershipByPubkey.set(pubkey, member);
if (!rankMap.has(pubkey)) rankMap.set(pubkey, member);
};
seedRankZero(community.founderPubkey);
community.moderatorPubkeys.forEach(seedRankZero);
for (const pending of members) {
if (moderation.bannedPubkeys.has(pending.profile.pubkey)) continue;
const nextMember: CommunityMember = pending.role === 'moderator'
? { pubkey: pending.profile.pubkey, rank: 0 }
: {
pubkey: pending.profile.pubkey,
rank: 1,
awardEvent: awardEvents.get(pending.profile.pubkey),
awardedBy: user?.pubkey,
};
const current = membershipByPubkey.get(nextMember.pubkey);
if (!current || nextMember.rank < current.rank) {
membershipByPubkey.set(nextMember.pubkey, nextMember);
}
const currentRank = rankMap.get(nextMember.pubkey);
if (!currentRank || nextMember.rank < currentRank.rank) {
rankMap.set(nextMember.pubkey, nextMember);
}
}
const membership: CommunityMembership = {
members: Array.from(membershipByPubkey.values()).sort((a, b) => a.rank - b.rank),
};
return { membership, moderation, rankMap };
});
}, [community.aTag, community.founderPubkey, community.moderatorPubkeys, queryClient, user?.pubkey]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleSubmit = useCallback(async () => {
if (!user || pendingMembers.length === 0) return;
if (isBadgeImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (badgeImageUrl.trim() && !sanitizeUrl(badgeImageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
if (needsBadgeCreation && !isFounder) {
toast({ title: 'Member badge is missing', description: 'Only the founder can initialize community membership.', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
const newModerators = pendingMembers.filter((m) => m.role === 'moderator');
const newMembers = pendingMembers.filter((m) => m.role === 'member');
let badgeATag = existingBadgeATag;
// Step 1: Create badge definition if needed
if (newMembers.length > 0 && !hasBadge) {
const badgeDTag = `${community.dTag}-member`;
const existingBadge = await nostr.query([{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
}]);
if (existingBadge.length > 0) {
toast({
title: 'Member badge ID already in use',
description: 'This community needs a member badge, but that badge identifier already exists on your account.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeTags: string[][] = [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${community.name}`],
];
const sanitizedBadgeImage = sanitizeUrl(badgeImageUrl.trim());
if (sanitizedBadgeImage) {
badgeTags.push(['image', sanitizedBadgeImage, '1024x1024']);
}
badgeTags.push(['alt', `Badge definition: Member of ${community.name}`]);
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: badgeTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`;
}
// Step 2: Republish community definition if needed
// Needed when: adding moderators (new p tags) OR badge was just created (new a tag)
const needsCommunityUpdate = newModerators.length > 0 || (newMembers.length > 0 && !hasBadge);
if (needsCommunityUpdate) {
// Fetch fresh community event to avoid stale overwrites
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const baseTags = prev?.tags ?? communityEvent.tags;
const updatedTags = [...baseTags];
// Add new moderator p tags
for (const mod of newModerators) {
// Don't add if already exists
const exists = updatedTags.some(
([n, v, , role]) => n === 'p' && v === mod.profile.pubkey && role === 'moderator',
);
if (!exists) {
updatedTags.push(['p', mod.profile.pubkey, '', 'moderator']);
}
}
// Add badge a tag if badge was just created
if (badgeATag && !hasBadge) {
updatedTags.push(['a', badgeATag, '', 'member']);
}
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? '',
tags: updatedTags,
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.setQueryData(['event', updatedEvent.id], updatedEvent);
}
// Step 3: Publish badge awards for each member
const memberAwardEvents = new Map<string, NostrEvent>();
if (newMembers.length > 0 && badgeATag) {
for (const member of newMembers) {
const awardEvent = await publishEvent({
kind: BADGE_AWARD_KIND,
content: '',
tags: [
['a', badgeATag],
['p', member.profile.pubkey],
['alt', `Badge award: Member in ${community.name}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
memberAwardEvents.set(member.profile.pubkey, awardEvent);
}
}
applyOptimisticMembership(pendingMembers, memberAwardEvents);
queryClient.invalidateQueries({ queryKey: ['community-members', community.aTag], refetchType: 'inactive' });
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
if (!hasBadge && newMembers.length > 0) {
queryClient.invalidateQueries({ queryKey: ['badge-feed'] });
}
const addedCount = pendingMembers.length;
toast({ title: `Added ${addedCount} ${addedCount === 1 ? 'person' : 'people'} to the community` });
resetForm();
onComplete?.();
} catch (err) {
toast({
title: 'Failed to add members',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, pendingMembers, existingBadgeATag, hasBadge, needsBadgeCreation, isFounder, community, communityEvent,
badgeImageUrl, nostr, publishEvent, queryClient, toast, resetForm, onComplete, applyOptimisticMembership, isBadgeImageUploading,
]);
if (!user) return null;
return (
<div className="space-y-4">
{/* People search */}
<div className="space-y-1.5">
<Label>Search</Label>
<PersonSearch
onAdd={addPerson}
onAddMany={addPeople}
excludePubkeys={[
community.founderPubkey,
...existingMemberPubkeys,
...pendingMembers.map((m) => m.profile.pubkey),
]}
/>
</div>
{/* Pending members list */}
{pendingMembers.length > 0 && (
<div className="space-y-1.5">
<Label>
People to add
<span className="text-muted-foreground font-normal ml-1">({pendingMembers.length})</span>
</Label>
<div className="space-y-1">
{pendingMembers.map((pm) => (
<PendingMemberChip
key={pm.profile.pubkey}
pending={pm}
onRemove={removePerson}
onRoleChange={isFounder ? setRole : undefined}
/>
))}
</div>
</div>
)}
{hasPendingMembers && (
<div className="space-y-2">
<Label>Member badge</Label>
{hasBadge ? (
<div className="rounded-xl border border-border/60 bg-secondary/30 p-3">
{isBadgeError ? (
<p className="text-sm text-destructive">Failed to load the current member badge.</p>
) : isBadgeLoading ? (
<div className="flex items-center gap-3">
<div className="size-12 animate-pulse rounded-lg bg-muted" />
<div className="space-y-2">
<div className="h-4 w-28 animate-pulse rounded bg-muted" />
<div className="h-3 w-44 animate-pulse rounded bg-muted" />
</div>
</div>
) : existingBadge ? (
<div className="flex items-center gap-3">
<BadgeThumbnail badge={existingBadge} size={48} className="shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{existingBadge.name}</p>
<p className="text-xs text-muted-foreground line-clamp-2">
{existingBadge.description || 'Selected members will receive this badge.'}
</p>
</div>
</div>
) : (
<p className="text-sm text-destructive">The configured member badge could not be found.</p>
)}
</div>
) : (
<ImageUploadField
id="member-badge-image"
label={<>Create Member Badge Image <span className="text-muted-foreground font-normal">(optional)</span></>}
value={badgeImageUrl}
onChange={setBadgeImageUrl}
onUploadingChange={setIsBadgeImageUploading}
uploadToastTitle="Badge image uploaded"
previewAlt="Badge preview"
objectFit="contain"
dropAreaClassName="min-h-24"
/>
)}
</div>
)}
{/* Submit button */}
<Button
onClick={handleSubmit}
disabled={pendingMembers.length === 0 || isPublishing || isBadgeImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> Adding...</>
) : (
<><UserPlus className="size-4" /> Add {pendingMembers.length || ''} {pendingMembers.length === 1 ? 'Person' : pendingMembers.length > 1 ? 'People' : 'Members'}</>
)}
</Button>
</div>
);
}
// ── Sub-Components ────────────────────────────────────────────────────────────
/** Inline type-ahead person search. */
export function PersonSearch({
onAdd,
onAddMany,
excludePubkeys,
}: {
onAdd: (profile: SearchProfile) => void;
onAddMany: (profiles: SearchProfile[], sourceTitle?: string) => void;
excludePubkeys: string[];
}) {
const { nostr } = useNostr();
const { toast } = useToast();
const [query, setQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isAddingPack, setIsAddingPack] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data: profiles, isFetching } = useSearchProfiles(query);
const { data: peopleLists, isFetching: isFetchingPeopleLists } = useSearchPeopleLists(query);
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
const filteredProfiles = useMemo(
() => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)),
[profiles, excludeSet],
);
const filteredPeopleLists = useMemo(
() => (peopleLists ?? []).filter((pack) => pack.pubkeys.some((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey.toLowerCase()))),
[peopleLists, excludeSet],
);
const hasResults = filteredProfiles.length > 0 || filteredPeopleLists.length > 0;
const isSearching = isFetching || isFetchingPeopleLists || isAddingPack;
useEffect(() => {
if (query.trim().length > 0 && hasResults) {
setDropdownOpen(true);
} else if (query.trim().length === 0) {
setDropdownOpen(false);
}
}, [hasResults, query]);
const handleSelect = useCallback((profile: SearchProfile) => {
onAdd(profile);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
}, [onAdd]);
const handleSelectPeopleList = useCallback(async (pack: PeopleListSearchResult) => {
const eligiblePubkeys = Array.from(new Set(
pack.pubkeys
.map((pubkey) => pubkey.toLowerCase())
.filter((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey)),
));
if (eligiblePubkeys.length === 0) {
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
return;
}
if (eligiblePubkeys.length > 20 && !window.confirm(`Add ${eligiblePubkeys.length} people from ${pack.title}?`)) {
return;
}
setIsAddingPack(true);
try {
const events = await nostr.query(
[{ kinds: [0], authors: eligiblePubkeys, limit: eligiblePubkeys.length }],
{ signal: AbortSignal.timeout(8000) },
);
const latestByPubkey = new Map<string, NostrEvent>();
for (const event of events) {
const existing = latestByPubkey.get(event.pubkey);
if (!existing || event.created_at > existing.created_at) latestByPubkey.set(event.pubkey, event);
}
const profilesToAdd = eligiblePubkeys.map((pubkey) => {
const event = latestByPubkey.get(pubkey);
return event ? profileFromEvent(event) : makeFallbackProfile(pubkey);
});
onAddMany(profilesToAdd, pack.title);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
} catch (error) {
toast({
title: 'Failed to load follow pack members',
description: error instanceof Error ? error.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsAddingPack(false);
}
}, [excludeSet, nostr, onAddMany, toast]);
return (
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<div className="relative flex items-center">
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
{isSearching && query.trim() && (
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
)}
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => {
if (query.trim().length > 0 && hasResults) {
setDropdownOpen(true);
}
}}
placeholder="Search people..."
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm"
autoComplete="off"
/>
</div>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
>
{hasResults ? (
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredProfiles.map((profile) => (
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
))}
{filteredPeopleLists.map((pack) => (
<PeopleListSearchResultItem key={`${pack.event.kind}:${pack.event.pubkey}:${pack.event.tags.find(([name]) => name === 'd')?.[1] ?? pack.event.id}`} pack={pack} onClick={handleSelectPeopleList} />
))}
</div>
) : query.trim().length >= 2 && !isSearching ? (
<div className="py-4 text-center text-sm text-muted-foreground">
No people or follow packs found
</div>
) : null}
</PopoverContent>
</Popover>
);
}
/** A follow pack / follow set search result row. */
function PeopleListSearchResultItem({ pack, onClick }: { pack: PeopleListSearchResult; onClick: (pack: PeopleListSearchResult) => void }) {
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(pack)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-8 shrink-0 rounded-full bg-primary/10 text-primary flex items-center justify-center">
<PartyPopper className="size-4" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{pack.title}</span>
<span className="text-xs text-muted-foreground truncate block">
Follow pack · {pack.pubkeys.length} people
</span>
</div>
</button>
);
}
/** A pending member chip with role toggle and remove button. */
function PendingMemberChip({
pending,
onRemove,
onRoleChange,
}: {
pending: PendingMember;
onRemove: (pubkey: string) => void;
onRoleChange?: (pubkey: string, role: MemberRole) => void;
}) {
const { profile, role } = pending;
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<div className="flex items-center gap-2 p-2 rounded-lg bg-secondary/30 border border-border/50">
<Avatar className="size-7 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<span className="flex-1 text-sm truncate">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{onRoleChange ? (
<ToggleGroup
type="single"
value={role}
onValueChange={(value) => {
if (value === 'member' || value === 'moderator') onRoleChange(pubkey, value);
}}
className="shrink-0 rounded-full bg-muted p-0.5"
aria-label={`Role for ${displayName}`}
>
<ToggleGroupItem value="member" size="sm" className="h-7 rounded-full px-2 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground" aria-label="Member">
<Users className="size-3 sm:mr-1" />
<span className="hidden sm:inline">Member</span>
</ToggleGroupItem>
<ToggleGroupItem value="moderator" size="sm" className="h-7 rounded-full px-2 text-xs data-[state=on]:bg-amber-500 data-[state=on]:text-white" aria-label="Moderator">
<Crown className="size-3 sm:mr-1" />
<span className="hidden sm:inline">Moderator</span>
</ToggleGroupItem>
</ToggleGroup>
) : (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Users className="size-3" />
Member
</span>
)}
<button
type="button"
onClick={() => onRemove(pubkey)}
className="shrink-0 size-6 rounded-full hover:bg-destructive/10 flex items-center justify-center transition-colors"
title="Remove"
>
<X className="size-3.5 text-muted-foreground hover:text-destructive" />
</button>
</div>
);
}
/** A profile search result row. */
function SearchResultItem({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(profile)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar className="size-8 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{metadata.nip05 && (
<span className="text-xs text-muted-foreground truncate block">
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
</span>
)}
</div>
</button>
);
}
+22 -58
View File
@@ -18,42 +18,27 @@ import {
} from '@/lib/communityUtils';
// ── Props ─────────────────────────────────────────────────────────────────────
//
// Only content-level bans remain. Agora's organization trust model has no
// "member" tier any more, so banning a user wholesale is no longer
// modeled — hide each unwanted post individually instead.
interface BanContentProps {
/** Ban a specific post. */
mode: 'content';
interface BanConfirmDialogProps {
/** The event ID to ban. */
eventId: string;
/** The event author's pubkey. */
targetPubkey: string;
/** Display name for the dialog description. */
displayName?: string;
}
interface BanMemberProps {
/** Ban a member. */
mode: 'member';
eventId?: never;
/** The pubkey of the member to ban. */
targetPubkey: string;
/** Display name for the dialog description. */
displayName?: string;
}
type BanMode = BanContentProps | BanMemberProps;
type BanConfirmDialogProps = BanMode & {
/** The community `A` tag coordinate. */
communityATag: string;
/** Display name for the dialog description. */
displayName?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
};
}
export function BanConfirmDialog({
mode,
eventId,
targetPubkey,
displayName,
communityATag,
open,
onOpenChange,
@@ -62,23 +47,15 @@ export function BanConfirmDialog({
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const [reason, setReason] = useState('');
const title = mode === 'content' ? 'Remove from community' : `Ban ${displayName ? `@${displayName}` : 'member'} from community`;
const description = mode === 'content'
? 'This will hide the post from canonical community views.'
: `This will ban ${displayName ? `@${displayName}` : 'this member'} from the community. Their recruits remain unaffected.`;
const handleSubmit = async () => {
try {
const tags: string[][] = [];
if (mode === 'content' && eventId) {
tags.push(['e', eventId, 'other']);
}
tags.push(['p', targetPubkey, 'other']);
tags.push(['A', communityATag]);
tags.push(['L', MODERATION_LABEL_NAMESPACE]);
tags.push(['l', MODERATION_BAN_LABEL, MODERATION_LABEL_NAMESPACE]);
const tags: string[][] = [
['e', eventId, 'other'],
['p', targetPubkey, 'other'],
['A', communityATag],
['L', MODERATION_LABEL_NAMESPACE],
['l', MODERATION_BAN_LABEL, MODERATION_LABEL_NAMESPACE],
];
await publishEvent({
kind: REPORT_KIND,
@@ -87,36 +64,23 @@ export function BanConfirmDialog({
});
// Invalidate community queries so the moderation overlay updates
// immediately (removes banned content/members without a page refresh).
// The activity feed's key is `['community-activity-feed', <aTagsKey>]`
// where aTagsKey is a comma-joined list of the viewer's subscribed A
// tags. We match any feed whose aTagsKey contains this communityATag.
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(communityATag);
},
}),
]);
// immediately (removes banned content without a page refresh).
await queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] });
toast({ title: mode === 'content' ? 'Post removed from community' : 'Member banned from community' });
toast({ title: 'Post removed from community' });
setReason('');
onOpenChange(false);
} catch {
toast({ title: mode === 'content' ? 'Failed to remove post from community' : 'Failed to ban member from community', variant: 'destructive' });
toast({ title: 'Failed to remove post from community', variant: 'destructive' });
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md rounded-2xl flex flex-col overflow-hidden">
<DialogTitle>{title}</DialogTitle>
<DialogTitle>Remove from community</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{description}
This will hide the post from canonical community views.
</DialogDescription>
<div className="space-y-2">
@@ -146,7 +110,7 @@ export function BanConfirmDialog({
disabled={isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isPending ? 'Submitting...' : (mode === 'content' ? 'Remove' : 'Ban')}
{isPending ? 'Submitting...' : 'Remove'}
</Button>
</div>
</DialogContent>
-362
View File
@@ -1,362 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Award, Loader2 } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
import { ImageUploadField } from '@/components/ImageUploadField';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useBadgeDefinitions, type BadgeDefinition } from '@/hooks/useBadgeDefinitions';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { BADGE_DEFINITION_KIND, COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
interface BadgeRef {
pubkey: string;
identifier: string;
}
interface CommunityBadgePanelProps {
communityEvent: NostrEvent;
community: ParsedCommunity;
isFounder: boolean;
}
function parseBadgeATag(aTag: string | undefined): BadgeRef | undefined {
if (!aTag) return undefined;
const [kind, pubkey, ...identifierParts] = aTag.split(':');
const identifier = identifierParts.join(':');
if (kind !== String(BADGE_DEFINITION_KIND) || !pubkey || !identifier) return undefined;
return { pubkey, identifier };
}
function buildBadgeTags(baseTags: string[][], dTag: string, name: string, description: string, imageUrl: string): string[][] {
const tags = baseTags.filter(([tagName]) => !['d', 'name', 'description', 'image', 'thumb', 'alt'].includes(tagName));
const nextTags: string[][] = [
['d', dTag],
['name', name.trim()],
];
if (description.trim()) {
nextTags.push(['description', description.trim()]);
}
const image = sanitizeUrl(imageUrl.trim());
if (image) {
nextTags.push(['image', image, '1024x1024']);
}
nextTags.push(...tags);
nextTags.push(['alt', `Badge definition: ${name.trim()}`]);
return nextTags;
}
function buildCommunityBadgeTags(baseTags: string[][], badgeATag: string): string[][] {
return [
...baseTags.filter(([tagName, value, , role]) => !(tagName === 'a' && value?.startsWith(`${BADGE_DEFINITION_KIND}:`) && role === 'member')),
['a', badgeATag, '', 'member'],
];
}
export function CommunityBadgePanel({ communityEvent, community, isFounder }: CommunityBadgePanelProps) {
const [editOpen, setEditOpen] = useState(false);
const badgeRef = useMemo(() => parseBadgeATag(community.memberBadgeATag), [community.memberBadgeATag]);
const badgeRefs = useMemo(() => badgeRef ? [badgeRef] : [], [badgeRef]);
const { badgeMap, isLoading, isError } = useBadgeDefinitions(badgeRefs);
const badge = community.memberBadgeATag ? badgeMap.get(community.memberBadgeATag) : undefined;
const badgeButtonLabel = badge ? `Edit ${badge.name} badge` : 'Set member badge';
const badgeVisual = isLoading ? (
<div className="size-10 animate-pulse rounded-lg bg-muted" />
) : badge ? (
<BadgeThumbnail badge={badge} size={40} className="shrink-0" />
) : (
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Award className="size-4" />
</div>
);
return (
<div className="min-w-0 flex-1">
<p className="mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Member badge</p>
<div className="flex items-center gap-3 py-1">
{isFounder ? (
<button
type="button"
onClick={() => setEditOpen(true)}
className="shrink-0 rounded-lg transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-label={badgeButtonLabel}
title={badgeButtonLabel}
>
{badgeVisual}
</button>
) : badgeVisual}
<div className="min-w-0 flex-1">
{isError ? (
<p className="text-sm text-destructive">Failed to load badge</p>
) : isLoading ? (
<div className="space-y-1.5">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
</div>
) : badge ? (
<>
<p className="truncate text-sm font-medium">{badge.name}</p>
<p className="truncate text-xs text-muted-foreground">Community member badge</p>
</>
) : (
<>
<p className="truncate text-sm font-medium">Member badge</p>
<p className="truncate text-xs text-muted-foreground">
{isFounder ? 'Click the badge image to set one' : 'No badge set yet'}
</p>
</>
)}
</div>
</div>
{isFounder && (
<CommunityBadgeDialog
open={editOpen}
onOpenChange={setEditOpen}
communityEvent={communityEvent}
community={community}
badge={badge}
/>
)}
</div>
);
}
export function CommunityBadgeEditorDialog({
open,
onOpenChange,
communityEvent,
community,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
communityEvent: NostrEvent;
community: ParsedCommunity;
}) {
const badgeRef = useMemo(() => parseBadgeATag(community.memberBadgeATag), [community.memberBadgeATag]);
const badgeRefs = useMemo(() => badgeRef ? [badgeRef] : [], [badgeRef]);
const { badgeMap } = useBadgeDefinitions(badgeRefs);
const badge = community.memberBadgeATag ? badgeMap.get(community.memberBadgeATag) : undefined;
return (
<CommunityBadgeDialog
open={open}
onOpenChange={onOpenChange}
communityEvent={communityEvent}
community={community}
badge={badge}
/>
);
}
function CommunityBadgeDialog({
open,
onOpenChange,
communityEvent,
community,
badge,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
communityEvent: NostrEvent;
community: ParsedCommunity;
badge?: BadgeDefinition;
}) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
const [name, setName] = useState('Member');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
const canEditExistingBadge = !!badge && !!user && badge.event.pubkey === user.pubkey;
const canSave = !badge || canEditExistingBadge;
const resetForm = useCallback(() => {
setName(badge?.name || 'Member');
setDescription(badge?.description || `Member of ${community.name}`);
setImageUrl(badge?.image || badge?.thumbs[0]?.url || '');
setIsPublishing(false);
setIsImageUploading(false);
}, [badge, community.name]);
useEffect(() => {
if (open) resetForm();
}, [open, resetForm]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
const handleSave = useCallback(async () => {
if (!user || user.pubkey !== communityEvent.pubkey) return;
if (!name.trim()) {
toast({ title: 'Enter a badge name', variant: 'destructive' });
return;
}
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
toast({ title: 'Badge image must be a valid https URL', variant: 'destructive' });
return;
}
if (badge && !canEditExistingBadge) {
toast({ title: 'Badge cannot be edited', description: 'Only the badge issuer can edit this member badge.', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
const targetDTag = badge?.identifier || `${community.dTag}-member`;
const prevBadge = await fetchFreshEvent(nostr, {
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [targetDTag],
});
const baseBadge = prevBadge ?? badge?.event;
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: baseBadge?.content ?? '',
tags: buildBadgeTags(baseBadge?.tags ?? [['d', targetDTag]], targetDTag, name, description, imageUrl),
prev: prevBadge ?? undefined,
});
const badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${targetDTag}`;
if (!community.memberBadgeATag) {
const prevCommunity = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const baseCommunity = prevCommunity ?? communityEvent;
const updatedCommunity = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: baseCommunity.content,
tags: buildCommunityBadgeTags(baseCommunity.tags, badgeATag),
prev: prevCommunity ?? undefined,
});
queryClient.setQueryData(['addr-event', COMMUNITY_DEFINITION_KIND, updatedCommunity.pubkey, community.dTag], updatedCommunity);
}
queryClient.setQueryData(['addr-event', BADGE_DEFINITION_KIND, badgeEvent.pubkey, targetDTag], badgeEvent);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['badge-definitions-batch'], exact: false }),
queryClient.invalidateQueries({ queryKey: ['community-members', community.aTag], exact: false }),
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false }),
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false }),
]);
toast({ title: badge ? 'Member badge updated' : 'Member badge added' });
handleOpenChange(false);
} catch (error) {
toast({
title: 'Failed to update member badge',
description: error instanceof Error ? error.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, communityEvent, name, isImageUploading, imageUrl, badge, canEditExistingBadge, community, nostr,
publishEvent, description, queryClient, toast, handleOpenChange,
]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Award className="size-5 text-primary" />
Member Badge
</DialogTitle>
<DialogDescription>
This badge is awarded to members of {community.name}.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-5 pb-5">
{badge && !canEditExistingBadge && (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
This badge was issued by another account, so it cannot be edited here.
</p>
)}
<div className="space-y-1.5">
<Label htmlFor="community-member-badge-name">Badge name *</Label>
<Input
id="community-member-badge-name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!canSave || isPublishing}
maxLength={80}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="community-member-badge-description">Description</Label>
<Textarea
id="community-member-badge-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={!canSave || isPublishing}
rows={2}
/>
</div>
<ImageUploadField
id="community-member-badge-image"
label={<>Badge image <span className="text-muted-foreground font-normal">(recommended)</span></>}
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
uploadToastTitle="Badge image uploaded"
previewAlt="Member badge preview"
objectFit="contain"
dropAreaClassName="min-h-28"
disabled={!canSave || isPublishing}
/>
<Button
onClick={handleSave}
disabled={!canSave || !name.trim() || isPublishing || isImageUploading}
className="w-full gap-2"
>
{isPublishing ? <><Loader2 className="size-4 animate-spin" /> Saving...</> : <><Award className="size-4" /> Save Badge</>}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
-213
View File
@@ -1,213 +0,0 @@
import { useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { MessageSquare } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ComposeBox } from '@/components/ComposeBox';
import { ContentWarningGuard } from '@/components/ContentWarningGuard';
import { NoteContent } from '@/components/NoteContent';
import { useAuthor } from '@/hooks/useAuthor';
import { useCommunityChatMessages, COMMUNITY_CHAT_KIND } from '@/hooks/useCommunityChatMessages';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { getDisplayName } from '@/lib/getDisplayName';
import type { CommunityMember, CommunityModeration } from '@/lib/communityUtils';
import { cn } from '@/lib/utils';
interface CommunityChatPanelProps {
communityATag: string;
moderation: CommunityModeration;
rankMap: ReadonlyMap<string, CommunityMember>;
isMembershipLoading: boolean;
}
function shortTimeAgo(timestamp: number): string {
const diff = Math.max(0, Math.floor(Date.now() / 1000) - timestamp);
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
export function CommunityChatPanel({
communityATag,
moderation,
rankMap,
isMembershipLoading,
}: CommunityChatPanelProps) {
const queryClient = useQueryClient();
const { user } = useCurrentUser();
const { data: messages, isLoading, isError, error, queryKey } = useCommunityChatMessages(communityATag, moderation);
const isBanned = !!user && moderation.bannedPubkeys.has(user.pubkey);
const isMember = !!user && rankMap.has(user.pubkey) && !isBanned;
const disabledReason = !user
? 'Log in to chat with this community.'
: isMembershipLoading
? 'Loading membership...'
: isBanned
? 'You are banned from this community.'
: !isMember
? 'Only community members can chat.'
: undefined;
const canSend = !disabledReason;
const chatPublish = useMemo(() => ({
kind: COMMUNITY_CHAT_KIND,
tags: [['a', communityATag, '', 'root']],
suppressSuccessToast: true,
}), [communityATag]);
const handlePublished = useCallback((event: NostrEvent) => {
queryClient.setQueryData<NostrEvent[]>(queryKey, (old = []) => {
if (old.some((existing) => existing.id === event.id)) return old;
return [...old, event].sort((a, b) => b.created_at - a.created_at);
});
}, [queryClient, queryKey]);
return (
<div>
<div>
{disabledReason && (
<p className="px-4 pt-3 text-center text-xs text-muted-foreground">{disabledReason}</p>
)}
{canSend && (
<ComposeBox
compact
placeholder="What's up?"
customPublish={chatPublish}
hidePoll
submitLabel="Send"
onPublished={handlePublished}
/>
)}
</div>
<div>
{isLoading ? (
<CommunityChatSkeleton />
) : isError ? (
<div className="py-12 px-4 text-center text-sm text-destructive">
{error instanceof Error ? error.message : 'Failed to load community chat.'}
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
<div className="mb-3 rounded-full bg-primary/10 p-3">
<MessageSquare className="size-6 text-primary" />
</div>
<p className="text-sm font-medium">No messages yet</p>
<p className="mt-1 text-xs text-muted-foreground">Start the first live conversation here.</p>
</div>
) : (
<div>
{messages.map((event, index) => {
const previous = messages[index - 1];
const showAvatar = !previous
|| previous.pubkey !== event.pubkey
|| previous.created_at - event.created_at > 300;
return <CommunityChatMessage key={event.id} event={event} showAvatar={showAvatar} />;
})}
</div>
)}
</div>
</div>
);
}
function CommunityChatSkeleton() {
return (
<div className="space-y-4 px-2 py-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex items-start gap-3">
<Skeleton className="size-8 rounded-full" />
<div className="flex-1 space-y-2 pt-1">
<Skeleton className="h-3 w-24" />
<Skeleton className={cn('h-4', index % 2 === 0 ? 'w-4/5' : 'w-2/3')} />
</div>
</div>
))}
</div>
);
}
function CommunityChatMessage({ event, showAvatar }: { event: NostrEvent; showAvatar: boolean }) {
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const metadata: NostrMetadata | undefined = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const isOwnMessage = user?.pubkey === event.pubkey;
return (
<div
className={cn(
'group flex gap-3 px-4 py-3 transition-colors hover:bg-secondary/40',
!showAvatar && 'py-2',
isOwnMessage && 'justify-end',
)}
>
{!isOwnMessage && <ChatMessageAvatar showAvatar={showAvatar} profileUrl={profileUrl} metadata={metadata} displayName={displayName} createdAt={event.created_at} />}
<div className={cn('min-w-0 flex-1', isOwnMessage && 'flex flex-col items-end')}>
{showAvatar && (
<div className={cn('mb-0.5 flex items-baseline gap-2', isOwnMessage && 'justify-end')}>
<Link
to={profileUrl}
className={cn('truncate text-xs font-semibold text-primary hover:underline', isOwnMessage && 'order-2')}
onClick={(event) => event.stopPropagation()}
>
{displayName}
</Link>
<span className={cn('text-[10px] text-muted-foreground/60', isOwnMessage && 'order-1')}>{shortTimeAgo(event.created_at)}</span>
</div>
)}
<ContentWarningGuard event={event} className="w-full max-w-[64%] sm:max-w-xs">
<div
className={cn(
'inline-block w-fit max-w-[64%] break-words rounded-2xl px-3 py-2 text-sm leading-relaxed sm:max-w-xs',
isOwnMessage ? 'rounded-tr-md bg-primary text-primary-foreground text-right' : 'rounded-tl-md bg-secondary/60',
)}
>
<NoteContent event={event} disableNoteEmbeds />
</div>
</ContentWarningGuard>
</div>
{isOwnMessage && <ChatMessageAvatar showAvatar={showAvatar} profileUrl={profileUrl} metadata={metadata} displayName={displayName} createdAt={event.created_at} />}
</div>
);
}
function ChatMessageAvatar({
showAvatar,
profileUrl,
metadata,
displayName,
createdAt,
}: {
showAvatar: boolean;
profileUrl: string;
metadata: NostrMetadata | undefined;
displayName: string;
createdAt: number;
}) {
return (
<div className="w-8 shrink-0">
{showAvatar ? (
<Link to={profileUrl} onClick={(event) => event.stopPropagation()}>
<Avatar className="size-8">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/15 text-[10px] text-primary">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
) : (
<span className="hidden pt-0.5 text-[10px] text-muted-foreground/60 group-hover:block">
{shortTimeAgo(createdAt)}
</span>
)}
</div>
);
}
+85 -167
View File
@@ -2,7 +2,6 @@ import { useMemo, useCallback, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
Award,
CalendarDays,
ChevronLeft,
Crown,
@@ -13,7 +12,6 @@ import {
MoreVertical,
Pencil,
Shield,
ShieldBan,
Share2,
UserCheck,
UserMinus,
@@ -22,7 +20,6 @@ import {
} from 'lucide-react';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { AddMemberDialog } from '@/components/AddMemberDialog';
import { CampaignCard } from '@/components/CampaignCard';
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
import { PeopleAvatarStack } from '@/components/PeopleAvatarStack';
@@ -33,13 +30,10 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
import { BanConfirmDialog } from '@/components/BanConfirmDialog';
import { DonateDialog } from '@/components/DonateDialog';
import { NoteContent } from '@/components/NoteContent';
import { CommunityBadgeEditorDialog } from '@/components/CommunityBadgePanel';
import { FollowToggleButton } from '@/components/FollowButton';
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
@@ -51,7 +45,6 @@ import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventStats } from '@/hooks/useTrending';
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
import { useNow } from '@/hooks/useNow';
import {
useOrganizationCampaigns,
@@ -62,7 +55,7 @@ import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useToast } from '@/hooks/useToast';
import { CommunityModerationContext } from '@/contexts/CommunityModerationContext';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { applyCommunityModerationToEvents, canBanTarget, getViewerAuthority, parseCommunityEvent, type CommunityMember } from '@/lib/communityUtils';
import { applyCommunityModerationToEvents, parseCommunityEvent } from '@/lib/communityUtils';
import type { ParsedCampaign } from '@/lib/campaign';
import type { Action } from '@/hooks/useActions';
import { formatNumber } from '@/lib/formatNumber';
@@ -72,7 +65,7 @@ import { cn } from '@/lib/utils';
// ── Sub-components ────────────────────────────────────────────────────────────
function PersonRow({ pubkey, label, size = 'md', onBan }: { pubkey: string; label?: string; size?: 'sm' | 'md'; onBan?: () => void }) {
function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: string; size?: 'sm' | 'md' }) {
const { data } = useAuthor(pubkey);
const metadata: NostrMetadata | undefined = data?.metadata;
const name = metadata?.display_name || metadata?.name || genUserName(pubkey);
@@ -99,16 +92,6 @@ function PersonRow({ pubkey, label, size = 'md', onBan }: { pubkey: string; labe
<Badge variant="secondary" className="ml-auto capitalize text-xs shrink-0">{label}</Badge>
)}
</Link>
{onBan && (
<button
onClick={(e) => { e.stopPropagation(); onBan(); }}
className="p-1.5 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors shrink-0"
aria-label="Ban from community"
title="Ban from community"
>
<ShieldBan className="size-4" />
</button>
)}
</div>
);
}
@@ -419,10 +402,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const { user } = useCurrentUser();
const { btcPrice } = useBitcoinWallet();
// ── Member ban dialog state ────────────────────────────────────────────────
const [banDialogOpen, setBanDialogOpen] = useState(false);
const [banTargetPubkey, setBanTargetPubkey] = useState<string | null>(null);
// ── Tab + FAB state ────────────────────────────────────────────────────────
// ── FAB + dialog state ─────────────────────────────────────────────────────
// The detail page is single-column now (no tab strip), so the FAB is
@@ -433,8 +412,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
const [composeOpen, setComposeOpen] = useState(false);
const [eventDialogOpen, setEventDialogOpen] = useState(false);
const [membersDialogOpen, setMembersDialogOpen] = useState(false);
const [addMembersDialogOpen, setAddMembersDialogOpen] = useState(false);
const [badgeDialogOpen, setBadgeDialogOpen] = useState(false);
const [descriptionDialogOpen, setDescriptionDialogOpen] = useState(false);
const [donateOpen, setDonateOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
@@ -483,11 +460,19 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
}), [description, event.id, event.pubkey, event.created_at]);
// ── Members ─────────────────────────────────────────────────────────────────
const { data: membership, moderation, rankMap, isLoading: membersLoading } = useCommunityMembers(community);
const viewerMember = user ? getViewerAuthority(user.pubkey, rankMap, moderation) : undefined;
// Agora's organization model has only two trust tiers — founder and
// moderators. The "membership" list shown in the hero / members dialog
// is therefore exactly that roster, read directly from the parsed
// community. `useCommunityMembers` still resolves moderation state
// (content bans, reports) used by the comment thread below.
const { moderation, rankMap, isLoading: membersLoading } = useCommunityMembers(community);
const communityDonationTarget = useMemo<ParsedCampaign | null>(() => {
if (!community || !membership || membership.members.length === 0) return null;
if (!community) return null;
const recipients = [
{ pubkey: community.founderPubkey, weight: 1 },
...community.moderatorPubkeys.map((pubkey) => ({ pubkey, weight: 1 })),
];
return {
event,
pubkey: event.pubkey,
@@ -499,18 +484,16 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
image: community.image,
category: 'community',
tags: ['community'],
recipients: membership.members.map((member) => ({
pubkey: member.pubkey,
weight: 1,
})),
recipients,
createdAt: event.created_at,
archived: false,
};
}, [community, membership, event]);
}, [community, event]);
// Founder can add moderators + members; moderators (rank 0) can add members
// Only the founder can edit organization metadata. Moderators can
// moderate content via the community context but don't get the
// "Edit community" action.
const isFounder = !!user && user.pubkey === event.pubkey;
const canAddMembers = isFounder || (!!viewerMember && viewerMember.rank === 0);
// NIP-51 kind 10004 is the standard Communities list. In the UI this is
// presented as following a community.
@@ -519,41 +502,44 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
toggleBookmark: toggleCommunityFollow,
} = useCommunityBookmarks();
const savedCommunityFollow = !!communityATag && isCommunitySaved(communityATag);
const membershipFollow = isFounder || !!viewerMember;
const isModerator = !!user && community
? community.moderatorPubkeys.includes(user.pubkey)
: false;
const membershipFollow = isFounder || isModerator;
const communityFollowed = membershipFollow || savedCommunityFollow;
const handleToggleFollow = useCallback(() => {
if (!user || !communityATag || toggleCommunityFollow.isPending) return;
if (membershipFollow) {
toast({ title: isFounder ? 'You founded this community' : 'You are already a member of this community' });
toast({ title: isFounder ? 'You founded this organization' : 'You moderate this organization' });
return;
}
toggleCommunityFollow.mutate({ aTag: communityATag });
}, [user, communityATag, toggleCommunityFollow, membershipFollow, isFounder, toast]);
// Batch-fetch profiles for all members
const allMemberPubkeys = useMemo(
() => membership?.members.map((m) => m.pubkey) ?? [],
[membership],
);
useAuthors(allMemberPubkeys);
// Founder + moderator pubkeys for the avatar stack + members dialog.
// Founder always first; moderators in their listed order.
const leadershipPubkeys = useMemo<string[]>(() => {
if (!community) return [];
return [community.founderPubkey, ...community.moderatorPubkeys];
}, [community]);
useAuthors(leadershipPubkeys);
// Single section now — founder + moderators are all "Leadership".
// Members no longer exist in the organization model.
const memberSections = useMemo(() => {
if (!membership) return [];
const leadership: CommunityMember[] = [];
const members: CommunityMember[] = [];
for (const member of membership.members) {
if (member.rank === 0) leadership.push(member);
else members.push(member);
}
return [
{ key: 'leadership', label: 'Leadership', members: leadership },
{ key: 'members', label: 'Members', members },
].filter((section) => section.members.length > 0);
}, [membership]);
if (!community || leadershipPubkeys.length === 0) return [];
return [{
key: 'leadership',
label: 'Leadership',
members: leadershipPubkeys.map((pubkey) => ({
pubkey,
isFounder: pubkey === community.founderPubkey,
})),
}];
}, [community, leadershipPubkeys]);
// ── Comments (NIP-22 on the community event) ───────────────────────────────
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
const { membersOnly } = useMembersOnlyFilter();
// ── Official activity shelves ─────────────────────────────────────────────
// Author-filtered to founder + moderators (see useOrganizationActivity).
@@ -598,15 +584,11 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
if (!commentsData) return [];
const topLevel = commentsData.topLevelComments ?? [];
// Filter: omit banned events and posts by banned members, then optionally
// restrict to validated members when the "members only" toggle is
// active. The member filter is a presentation-layer opt-in — the NIP
// lists it as a MAY feature, so users default to seeing everything.
const applyModeration = (events: NostrEvent[]): NostrEvent[] => {
const moderated = applyCommunityModerationToEvents(events, moderation);
if (!membersOnly) return moderated;
return moderated.filter((ev) => rankMap.has(ev.pubkey));
};
// Filter: omit content-banned posts. Moderation is applied by founder
// and moderators only; non-moderator kind 1984 events are dropped at
// the resolver level so there's nothing to filter beyond bans here.
const applyModeration = (events: NostrEvent[]): NostrEvent[] =>
applyCommunityModerationToEvents(events, moderation);
const buildNode = (ev: NostrEvent): ReplyNode => {
const allChildren = applyModeration(commentsData.getDirectReplies(ev.id) ?? []);
@@ -627,7 +609,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
return applyModeration([...topLevel])
.sort((a, b) => b.created_at - a.created_at)
.map((r) => buildNode(r));
}, [commentsData, moderation, membersOnly, rankMap]);
}, [commentsData, moderation]);
// ── Share handler ───────────────────────────────────────────────────────────
const handleShare = useCallback(async () => {
@@ -762,17 +744,21 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
type="button"
onClick={() => setMembersDialogOpen(true)}
className="flex items-center gap-2 -ml-1 px-1 py-1 rounded-md hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 transition-colors min-w-0"
aria-label="Show all members"
aria-label="Show leadership"
>
<PeopleAvatarStack
pubkeys={allMemberPubkeys}
pubkeys={leadershipPubkeys}
maxVisible={6}
size="sm"
className="[&_.ring-2]:ring-black/40 pointer-events-none"
/>
{allMemberPubkeys.length > 0 && (
{leadershipPubkeys.length > 0 && (
<span className="text-xs font-medium text-white/90 [text-shadow:0_1px_3px_rgba(0,0,0,0.7)] truncate">
{allMemberPubkeys.length} member{allMemberPubkeys.length !== 1 ? 's' : ''}
{(() => {
const modCount = community?.moderatorPubkeys.length ?? 0;
if (modCount === 0) return 'Founder';
return `Founder + ${modCount} moderator${modCount === 1 ? '' : 's'}`;
})()}
</span>
)}
</button>
@@ -800,9 +786,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
</div>
<div className="flex items-center gap-0.5 shrink-0 [text-shadow:none]">
<MembersOnlyToggle
className="text-white/90 hover:text-white hover:bg-white/15 data-[state=on]:text-white"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
@@ -816,34 +799,22 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<DropdownMenuContent align="end" side="top" sideOffset={6} className="min-w-[180px]">
<DropdownMenuItem onSelect={() => setMembersDialogOpen(true)}>
<Users className="size-4 mr-2" />
View members
View leadership
</DropdownMenuItem>
{canAddMembers && community && (
<DropdownMenuItem onSelect={() => setAddMembersDialogOpen(true)}>
<UserPlus className="size-4 mr-2" />
Add members
</DropdownMenuItem>
)}
{isFounder && community && (
<>
<DropdownMenuItem onSelect={() => setBadgeDialogOpen(true)}>
<Award className="size-4 mr-2" />
Edit badge
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: community.dTag,
});
navigate(`/communities/new?edit=${naddr}`);
}}
>
<Pencil className="size-4 mr-2" />
Edit community
</DropdownMenuItem>
</>
<DropdownMenuItem
onSelect={() => {
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: community.dTag,
});
navigate(`/communities/new?edit=${naddr}`);
}}
>
<Pencil className="size-4 mr-2" />
Edit organization
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -960,14 +931,10 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
>
<p className="text-base font-medium text-foreground">
{membersOnly && (commentsData?.topLevelComments?.length ?? 0) > 0
? 'No comments from members yet'
: 'No comments yet'}
No comments yet
</p>
<p className="mt-1 text-sm text-muted-foreground">
{membersOnly && (commentsData?.topLevelComments?.length ?? 0) > 0
? 'Toggle the shield icon in the hero to see everything.'
: 'Be the first to start a discussion.'}
Be the first to start a discussion.
</p>
</button>
)}
@@ -1006,15 +973,15 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
</DialogContent>
</Dialog>
{/* Members dialog — opened from the avatar stack or overflow menu. */}
{/* Leadership dialog — opened from the avatar stack or overflow menu. */}
<Dialog open={membersDialogOpen} onOpenChange={setMembersDialogOpen}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col overflow-hidden p-0 gap-0">
<DialogHeader className="px-5 pt-5 pb-3 border-b border-border shrink-0">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5" />
Members
{allMemberPubkeys.length > 0 && (
<span className="text-muted-foreground font-normal text-sm">({allMemberPubkeys.length})</span>
Leadership
{leadershipPubkeys.length > 0 && (
<span className="text-muted-foreground font-normal text-sm">({leadershipPubkeys.length})</span>
)}
</DialogTitle>
</DialogHeader>
@@ -1024,7 +991,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<MembersSkeleton />
) : memberSections.length === 0 ? (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No members found.
No leadership found.
</div>
) : (
<div className="divide-y divide-border">
@@ -1036,27 +1003,14 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
<span className="text-muted-foreground/60 font-normal">({members.length})</span>
</h3>
<div className="space-y-0.5">
{members.map((m) => {
let roleLabel: string | undefined;
if (m.rank === 0) {
roleLabel = m.pubkey === event.pubkey ? 'Founder' : 'Moderator';
}
const canBanMember = viewerMember
&& m.pubkey !== user?.pubkey
&& canBanTarget(viewerMember, m);
return (
<PersonRow
key={m.pubkey}
pubkey={m.pubkey}
label={roleLabel}
size="sm"
onBan={canBanMember ? () => {
setBanTargetPubkey(m.pubkey);
setBanDialogOpen(true);
} : undefined}
/>
);
})}
{members.map((m) => (
<PersonRow
key={m.pubkey}
pubkey={m.pubkey}
label={m.isFounder ? 'Founder' : 'Moderator'}
size="sm"
/>
))}
</div>
</section>
))}
@@ -1066,42 +1020,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
</DialogContent>
</Dialog>
{/* Add members dialog — founder/moderator only. */}
{canAddMembers && community && (
<AddMemberDialog
open={addMembersDialogOpen}
onOpenChange={setAddMembersDialogOpen}
communityEvent={event}
community={community}
isFounder={isFounder}
existingMemberPubkeys={allMemberPubkeys}
/>
)}
{/* Member badge editor — founder only. */}
{isFounder && community && (
<CommunityBadgeEditorDialog
open={badgeDialogOpen}
onOpenChange={setBadgeDialogOpen}
communityEvent={event}
community={community}
/>
)}
{/* Member ban confirmation dialog */}
{banTargetPubkey && communityATag && (
<BanConfirmDialog
mode="member"
targetPubkey={banTargetPubkey}
communityATag={communityATag}
open={banDialogOpen}
onOpenChange={(open) => {
setBanDialogOpen(open);
if (!open) setBanTargetPubkey(null);
}}
/>
)}
{/* FAB-triggered compose modal — used by the \"New post\" FAB item.
Composes a NIP-22 reply against the community event itself. */}
<ReplyComposeModal
-176
View File
@@ -1,176 +0,0 @@
/**
* CommunityPulsePanel
*
* "Pulse" tab on the community detail page — an infinite-scrolling feed of
* posts published by community members *outside* this community. The intent
* is to surface what members are sharing in the wider Nostr ecosystem, as
* opposed to the in-community Activity tab.
*
* Implementation notes:
* - Authors come from the community `rankMap` (founders + moderators +
* members). Without authors the relay would return the entire global
* timeline.
* - Kinds come from `getEnabledFeedKinds(feedSettings)` so the feed
* respects the user's "Notes / Articles / Reposts / etc." preferences,
* exactly like the home feed.
* - Events tagged with this community's `a` reference are dropped — those
* belong on the Activity tab.
* - Replies (NIP-10 / NIP-22) are dropped so the Pulse reads like a
* timeline of top-level posts, not threaded responses.
* - Mute list, content-warning, and repost unwrap behavior come for free
* by reusing `useTabFeed` + the `feedUtils` helpers.
*/
import { useEffect, useMemo } from 'react';
import { useInView } from 'react-intersection-observer';
import { Loader2 } from 'lucide-react';
import type { NostrFilter } from '@nostrify/nostrify';
import { NoteCard } from '@/components/NoteCard';
import { FeedCard } from '@/components/FeedCard';
import { Skeleton } from '@/components/ui/skeleton';
import { useTabFeed } from '@/hooks/useProfileFeed';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { shouldHideFeedEvent } from '@/lib/feedUtils';
import { isReplyEvent } from '@/lib/nostrEvents';
interface CommunityPulsePanelProps {
/** `34550:<pubkey>:<d>` — used both for the cache key and the in-community filter. */
communityATag: string;
/** Author allowlist — founders + moderators + members. */
memberPubkeys: string[];
/** True while membership is still resolving; suppresses an empty-state flash. */
isMembershipLoading: boolean;
}
export function CommunityPulsePanel({
communityATag,
memberPubkeys,
isMembershipLoading,
}: CommunityPulsePanelProps) {
const { muteItems } = useMuteList();
const { ref: sentinelRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
// Build the TabFeed filter — kinds default to the user's enabled feed kinds
// (handled inside useTabFeed when `kinds` is omitted from the filter).
const filter = useMemo<NostrFilter | null>(
() => (memberPubkeys.length > 0 ? { authors: memberPubkeys } : null),
[memberPubkeys],
);
const {
data,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTabFeed(filter, `community-pulse-${communityATag}`, memberPubkeys.length > 0);
// Fetch next page when the sentinel scrolls into view.
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
/**
* Drop events that reference *this* community via an `a` tag — they belong
* to the Activity tab, not Pulse. We check both the original event and the
* embedded event of a repost.
*/
const referencesThisCommunity = (tags: string[][]): boolean => {
for (const tag of tags) {
if (tag[0] === 'a' && tag[1] === communityATag) return true;
}
return false;
};
// Flatten pages, dedupe, and apply mute / content-warning / reply /
// in-community filters.
const feedItems = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
return data.pages
.flatMap((page) => page.items)
.filter((item) => {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (seen.has(key)) return false;
seen.add(key);
if (shouldHideFeedEvent(item.event)) return false;
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
// Hide replies on original (non-repost) text notes; a repost of a
// reply is still a legitimate top-level surface.
if (item.event.kind === 1 && !item.repostedBy && isReplyEvent(item.event)) {
return false;
}
// Drop anything authored against this community — that's Activity.
if (referencesThisCommunity(item.event.tags)) return false;
if (item.repostEvent && referencesThisCommunity(item.repostEvent.tags)) return false;
return true;
});
// `referencesThisCommunity` and `communityATag` referenced via closure —
// adding `communityATag` to deps is sufficient.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.pages, muteItems, communityATag]);
// ── States ────────────────────────────────────────────────────────────────
if (memberPubkeys.length === 0 && !isMembershipLoading) {
return (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No community members yet nothing to surface here.
</div>
);
}
if ((isLoading || isMembershipLoading) && feedItems.length === 0) {
return (
<FeedCard className="mx-0 sm:mx-0 mt-2 divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</FeedCard>
);
}
if (feedItems.length === 0) {
return (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No posts from community members elsewhere yet.
</div>
);
}
return (
<>
<FeedCard className="mx-0 sm:mx-0 mt-2">
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
</FeedCard>
{hasNextPage && (
<div ref={sentinelRef} className="flex justify-center py-6">
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
</div>
)}
</>
);
}
-331
View File
@@ -1,331 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Users, Loader2 } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
import { BADGE_DEFINITION_KIND, COMMUNITY_DEFINITION_KIND, type ParsedCommunity } from '@/lib/communityUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Convert text into a URL-safe slug for the d-tag identifier. */
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// ── Types ─────────────────────────────────────────────────────────────────────
interface CreateCommunityDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Existing community event when editing. Omit to create a new community. */
communityEvent?: NostrEvent;
/** Parsed existing community data when editing. */
community?: ParsedCommunity;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CreateCommunityDialog({ open, onOpenChange, communityEvent, community }: CreateCommunityDialogProps) {
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isEditing = !!communityEvent && !!community;
// Form state
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
// Mutations
const { mutateAsync: publishEvent } = useNostrPublish();
// Derived
const effectiveSlug = isEditing && community ? community.dTag : slugify(name);
const populateFromCommunity = useCallback(() => {
setName(community?.name ?? '');
setDescription(community?.description ?? '');
setImageUrl(community?.image ?? '');
setIsPublishing(false);
setIsImageUploading(false);
}, [community]);
const resetForm = useCallback(() => {
if (isEditing) {
populateFromCommunity();
} else {
setName('');
setDescription('');
setImageUrl('');
setIsPublishing(false);
setIsImageUploading(false);
}
}, [isEditing, populateFromCommunity]);
useEffect(() => {
if (open && isEditing) {
populateFromCommunity();
}
}, [open, isEditing, populateFromCommunity]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
const buildUpdatedCommunityTags = useCallback((baseTags: string[][]): string[][] => {
const tags = baseTags.filter(([name]) => !['d', 'name', 'description', 'image', 'alt'].includes(name));
const nextTags: string[][] = [
['d', effectiveSlug],
['name', name.trim()],
];
if (description.trim()) {
nextTags.push(['description', description.trim()]);
}
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (sanitizedImage) {
nextTags.push(['image', sanitizedImage]);
}
nextTags.push(...tags);
nextTags.push(['alt', `Community: ${name.trim()}`]);
return nextTags;
}, [description, effectiveSlug, imageUrl, name]);
// ── Publish ───────────────────────────────────────────────────────────────
const handleCreate = useCallback(async () => {
if (!user || !name.trim() || !effectiveSlug) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
if (imageUrl.trim() && !sanitizeUrl(imageUrl.trim())) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
setIsPublishing(true);
try {
if (isEditing && communityEvent && community) {
const prev = await fetchFreshEvent(nostr, {
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [communityEvent.pubkey],
'#d': [community.dTag],
});
const updatedEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: prev?.content ?? communityEvent.content,
tags: buildUpdatedCommunityTags(prev?.tags ?? communityEvent.tags),
prev: prev ?? undefined,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & { prev?: NostrEvent });
queryClient.setQueryData(
['addr-event', COMMUNITY_DEFINITION_KIND, communityEvent.pubkey, community.dTag],
updatedEvent,
);
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'], exact: false });
queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false });
toast({ title: 'Community updated!' });
handleOpenChange(false);
return;
}
// Check for d-tag collision (same author, same kind, same d-tag)
const existing = await nostr.query([{
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [effectiveSlug],
limit: 1,
}]);
if (existing.length > 0) {
toast({
title: 'Name already in use',
description: 'You already have a community with this name. Please choose a different name.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeDTag = `${effectiveSlug}-member`;
const existingBadge = await nostr.query([{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
}]);
if (existingBadge.length > 0) {
toast({
title: 'Member badge ID already in use',
description: 'Choose a different community name so the member badge can be created safely.',
variant: 'destructive',
});
setIsPublishing(false);
return;
}
const badgeEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${name.trim()}`],
['alt', `Badge definition: Member of ${name.trim()}`],
],
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Founder as moderator (p tag) plus one flat member badge reference.
const communityTags = buildUpdatedCommunityTags([
['a', `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`, '', 'member'],
['p', user.pubkey, '', 'moderator'],
]);
// Publish community definition (kind 34550)
const createdEvent = await publishEvent({
kind: COMMUNITY_DEFINITION_KIND,
content: '',
tags: communityTags,
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
// Navigate to the new community
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
pubkey: createdEvent.pubkey,
identifier: effectiveSlug,
});
toast({ title: 'Community created!' });
handleOpenChange(false);
navigate(`/${naddr}`);
} catch (err) {
toast({
title: isEditing ? 'Failed to update community' : 'Failed to create community',
description: err instanceof Error ? err.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsPublishing(false);
}
}, [
user, name, effectiveSlug, isEditing, communityEvent, community, nostr, isImageUploading, imageUrl,
publishEvent, buildUpdatedCommunityTags, queryClient, toast, handleOpenChange, navigate,
]);
if (!user) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg gap-0 p-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Users className="size-5 text-primary" />
{isEditing ? 'Edit Community' : 'Create a Community'}
</DialogTitle>
<DialogDescription>
{isEditing
? 'Update the name, image, and description. Moderators are preserved.'
: "Start a new community on Nostr. You'll be the founder."}
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[calc(100vh-9rem)] sm:max-h-none">
<div className="px-5 pb-5 space-y-4">
{/* Community name */}
<div className="space-y-1.5">
<Label htmlFor="community-name">Community Name *</Label>
<Input
id="community-name"
placeholder="e.g. The Arbiter's Guard"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
/>
{name.trim() && (
<p className="text-xs text-muted-foreground font-mono">
ID: {effectiveSlug || '...'}{isEditing ? ' (unchanged)' : ''}
</p>
)}
</div>
<ImageUploadField
id="community-image"
label={<>Community Image <span className="text-muted-foreground font-normal">(recommended)</span></>}
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Community image preview"
dropAreaClassName="min-h-32"
/>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="community-description">
Description
<span className="text-muted-foreground font-normal ml-1">(recommended)</span>
</Label>
<Textarea
id="community-description"
placeholder="What is this community about?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
{/* Submit button */}
<Button
onClick={handleCreate}
disabled={!name.trim() || !effectiveSlug || isPublishing || isImageUploading}
className="w-full gap-2"
>
{isPublishing ? (
<><Loader2 className="size-4 animate-spin" /> {isEditing ? 'Saving...' : 'Creating...'}</>
) : (
<><Users className="size-4" /> {isEditing ? 'Save Changes' : 'Create Community'}</>
)}
</Button>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
-61
View File
@@ -1,61 +0,0 @@
import { Shield, ShieldOff } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
import { cn } from '@/lib/utils';
interface MembersOnlyToggleProps {
/** Additional classes for the trigger button. */
className?: string;
}
/**
* Shield-icon toggle that controls the "members only" filter for community
* surfaces. When active, community feeds only show content authored by
* validated members. When inactive (default), the feed shows every event
* scoped to the community regardless of author.
*
* Per the flat-communities spec, members-only is a MAY feature — the
* protocol makes no recommendation, so the toggle is an opt-in UX choice.
*
* The preference is persisted in localStorage via `useMembersOnlyFilter` and
* is global across community surfaces (Activities feed, per-community
* Comments tab, etc.).
*/
export function MembersOnlyToggle({ className }: MembersOnlyToggleProps) {
const { membersOnly, toggle } = useMembersOnlyFilter();
const label = membersOnly ? 'Showing members only' : 'Showing everyone';
const hint = membersOnly
? 'Click to show posts from anyone scoped to this community.'
: 'Click to limit posts to validated community members.';
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggle}
aria-pressed={membersOnly}
aria-label={label}
className={cn(
'p-2 rounded-full transition-colors',
membersOnly
? 'text-primary hover:bg-primary/10'
: 'text-muted-foreground hover:bg-secondary',
className,
)}
>
{membersOnly
? <Shield className="size-5" />
: <ShieldOff className="size-5" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[220px] text-center">
<p className="text-xs font-medium">{label}</p>
<p className="text-xs text-muted-foreground mt-0.5">{hint}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+14 -39
View File
@@ -17,7 +17,6 @@ import {
Check,
Radio,
ShieldBan,
Ban,
} from 'lucide-react';
import {
Dialog,
@@ -206,7 +205,6 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
// These states live here (not in NoteMoreMenuContent) so they persist after the menu closes
const [reportOpen, setReportOpen] = useState(false);
const [banContentOpen, setBanContentOpen] = useState(false);
const [banMemberOpen, setBanMemberOpen] = useState(false);
const [addToListOpen, setAddToListOpen] = useState(false);
const [eventJsonOpen, setEventJsonOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -262,10 +260,6 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
onOpenChange(false);
setTimeout(() => setBanContentOpen(true), 150);
}}
onBanMember={() => {
onOpenChange(false);
setTimeout(() => setBanMemberOpen(true), 150);
}}
onAddToList={() => {
onOpenChange(false);
setTimeout(() => setAddToListOpen(true), 150);
@@ -293,23 +287,13 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
)}
{communityContext?.canBan && (
<>
<BanConfirmDialog
mode="content"
eventId={event.id}
targetPubkey={event.pubkey}
communityATag={communityContext.communityATag}
open={banContentOpen}
onOpenChange={setBanContentOpen}
/>
<BanConfirmDialog
mode="member"
targetPubkey={event.pubkey}
communityATag={communityContext.communityATag}
open={banMemberOpen}
onOpenChange={setBanMemberOpen}
/>
</>
<BanConfirmDialog
eventId={event.id}
targetPubkey={event.pubkey}
communityATag={communityContext.communityATag}
open={banContentOpen}
onOpenChange={setBanContentOpen}
/>
)}
<AddToListDialog
@@ -357,13 +341,12 @@ interface NoteMoreMenuContentProps extends NoteMoreMenuProps {
communityContext?: CommunityMenuContext;
onReport: () => void;
onBanContent: () => void;
onBanMember: () => void;
onAddToList: () => void;
onViewEventJson: () => void;
onDelete: () => void;
}
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onBanMember, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onReport, onBanContent, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) {
const navigate = useNavigate();
const { user } = useCurrentUser();
const { isBookmarked, toggleBookmark } = useBookmarks();
@@ -598,20 +581,12 @@ function NoteMoreMenuContent({ event, open, onOpenChange, communityContext, onRe
/>
)}
{!isOwnPost && communityContext?.canBan && (
<>
<MenuItem
icon={<ShieldBan className="size-5" />}
label="Remove from community"
onClick={onBanContent}
destructive
/>
<MenuItem
icon={<Ban className="size-5" />}
label={`Ban @${displayName} from community`}
onClick={onBanMember}
destructive
/>
</>
<MenuItem
icon={<ShieldBan className="size-5" />}
label="Remove from community"
onClick={onBanContent}
destructive
/>
)}
{isOwnPost && (
<MenuItem
+237
View File
@@ -0,0 +1,237 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { Loader2, Search, PartyPopper } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useToast } from '@/hooks/useToast';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { useSearchPeopleLists, type PeopleListSearchResult } from '@/hooks/useSearchPeopleLists';
import { parseAuthorEvent } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
function isHexPubkey(value: string): boolean {
return /^[0-9a-f]{64}$/i.test(value);
}
function makeFallbackProfile(pubkey: string): SearchProfile {
return {
pubkey,
metadata: {},
event: {
id: '',
pubkey,
created_at: 0,
kind: 0,
tags: [],
content: '{}',
sig: '',
},
};
}
function profileFromEvent(event: NostrEvent): SearchProfile {
const parsed = parseAuthorEvent(event);
return { pubkey: event.pubkey, metadata: parsed.metadata ?? {}, event };
}
/** Inline type-ahead person search. */
export function PersonSearch({
onAdd,
onAddMany,
excludePubkeys,
}: {
onAdd: (profile: SearchProfile) => void;
onAddMany: (profiles: SearchProfile[], sourceTitle?: string) => void;
excludePubkeys: string[];
}) {
const { nostr } = useNostr();
const { toast } = useToast();
const [query, setQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isAddingPack, setIsAddingPack] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data: profiles, isFetching } = useSearchProfiles(query);
const { data: peopleLists, isFetching: isFetchingPeopleLists } = useSearchPeopleLists(query);
const excludeSet = useMemo(() => new Set(excludePubkeys), [excludePubkeys]);
const filteredProfiles = useMemo(
() => (profiles ?? []).filter((p) => !excludeSet.has(p.pubkey)),
[profiles, excludeSet],
);
const filteredPeopleLists = useMemo(
() => (peopleLists ?? []).filter((pack) => pack.pubkeys.some((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey.toLowerCase()))),
[peopleLists, excludeSet],
);
const hasResults = filteredProfiles.length > 0 || filteredPeopleLists.length > 0;
const isSearching = isFetching || isFetchingPeopleLists || isAddingPack;
useEffect(() => {
if (query.trim().length > 0 && hasResults) {
setDropdownOpen(true);
} else if (query.trim().length === 0) {
setDropdownOpen(false);
}
}, [hasResults, query]);
const handleSelect = useCallback((profile: SearchProfile) => {
onAdd(profile);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
}, [onAdd]);
const handleSelectPeopleList = useCallback(async (pack: PeopleListSearchResult) => {
const eligiblePubkeys = Array.from(new Set(
pack.pubkeys
.map((pubkey) => pubkey.toLowerCase())
.filter((pubkey) => isHexPubkey(pubkey) && !excludeSet.has(pubkey)),
));
if (eligiblePubkeys.length === 0) {
toast({ title: 'No new people to add', description: 'Everyone in that follow pack is already included.' });
return;
}
if (eligiblePubkeys.length > 20 && !window.confirm(`Add ${eligiblePubkeys.length} people from ${pack.title}?`)) {
return;
}
setIsAddingPack(true);
try {
const events = await nostr.query(
[{ kinds: [0], authors: eligiblePubkeys, limit: eligiblePubkeys.length }],
{ signal: AbortSignal.timeout(8000) },
);
const latestByPubkey = new Map<string, NostrEvent>();
for (const event of events) {
const existing = latestByPubkey.get(event.pubkey);
if (!existing || event.created_at > existing.created_at) latestByPubkey.set(event.pubkey, event);
}
const profilesToAdd = eligiblePubkeys.map((pubkey) => {
const event = latestByPubkey.get(pubkey);
return event ? profileFromEvent(event) : makeFallbackProfile(pubkey);
});
onAddMany(profilesToAdd, pack.title);
setQuery('');
setDropdownOpen(false);
inputRef.current?.focus();
} catch (error) {
toast({
title: 'Failed to load follow pack members',
description: error instanceof Error ? error.message : 'Please try again.',
variant: 'destructive',
});
} finally {
setIsAddingPack(false);
}
}, [excludeSet, nostr, onAddMany, toast]);
return (
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger asChild>
<div className="relative flex items-center">
<Search className="absolute left-3 size-4 text-muted-foreground pointer-events-none" />
{isSearching && query.trim() && (
<Loader2 className="absolute right-3 size-4 text-muted-foreground animate-spin" />
)}
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => {
if (query.trim().length > 0 && hasResults) {
setDropdownOpen(true);
}
}}
placeholder="Search people..."
className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm"
autoComplete="off"
/>
</div>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
onOpenAutoFocus={(e) => e.preventDefault()}
className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden"
>
{hasResults ? (
<div className="max-h-[200px] overflow-y-auto py-1">
{filteredProfiles.map((profile) => (
<SearchResultItem key={profile.pubkey} profile={profile} onClick={handleSelect} />
))}
{filteredPeopleLists.map((pack) => (
<PeopleListSearchResultItem key={`${pack.event.kind}:${pack.event.pubkey}:${pack.event.tags.find(([name]) => name === 'd')?.[1] ?? pack.event.id}`} pack={pack} onClick={handleSelectPeopleList} />
))}
</div>
) : query.trim().length >= 2 && !isSearching ? (
<div className="py-4 text-center text-sm text-muted-foreground">
No people or follow packs found
</div>
) : null}
</PopoverContent>
</Popover>
);
}
/** A follow pack / follow set search result row. */
function PeopleListSearchResultItem({ pack, onClick }: { pack: PeopleListSearchResult; onClick: (pack: PeopleListSearchResult) => void }) {
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(pack)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-8 shrink-0 rounded-full bg-primary/10 text-primary flex items-center justify-center">
<PartyPopper className="size-4" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">{pack.title}</span>
<span className="text-xs text-muted-foreground truncate block">
Follow pack · {pack.pubkeys.length} people
</span>
</div>
</button>
);
}
/** A profile search result row. */
function SearchResultItem({ profile, onClick }: { profile: SearchProfile; onClick: (profile: SearchProfile) => void }) {
const { metadata, pubkey } = profile;
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
return (
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
onClick={() => onClick(profile)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar className="size-8 shrink-0">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{metadata.nip05 && (
<span className="text-xs text-muted-foreground truncate block">
{metadata.nip05.startsWith('_@') ? metadata.nip05.slice(2) : metadata.nip05}
</span>
)}
</div>
</button>
);
}
-320
View File
@@ -1,320 +0,0 @@
import { useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useMyCommunities } from './useMyCommunities';
import {
type CommunityMember,
type CommunityModeration,
BADGE_AWARD_KIND,
COMMUNITY_DEFINITION_KIND,
REPORT_KIND,
isEventAllowedByModeration,
resolveCommunityModeration,
resolveMembership,
} from '@/lib/communityUtils';
import { ZAP_GOAL_KIND } from '@/lib/goalUtils';
import { getPaginationCursor } from '@/lib/feedUtils';
import { queryAll } from '@/lib/queryAll';
/** Internal result type — events plus per-community moderation/membership data. */
interface ActivityFeedResult {
events: NostrEvent[];
/** Moderation data keyed by community A tag. */
moderationByATag: Map<string, CommunityModeration>;
/** Flat authority maps keyed by community A tag (pre-moderation, for authority checks). */
rankMapByATag: Map<string, Map<string, CommunityMember>>;
/** Cursor for the next comments page, when comments still have more events. */
commentsNextUntil?: number;
/** Cursor for the next goals page, when goals still have more events. */
goalsNextUntil?: number;
/** Cursor for the next actions page, when actions still have more events. */
actionsNextUntil?: number;
/** Whether comments still have more events. */
hasMoreComments: boolean;
/** Whether goals still have more events. */
hasMoreGoals: boolean;
/** Whether actions still have more events. */
hasMoreActions: boolean;
}
interface ActivityFeedPageParam {
includeComments: boolean;
includeGoals: boolean;
includeActions: boolean;
commentsUntil?: number;
goalsUntil?: number;
actionsUntil?: number;
}
const EMPTY_MODERATION_BY_A_TAG: ReadonlyMap<string, CommunityModeration> = new Map();
const EMPTY_RANK_MAP_BY_A_TAG: ReadonlyMap<string, Map<string, CommunityMember>> = new Map();
const ACTIVITY_PAGE_SIZE = 100;
const INITIAL_PAGE_PARAM: ActivityFeedPageParam = {
includeComments: true,
includeGoals: true,
includeActions: true,
};
/**
* Fetches a chronological activity feed for communities the current user
* belongs to (founded or joined).
*
* The feed merges:
* 1. Kind 34550 community definition events for the user's communities
* 2. Kind 1111 NIP-22 comments scoped to those communities (via #A tag)
* 3. Kind 36639 actions scoped to those communities (via #A tag)
*
* Community moderation (kind 1984 bans) is applied per-community: events
* from banned members and individually banned posts are filtered out.
* Bans are scoped — a member banned in community A is only filtered from
* community A's posts, not from community B.
*
* Sorted by created_at descending.
*
* Also returns per-community `moderationByATag` and `rankMapByATag` so
* callers can provide `CommunityModerationContext` to `NoteMoreMenu`.
*/
export function useCommunityActivityFeed(enabled = true) {
const { nostr } = useNostr();
const queryClient = useQueryClient();
const { data: myCommunities, isLoading: communitiesLoading } = useMyCommunities();
const aTags = myCommunities?.map((c) => c.community.aTag).filter(Boolean) ?? [];
const aTagsKey = aTags.join(',');
const query = useInfiniteQuery<ActivityFeedResult, Error>({
queryKey: ['community-activity-feed', aTagsKey],
queryFn: async ({ pageParam, signal }) => {
if (aTags.length === 0 || !myCommunities) {
return {
events: [],
moderationByATag: new Map(),
rankMapByATag: new Map(),
hasMoreComments: false,
hasMoreGoals: false,
hasMoreActions: false,
};
}
const timeout = AbortSignal.timeout(8_000);
const combinedSignal = AbortSignal.any([signal, timeout]);
const page = (pageParam as ActivityFeedPageParam | undefined) ?? INITIAL_PAGE_PARAM;
const awardFilters = myCommunities
.filter((entry) => !!entry.community.memberBadgeATag)
.map((entry) => ({
kinds: [BADGE_AWARD_KIND],
authors: [entry.community.founderPubkey, ...entry.community.moderatorPubkeys],
'#a': [entry.community.memberBadgeATag!],
limit: 500,
}));
// Fetch community definitions, comments, membership awards, goals, and actions in parallel.
// Awards are exhausted per-community with `queryAll` so every community's
// membership is complete, regardless of how many communities the user
// belongs to. See src/lib/queryAll.ts.
const [definitionEvents, comments, awards, goals, actions] = await Promise.all([
// The community definitions themselves
nostr.query(
[{
kinds: [COMMUNITY_DEFINITION_KIND],
authors: myCommunities.map((c) => c.event.pubkey),
'#d': myCommunities.map((c) => c.community.dTag),
limit: 50,
}],
{ signal: combinedSignal },
),
// Kind 1111 comments scoped to these communities via uppercase A tag
page.includeComments
? nostr.query(
[{
kinds: [1111],
'#A': aTags,
limit: ACTIVITY_PAGE_SIZE,
...(page.commentsUntil ? { until: page.commentsUntil } : {}),
}],
{ signal: combinedSignal },
)
: Promise.resolve([] as NostrEvent[]),
// Flat membership awards, one exhaustive query per community.
awardFilters.length > 0
? Promise.all(
awardFilters.map((f) => queryAll(nostr, f, { signal: combinedSignal })),
).then((pages) => pages.flat())
: Promise.resolve([] as NostrEvent[]),
// NIP-75 zap goals linked to these communities (lowercase a tag)
page.includeGoals
? nostr.query(
[{
kinds: [ZAP_GOAL_KIND],
'#a': aTags,
limit: ACTIVITY_PAGE_SIZE,
...(page.goalsUntil ? { until: page.goalsUntil } : {}),
}],
{ signal: combinedSignal },
)
: Promise.resolve([] as NostrEvent[]),
// Activist actions linked to these communities (uppercase A tag)
page.includeActions
? nostr.query(
[{
kinds: [36639],
'#A': aTags,
limit: ACTIVITY_PAGE_SIZE,
...(page.actionsUntil ? { until: page.actionsUntil } : {}),
}],
{ signal: combinedSignal },
)
: Promise.resolve([] as NostrEvent[]),
]);
// ── Resolve membership and moderation per community ──
// Membership is resolved for all communities so callers can provide
// CommunityModerationContext (for NoteMoreMenu ban actions).
// Bans are community-scoped: a member banned in community A should
// only be filtered from community A's posts, not from community B.
//
// We do **not** seed the `['community-members', aTag]` cache from
// this hook. Even with exhaustive `queryAll` paging, the per-community
// fetch in `useCommunityMembers` may apply different filters or
// trigger a fresh read; keeping it authoritative avoids stale writes.
const rankMapByATag = new Map<string, Map<string, CommunityMember>>();
const reportAuthorSet = new Set<string>();
for (const entry of myCommunities) {
const community = entry.community;
// Resolve flat membership for this community.
const fullMembership = resolveMembership(community, awards);
const rankMap = new Map<string, CommunityMember>();
for (const m of fullMembership.members) {
rankMap.set(m.pubkey, m);
reportAuthorSet.add(m.pubkey);
}
rankMapByATag.set(community.aTag, rankMap);
}
const reports = reportAuthorSet.size > 0
? await queryAll(
nostr,
{ kinds: [REPORT_KIND], authors: [...reportAuthorSet], '#A': aTags, limit: 500 },
{ signal: combinedSignal },
)
: [];
const moderationByATag = new Map<string, CommunityModeration>();
for (const entry of myCommunities) {
const community = entry.community;
const rankMap = rankMapByATag.get(community.aTag) ?? new Map<string, CommunityMember>();
// Resolve moderation. The resolver filters `reports` by matching
// `A` tag internally, so we can pass the full cross-community
// array without pre-grouping.
const moderation = resolveCommunityModeration(community.aTag, reports, rankMap);
if (moderation.allReports.length > 0) {
moderationByATag.set(community.aTag, moderation);
}
}
// ── Check whether an event survives moderation in its community ──
const isAllowed = (event: NostrEvent): boolean => {
// NIP-22 comments and actions use uppercase A; goals use lowercase a with a 34550: prefix
const eventATag = event.tags.find(([n]) => n === 'A')?.[1]
?? event.tags.find(([n, v]) => n === 'a' && v?.startsWith('34550:'))?.[1];
if (!eventATag) return true; // No community scope — not bannable here
const moderation = moderationByATag.get(eventATag);
if (!moderation) return true; // No moderation data for this community
return isEventAllowedByModeration(event, moderation);
};
// ── Merge, deduplicate, and filter ──
const knownCommunityATags = new Set(aTags);
const seen = new Set<string>();
const merged: NostrEvent[] = [];
for (const event of [...definitionEvents, ...comments, ...goals, ...actions]) {
if (seen.has(event.id)) continue;
seen.add(event.id);
if (event.kind === COMMUNITY_DEFINITION_KIND) {
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (!dTag || !knownCommunityATags.has(`${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`)) continue;
}
if (!isAllowed(event)) continue;
merged.push(event);
}
// Sort by created_at descending
merged.sort((a, b) => b.created_at - a.created_at);
const hasMoreComments = page.includeComments && comments.length === ACTIVITY_PAGE_SIZE;
const hasMoreGoals = page.includeGoals && goals.length === ACTIVITY_PAGE_SIZE;
const hasMoreActions = page.includeActions && actions.length === ACTIVITY_PAGE_SIZE;
// Seed the ['event', id] cache so embedded previews (quotes, reply
// context, etc.) resolve instantly instead of refetching.
for (const event of merged) {
if (!queryClient.getQueryData(['event', event.id])) {
queryClient.setQueryData(['event', event.id], event);
}
}
return {
events: merged,
moderationByATag,
rankMapByATag,
commentsNextUntil: hasMoreComments ? getPaginationCursor(comments) - 1 : undefined,
goalsNextUntil: hasMoreGoals ? getPaginationCursor(goals) - 1 : undefined,
actionsNextUntil: hasMoreActions ? getPaginationCursor(actions) - 1 : undefined,
hasMoreComments,
hasMoreGoals,
hasMoreActions,
};
},
getNextPageParam: (lastPage) => {
if (!lastPage.hasMoreComments && !lastPage.hasMoreGoals && !lastPage.hasMoreActions) return undefined;
return {
includeComments: lastPage.hasMoreComments,
includeGoals: lastPage.hasMoreGoals,
includeActions: lastPage.hasMoreActions,
commentsUntil: lastPage.commentsNextUntil,
goalsUntil: lastPage.goalsNextUntil,
actionsUntil: lastPage.actionsNextUntil,
} satisfies ActivityFeedPageParam;
},
initialPageParam: INITIAL_PAGE_PARAM,
enabled: enabled && !communitiesLoading && aTags.length > 0,
staleTime: 2 * 60_000,
gcTime: 30 * 60_000,
placeholderData: (prev) => prev,
refetchOnWindowFocus: false,
});
return useMemo(() => {
const pages = query.data?.pages ?? [];
const seen = new Set<string>();
const events = pages
.flatMap((page) => page.events)
.filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
return true;
})
.sort((a, b) => b.created_at - a.created_at);
const latestPage = pages[pages.length - 1];
return {
data: query.data ? events : undefined,
moderationByATag: (latestPage?.moderationByATag ?? EMPTY_MODERATION_BY_A_TAG) as Map<string, CommunityModeration>,
rankMapByATag: (latestPage?.rankMapByATag ?? EMPTY_RANK_MAP_BY_A_TAG) as Map<string, Map<string, CommunityMember>>,
isLoading: enabled && (communitiesLoading || query.isLoading),
isError: query.isError,
error: query.error,
hasNextPage: query.hasNextPage,
isFetchingNextPage: query.isFetchingNextPage,
fetchNextPage: query.fetchNextPage,
};
}, [query.data, enabled, communitiesLoading, query.isLoading, query.isError, query.error, query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
}
-83
View File
@@ -1,83 +0,0 @@
import { useEffect, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { applyCommunityModerationToEvents, type CommunityModeration } from '@/lib/communityUtils';
export const COMMUNITY_CHAT_KIND = 1311;
function isCommunityChatMessage(event: NostrEvent, communityATag: string): boolean {
return event.kind === COMMUNITY_CHAT_KIND
&& event.tags.some(([name, value]) => name === 'a' && value === communityATag);
}
export function useCommunityChatMessages(
communityATag: string | undefined,
moderation?: CommunityModeration,
) {
const { nostr } = useNostr();
const queryClient = useQueryClient();
const queryKey = useMemo(() => ['community-chat', communityATag ?? ''], [communityATag]);
const query = useQuery<NostrEvent[]>({
queryKey,
queryFn: async ({ signal }) => {
if (!communityATag) return [];
const events = await nostr.query(
[{ kinds: [COMMUNITY_CHAT_KIND], '#a': [communityATag], limit: 100 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8_000)]) },
);
return events
.filter((event) => isCommunityChatMessage(event, communityATag))
.sort((a, b) => b.created_at - a.created_at);
},
enabled: !!communityATag,
staleTime: 10_000,
});
useEffect(() => {
if (!communityATag) return;
const controller = new AbortController();
const since = Math.floor(Date.now() / 1000);
(async () => {
try {
for await (const msg of nostr.req(
[{ kinds: [COMMUNITY_CHAT_KIND], '#a': [communityATag], since }],
{ signal: controller.signal },
)) {
if (msg[0] !== 'EVENT') continue;
const event = msg[2] as NostrEvent;
if (!isCommunityChatMessage(event, communityATag)) continue;
queryClient.setQueryData<NostrEvent[]>(queryKey, (old = []) => {
if (old.some((existing) => existing.id === event.id)) return old;
return [...old, event].sort((a, b) => b.created_at - a.created_at);
});
}
} catch (error) {
if (!controller.signal.aborted) {
console.error('Community chat subscription failed:', error);
}
}
})();
return () => controller.abort();
}, [nostr, communityATag, queryClient, queryKey]);
const moderatedMessages = useMemo(() => {
const messages = query.data ?? [];
return moderation ? applyCommunityModerationToEvents(messages, moderation) : messages;
}, [query.data, moderation]);
return {
...query,
data: moderatedMessages,
queryKey,
};
}
+26 -39
View File
@@ -7,30 +7,35 @@ import {
type CommunityMembership,
type CommunityModeration,
type ParsedCommunity,
BADGE_AWARD_KIND,
EMPTY_MEMBERSHIP,
EMPTY_MODERATION,
EMPTY_RANK_MAP,
REPORT_KIND,
resolveCommunityModeration,
resolveMembership,
} from '@/lib/communityUtils';
import { queryAll } from '@/lib/queryAll';
interface CommunityMembersResult {
/** Resolved membership with banned members removed. Use `members` to list active community members. */
/** Founder + moderators of the organization. */
membership: CommunityMembership;
/** Resolved moderation data (bans, reports, content warnings). */
/** Resolved moderation data (content bans and soft reports). */
moderation: CommunityModeration;
/** Flat authority lookup before moderation overlay. Includes banned members. Used for authority checks only — do NOT use to list active members. */
/** Flat authority lookup keyed by pubkey. Includes founder + every moderator. */
rankMap: Map<string, CommunityMember>;
}
/**
* Fetch and resolve flat membership and moderation state for a community.
* Resolve the founder/moderator roster and active moderation state for an
* organization.
*
* Queries founder/moderator-authored membership awards (kind 8), then
* queries member-authored reports and bans (kind 1984).
* Agora's organization model has only two trust levels — founder (kind
* 34550 author) and moderators (`p` tags with `moderator` role on that
* event). Both are read directly from the parsed community; no separate
* relay query is needed for membership.
*
* The hook still queries kind 1984 moderation events scoped to this
* organization so the UI can hide content-banned posts and surface soft
* reports as content warnings.
*/
export function useCommunityMembers(community: ParsedCommunity | null | undefined) {
const { nostr } = useNostr();
@@ -48,47 +53,30 @@ export function useCommunityMembers(community: ParsedCommunity | null | undefine
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(10_000)]);
const awardAuthors = [community.founderPubkey, ...community.moderatorPubkeys];
// Exhaustive paging: awards and reports are unbounded sets that grow
// with the community. `queryAll` pages with `until` until the relay
// drains, capped at 5_000 events / 10 pages so worst-case cost is
// bounded. See src/lib/queryAll.ts.
const awards = community.memberBadgeATag
? await queryAll(
nostr,
{ kinds: [BADGE_AWARD_KIND], authors: awardAuthors, '#a': [community.memberBadgeATag], limit: 500 },
{ signal: combinedSignal },
)
: [];
// Step 1-2: Resolve full membership (needed for authority checks)
const fullMembership = resolveMembership(community, awards);
// Build authority lookup for checks (includes members even if later banned).
// Authority roster: founder (rank 0) + every listed moderator (rank 0).
// Rank is retained so legacy helpers (canBanTarget, getViewerAuthority)
// keep working with a uniform shape even though only one tier exists.
const rankMap = new Map<string, CommunityMember>();
for (const m of fullMembership.members) {
rankMap.set(m.pubkey, m);
rankMap.set(community.founderPubkey, { pubkey: community.founderPubkey, rank: 0 });
for (const modPk of community.moderatorPubkeys) {
if (rankMap.has(modPk)) continue;
rankMap.set(modPk, { pubkey: modPk, rank: 0 });
}
const reportAuthors = fullMembership.members.map((member) => member.pubkey);
// Moderation reports: paginate so an organization with a long history
// of moderation actions still loads completely. `queryAll` caps the
// total per `src/lib/queryAll.ts`.
const reports = await queryAll(
nostr,
{ kinds: [REPORT_KIND], authors: reportAuthors, '#A': [community.aTag], limit: 500 },
{ kinds: [REPORT_KIND], authors: [...rankMap.keys()], '#A': [community.aTag], limit: 500 },
{ signal: combinedSignal },
);
// Step 3: Resolve moderation using the flat membership map. The resolver
// filters by `A` tag internally; we pass all reports as-is since
// the relay query already scoped them to this community.
const moderation = resolveCommunityModeration(community.aTag, reports, rankMap);
// Step 4: Apply moderation overlay — filter banned members from the
// already-computed membership.
const membership: CommunityMembership = {
members: fullMembership.members.filter(
(m) => !moderation.bannedPubkeys.has(m.pubkey),
),
founderPubkey: community.founderPubkey,
moderatorPubkeys: community.moderatorPubkeys,
};
return { membership, moderation, rankMap };
@@ -97,7 +85,6 @@ export function useCommunityMembers(community: ParsedCommunity | null | undefine
staleTime: 2 * 60_000,
});
// Provide backward-compatible access to the membership data
return useMemo(() => ({
data: query.data?.membership,
moderation: query.data?.moderation ?? EMPTY_MODERATION,
+2 -3
View File
@@ -19,9 +19,8 @@ interface UseDiscoverCommunitiesOptions {
* deduped by addressable coordinate. Sorted newest first.
*
* The Discover page uses this to surface communities the visitor hasn't
* joined yet — distinct from `useMyCommunities`, which only returns
* communities the current user has founded, been awarded into, or
* bookmarked.
* joined yet — distinct from `useManageableOrganizations`, which only
* returns communities the current user founded or moderates.
*
* Validation is permissive: any community whose `parseCommunityEvent`
* succeeds (has a `d` tag, etc.) is kept. We do *not* filter by image
+1 -1
View File
@@ -60,7 +60,7 @@ function filterDiscoverEvents(events: NostrEvent[]): NostrEvent[] {
*
* Each page issues exactly one relay request (the union of all relevant
* filters) to stay inside per-page rate budgets — the same pattern
* `useWorldFeed` and `useCommunityActivityFeed` use.
* `useWorldFeed` uses.
*
* Returns the standard `useInfiniteQuery` surface plus a flattened
* `events` list for convenient consumption.
+9 -27
View File
@@ -2,7 +2,6 @@ import { useCallback, useMemo } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCommunityActivityFeed } from '@/hooks/useCommunityActivityFeed';
import { useCountryFollows } from '@/hooks/useCountryFollows';
import { useFeed } from '@/hooks/useFeed';
import { useFeedRelays } from '@/hooks/useFeedRelays';
@@ -159,10 +158,9 @@ function useFollowedHashtagsFeed(hashtags: string[], kinds: number[], enabled: b
}
/**
* Combined "Following" feed: people you follow + your communities' activity +
* the countries you follow + the hashtags you follow. Items are sorted
* strictly by recency (`sortTimestamp` desc) with no per-source
* prioritisation.
* Combined "Following" feed: people you follow + the countries you follow +
* the hashtags you follow. Items are sorted strictly by recency
* (`sortTimestamp` desc) with no per-source prioritisation.
*
* Older content from sources with sparse activity is filtered out so the
* top of the feed doesn't drift back in time while a higher-volume source
@@ -180,7 +178,6 @@ export function useFollowingFeed(enabled = true) {
);
const networkFeed = useFeed('network', { enabled });
const communityFeed = useCommunityActivityFeed(enabled);
const { followedCountries, isLoading: countryFollowsLoading } = useCountryFollows();
const countryFeed = useFollowedCountriesFeed(followedCountries, enabled);
const hasFollowedCountries = followedCountries.length > 0;
@@ -192,11 +189,6 @@ export function useFollowingFeed(enabled = true) {
const networkItems = (networkFeed.data?.pages as unknown as { items: FeedItem[] }[] | undefined)
?.flatMap((page) => page.items) ?? [];
const communityItems = (communityFeed.data ?? []).map((event): FeedItem => ({
event,
sortTimestamp: event.created_at,
}));
const countryItems = (countryFeed.data?.pages ?? [])
.flatMap((page) => page.events)
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
@@ -206,9 +198,8 @@ export function useFollowingFeed(enabled = true) {
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
// Recency floor: prevent an older event from a sparse source (e.g. a
// community/country/hashtag with little recent activity) from
// out-ranking a newer item that simply hasn't loaded into the network
// feed yet.
// country/hashtag with little recent activity) from out-ranking a
// newer item that simply hasn't loaded into the network feed yet.
const nowSeconds = Math.floor(Date.now() / 1000);
const networkOldest = networkItems.length > 0
? Math.min(...networkItems.map((item) => item.sortTimestamp))
@@ -219,9 +210,9 @@ export function useFollowingFeed(enabled = true) {
: windowFloor;
// Network items pass through untouched — they define their own
// recency floor. Community, country, and hashtag items are filtered
// to drop anything older than the floor.
const trimmedExternal = [...communityItems, ...countryItems, ...hashtagItems]
// recency floor. Country and hashtag items are filtered to drop
// anything older than the floor.
const trimmedExternal = [...countryItems, ...hashtagItems]
.filter((item) => item.sortTimestamp >= recencyFloor);
const merged = [...networkItems, ...trimmedExternal];
@@ -242,12 +233,10 @@ export function useFollowingFeed(enabled = true) {
);
return { pages: [{ items: sorted }] };
}, [networkFeed.data?.pages, communityFeed.data, countryFeed.data?.pages, hashtagFeed.data?.pages]);
}, [networkFeed.data?.pages, countryFeed.data?.pages, hashtagFeed.data?.pages]);
const networkHasNextPage = networkFeed.hasNextPage;
const networkFetchNextPage = networkFeed.fetchNextPage;
const communityHasNextPage = communityFeed.hasNextPage;
const communityFetchNextPage = communityFeed.fetchNextPage;
const countryHasNextPage = countryFeed.hasNextPage;
const countryFetchNextPage = countryFeed.fetchNextPage;
const hashtagHasNextPage = hashtagFeed.hasNextPage;
@@ -256,15 +245,12 @@ export function useFollowingFeed(enabled = true) {
const fetchNextPage = useCallback(async () => {
await Promise.all([
networkHasNextPage ? networkFetchNextPage() : Promise.resolve(),
communityHasNextPage ? communityFetchNextPage() : Promise.resolve(),
countryHasNextPage ? countryFetchNextPage() : Promise.resolve(),
hashtagHasNextPage ? hashtagFetchNextPage() : Promise.resolve(),
]);
}, [
networkHasNextPage,
networkFetchNextPage,
communityHasNextPage,
communityFetchNextPage,
countryHasNextPage,
countryFetchNextPage,
hashtagHasNextPage,
@@ -275,7 +261,6 @@ export function useFollowingFeed(enabled = true) {
data,
isPending: enabled && (
networkFeed.isPending
|| communityFeed.isLoading
|| countryFollowsLoading
|| (hasFollowedCountries && countryFeed.isPending)
|| hashtagFollowsLoading
@@ -283,7 +268,6 @@ export function useFollowingFeed(enabled = true) {
),
isLoading: enabled && (
networkFeed.isLoading
|| communityFeed.isLoading
|| countryFollowsLoading
|| (hasFollowedCountries && countryFeed.isLoading)
|| hashtagFollowsLoading
@@ -291,11 +275,9 @@ export function useFollowingFeed(enabled = true) {
),
fetchNextPage,
hasNextPage: !!networkFeed.hasNextPage
|| !!communityFeed.hasNextPage
|| !!countryFeed.hasNextPage
|| !!hashtagFeed.hasNextPage,
isFetchingNextPage: networkFeed.isFetchingNextPage
|| communityFeed.isFetchingNextPage
|| countryFeed.isFetchingNextPage
|| hashtagFeed.isFetchingNextPage,
};
-95
View File
@@ -1,95 +0,0 @@
import { useCallback, useSyncExternalStore } from 'react';
/**
* LocalStorage key for the "members only" filter toggle.
* Shared across all community surfaces so the preference is global.
*/
const STORAGE_KEY = 'community:members-only';
/**
* Controls whether community views filter content down to posts authored by
* validated members, or show everything scoped to the community.
*
* Defaults to `false` (show everything). The flat-communities spec treats
* members-only as a MAY feature (see NIP.md §Community-Scoped Content) —
* the protocol makes no recommendation, so the default is the broader view
* and users opt in via the shield-icon toggle.
*
* Implementation: a module-level singleton store (Set of subscribers +
* cached boolean). Every component mounting `useMembersOnlyFilter` shares
* the same state instance, so toggling the shield in one subtree
* immediately rerenders every other consumer in the same tab. Changes
* are also persisted to localStorage and synchronised with other tabs
* via the browser's `storage` event.
*/
/** Read the persisted boolean, defaulting to `false` when absent or malformed. */
function readFromStorage(): boolean {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return false;
return JSON.parse(raw) === true;
} catch {
return false;
}
}
// ── Module-level singleton store ────────────────────────────────────────────
// Module initialisation accesses `localStorage` which is unavailable in some
// SSR-ish environments. Guard so the module can still be imported.
let cached: boolean = typeof localStorage !== 'undefined' ? readFromStorage() : false;
const subscribers = new Set<() => void>();
function notify() {
for (const cb of subscribers) cb();
}
function setMembersOnly(next: boolean) {
if (cached === next) return;
cached = next;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// Storage quota / private mode — swallow; state still updates in-memory.
}
notify();
}
/** Subscribe the singleton to cross-tab `storage` events once per module load. */
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY) return;
const next = readFromStorage();
if (next !== cached) {
cached = next;
notify();
}
});
}
function subscribe(cb: () => void) {
subscribers.add(cb);
return () => {
subscribers.delete(cb);
};
}
function getSnapshot() {
return cached;
}
/**
* React hook that reads and writes the members-only filter preference.
*
* All instances share the same underlying state — toggling via one
* call immediately rerenders every component consuming this hook.
*/
export function useMembersOnlyFilter() {
const membersOnly = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const toggle = useCallback(() => {
setMembersOnly(!cached);
}, []);
return { membersOnly, setMembersOnly, toggle };
}
-214
View File
@@ -1,214 +0,0 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { COMMUNITIES_LIST_KIND, parseCommunityBookmarkATag } from './useCommunityBookmarks';
import {
COMMUNITY_DEFINITION_KIND,
BADGE_AWARD_KIND,
isAuthorizedAward,
parseCommunityEvent,
type ParsedCommunity,
} from '@/lib/communityUtils';
export interface MyCommunityEntry {
/** The parsed community data. */
community: ParsedCommunity;
/** The raw kind 34550 event. */
event: NostrEvent;
/** Whether the current user is the founder. */
isFounded: boolean;
/** Whether the current user is a validated member. */
isMember: boolean;
/** Whether the current user follows the community via kind 10004. */
isBookmarked: boolean;
}
/**
* Fetch communities the logged-in user has founded, been recruited into,
* or follows via their NIP-51 Communities list (kind 10004).
*
* Discovery:
*
* 1. Founded -- `{ kinds: [34550], authors: [user.pubkey] }`
* 2. Member-of -- kind 8 awards targeting the user, extract badge `a` tags,
* then find the community definitions referencing those badges.
* 3. Followed -- read kind 10004 authored by user, extract `a` tags
* pointing at kind 34550 events, and fetch those community definitions.
*
* Priority when the same community appears in multiple sources:
* founded > member > followed.
*/
export function useMyCommunities() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
return useQuery<MyCommunityEntry[]>({
queryKey: ['my-communities', user?.pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!user) return [];
const timeout = AbortSignal.timeout(10_000);
const combinedSignal = AbortSignal.any([signal, timeout]);
// ── Step 1: Communities founded by the user ───────────────────────────
const foundedEvents = await nostr.query(
[{ kinds: [COMMUNITY_DEFINITION_KIND], authors: [user.pubkey], limit: 50 }],
{ signal: combinedSignal },
);
// ── Step 2: Badge awards targeting the user + Bookmarks list ──────────
//
// Batched into a single relay round-trip. The kind 10004 list is a
// replaceable event, so pulling it here keeps the read path tight and
// reuses the same connection.
const [awards, bookmarkListEvents] = await Promise.all([
nostr.query(
[{ kinds: [BADGE_AWARD_KIND], '#p': [user.pubkey], limit: 200 }],
{ signal: combinedSignal },
),
nostr.query(
[{ kinds: [COMMUNITIES_LIST_KIND], authors: [user.pubkey], limit: 1 }],
{ signal: combinedSignal },
),
]);
// Extract badge a-tag coordinates from awards
const badgeATags = new Set<string>();
const awardsByBadgeATag = new Map<string, NostrEvent[]>();
for (const award of awards) {
for (const tag of award.tags) {
if (tag[0] === 'a' && tag[1]?.startsWith('30009:')) {
badgeATags.add(tag[1]);
const list = awardsByBadgeATag.get(tag[1]) ?? [];
list.push(award);
awardsByBadgeATag.set(tag[1], list);
}
}
}
// Step 3: Find community definitions that reference these badges
let memberCommunityEvents: NostrEvent[] = [];
if (badgeATags.size > 0) {
memberCommunityEvents = await nostr.query(
[{ kinds: [COMMUNITY_DEFINITION_KIND], '#a': [...badgeATags], limit: 100 }],
{ signal: combinedSignal },
);
}
// ── Step 4: Resolve followed community coordinates ────────────────────
//
// NIP-51 kind 10004 stores followed community definitions as `a` tags like
// `34550:<pubkey>:<d-tag>`. For each bookmarked coordinate we query
// with both `authors` and `#d` so relays return a single authentic
// event per bookmark (per AGENTS.md security guidance on addressable
// events).
//
// Multiple coordinates with the same author are grouped to minimise
// the number of relay queries while keeping the author filter intact.
const bookmarkListEvent = bookmarkListEvents[0];
const bookmarkedCoords: string[] = (bookmarkListEvent?.tags ?? [])
.filter(([n, v]) =>
n === 'a'
&& typeof v === 'string'
&& !!parseCommunityBookmarkATag(v),
)
.map(([, v]) => v);
// Group bookmarked coords by author pubkey: author -> Set<d-tag>
const coordsByAuthor = new Map<string, Set<string>>();
for (const coord of bookmarkedCoords) {
const parsed = parseCommunityBookmarkATag(coord);
if (!parsed) continue;
const existing = coordsByAuthor.get(parsed.pubkey);
if (existing) {
existing.add(parsed.dTag);
} else {
coordsByAuthor.set(parsed.pubkey, new Set([parsed.dTag]));
}
}
let bookmarkedCommunityEvents: NostrEvent[] = [];
if (coordsByAuthor.size > 0) {
bookmarkedCommunityEvents = await nostr.query(
Array.from(coordsByAuthor.entries()).map(([authorPubkey, dTags]) => ({
kinds: [COMMUNITY_DEFINITION_KIND],
authors: [authorPubkey],
'#d': [...dTags],
limit: dTags.size,
})),
{ signal: combinedSignal },
);
}
const bookmarkedATagSet = new Set(bookmarkedCoords);
// ── Merge & deduplicate ──────────────────────────────────────────────
// Priority: founded > member > followed. `isBookmarked` is resolved
// from the kind 10004 list irrespective of which bucket produced the
// entry, so founders/members who have also bookmarked see both flags.
const seen = new Map<string, MyCommunityEntry>();
for (const event of foundedEvents) {
const community = parseCommunityEvent(event);
if (!community) continue;
seen.set(community.aTag, {
community,
event,
isFounded: true,
isMember: false,
isBookmarked: bookmarkedATagSet.has(community.aTag),
});
}
for (const event of memberCommunityEvents) {
const community = parseCommunityEvent(event);
if (!community) continue;
if (!community.memberBadgeATag || !badgeATags.has(community.memberBadgeATag)) continue;
const hasValidAward = (awardsByBadgeATag.get(community.memberBadgeATag) ?? [])
.some((award) => isAuthorizedAward(award, community));
if (!hasValidAward) continue;
if (seen.has(community.aTag)) continue;
seen.set(community.aTag, {
community,
event,
isFounded: false,
isMember: true,
isBookmarked: bookmarkedATagSet.has(community.aTag),
});
}
for (const event of bookmarkedCommunityEvents) {
const community = parseCommunityEvent(event);
if (!community) continue;
if (!bookmarkedATagSet.has(community.aTag)) continue;
if (seen.has(community.aTag)) continue;
seen.set(community.aTag, {
community,
event,
isFounded: false,
isMember: false,
isBookmarked: true,
});
}
// Sort: founded first, then member, then followed-only;
// tie-break by created_at descending.
const sortRank = (entry: MyCommunityEntry): number => {
if (entry.isFounded) return 0;
if (entry.isMember) return 1;
return 2;
};
return Array.from(seen.values()).sort((a, b) => {
const rankDiff = sortRank(a) - sortRank(b);
if (rankDiff !== 0) return rankDiff;
return b.event.created_at - a.event.created_at;
});
},
enabled: !!user,
staleTime: 2 * 60_000,
});
}
+41 -191
View File
@@ -7,12 +7,6 @@ import { sanitizeUrl } from '@/lib/sanitizeUrl';
/** NIP-72 community definition (addressable). */
export const COMMUNITY_DEFINITION_KIND = 34550;
/** NIP-58 badge definition. */
export const BADGE_DEFINITION_KIND = 30009;
/** NIP-58 badge award. */
export const BADGE_AWARD_KIND = 8;
/** NIP-56 report. */
export const REPORT_KIND = 1984;
@@ -65,10 +59,6 @@ export interface ParsedCommunity {
founderPubkey: string;
/** Moderator pubkeys (from `p` tags with role "moderator"). */
moderatorPubkeys: string[];
/** Member badge `a` tag coordinate (e.g. `30009:<pubkey>:<d-tag>`). */
memberBadgeATag?: string;
/** Optional relay hint from the community definition's member badge `a` tag. */
memberBadgeRelayHint?: string;
/** Recommended relay URLs. */
relays: string[];
/** The `a` tag coordinate for the community: `34550:<pubkey>:<d-tag>`. */
@@ -96,12 +86,6 @@ export function parseCommunityEvent(event: NostrEvent): ParsedCommunity | null {
.map(([, pubkey]) => pubkey)
.filter((pubkey): pubkey is string => !!pubkey && pubkey !== event.pubkey && HEX_PUBKEY_RE.test(pubkey))));
const memberBadgeTag = event.tags.find(
([n, coord, , role]) => n === 'a' && coord?.startsWith('30009:') && role === 'member',
);
const memberBadgeATag = memberBadgeTag?.[1];
const memberBadgeRelayHint = memberBadgeTag?.[2] || undefined;
// Relay URLs
const relays = event.tags
.filter(([n]) => n === 'relay')
@@ -115,8 +99,6 @@ export function parseCommunityEvent(event: NostrEvent): ParsedCommunity | null {
image,
founderPubkey: event.pubkey,
moderatorPubkeys,
memberBadgeATag,
memberBadgeRelayHint,
relays,
aTag: `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`,
};
@@ -129,15 +111,13 @@ export interface CommunityMember {
pubkey: string;
/** Their effective rank in this community. */
rank: number;
/** The badge award event that established membership (undefined for leadership). */
awardEvent?: NostrEvent;
/** Pubkey of whoever awarded them (undefined for rank 0). */
awardedBy?: string;
}
export interface CommunityMembership {
/** Active members with banned members removed. Use this to list community members. */
members: CommunityMember[];
/** Founder pubkey. */
founderPubkey: string;
/** Moderator pubkeys (does NOT include the founder). */
moderatorPubkeys: string[];
}
/**
@@ -181,18 +161,22 @@ export function getViewerAuthority(
* Classification of a community-scoped kind 1984 event.
*
* - `content-ban`: Authoritative removal of a specific post (has `e` tag + ban label).
* - `member-ban`: Authoritative ban of a member (no `e` tag + ban label).
* - `report`: Soft content warning from a member (NIP-56 report type, no ban label).
* - `report`: Soft content warning from a moderator (NIP-56 report type, no ban label).
*
* Agora's organization trust model only knows founders and moderators —
* there is no "member" tier — so banning a user wholesale is not modeled
* here. Hide a user by banning each of their posts individually, or by
* dropping them from the moderator list.
*/
export type CommunityReportAction = 'content-ban' | 'member-ban' | 'report';
export type CommunityReportAction = 'content-ban' | 'report';
export interface CommunityReport {
/** The original kind 1984 event. */
event: NostrEvent;
/** Classified action type. */
action: CommunityReportAction;
/** Targeted event ID (for content-ban and report). Undefined for member-ban. */
targetEventId?: string;
/** Targeted event ID. */
targetEventId: string;
/** Targeted pubkey. */
targetPubkey: string;
/** NIP-56 report type from the `e` or `p` tag (e.g. "nudity", "spam", "other"). */
@@ -214,7 +198,15 @@ export interface CommunityContentBan {
export interface CommunityModeration {
/** Content-ban candidates grouped by target event ID. Apply only when target pubkey matches the actual event author. */
contentBansByEventId: Map<string, CommunityContentBan[]>;
/** Set of pubkeys that are member-banned. */
/**
* Set of pubkeys that are member-banned.
*
* Always empty in the current organization model — Agora no longer
* supports banning whole users from an organization; only event-level
* content bans remain. The field is kept on the type so existing
* helpers (`isEventAllowedByModeration`, `getViewerAuthority`) keep a
* stable shape, but new code should not rely on it being populated.
*/
bannedPubkeys: Set<string>;
/** Reports grouped by target event ID (for content warnings). */
reportsByEventId: Map<string, CommunityReport[]>;
@@ -231,7 +223,7 @@ export const EMPTY_MODERATION: CommunityModeration = {
};
/** Empty membership sentinel — no members. */
export const EMPTY_MEMBERSHIP: CommunityMembership = { members: [] };
export const EMPTY_MEMBERSHIP: CommunityMembership = { founderPubkey: '', moderatorPubkeys: [] };
/** Empty rank map sentinel — shared frozen instance for default-return paths. */
export const EMPTY_RANK_MAP: ReadonlyMap<string, CommunityMember> = new Map();
@@ -332,9 +324,11 @@ export function parseCommunityReport(event: NostrEvent): CommunityReport | null
const targetPubkey = pTag?.[1];
if (!targetPubkey || !HEX_PUBKEY_RE.test(targetPubkey)) return null;
// Extract target event ID (optional — determines content vs member action)
// Extract target event ID — required: both content-ban and report
// actions target a specific event in Agora's organization model.
const eTag = event.tags.find(([n]) => n === 'e');
const targetEventId = eTag?.[1];
if (!targetEventId) return null;
// Determine report type from the e or p tag's 3rd element
const rawType = eTag?.[2] || pTag?.[2] || 'other';
@@ -343,19 +337,7 @@ export function parseCommunityReport(event: NostrEvent): CommunityReport | null
: 'other';
const isBan = hasBanLabel(event);
let action: CommunityReportAction;
if (isBan && targetEventId) {
action = 'content-ban';
} else if (isBan && !targetEventId) {
action = 'member-ban';
} else if (!isBan && targetEventId) {
action = 'report';
} else {
// No ban label and no e tag — invalid per classification table
// (a "report" without a target event has nowhere to attach in the UI)
return null;
}
const action: CommunityReportAction = isBan ? 'content-ban' : 'report';
return {
event,
@@ -376,23 +358,16 @@ export function parseCommunityReport(event: NostrEvent): CommunityReport | null
* moderation state between communities (e.g. when a single relay query
* returns reports scoped to multiple communities via `#A`).
*
* Uses a two-pass approach to prevent banned members from retaining
* moderation authority:
*
* **Pass 1 — Resolve bans (authority-ordered):**
* Founder/moderators (rank 0) can ban members and non-members. Members
* (rank 1) can ban only non-members. Processing leadership before members
* ensures banned members cannot keep moderation authority.
*
* **Pass 2 — Resolve reports (filtered):**
* Processes non-ban reports, skipping any reporter who ended up in the
* banned set from pass 1. This prevents banned members from polluting the
* report queue.
* In Agora's organization model only the founder and listed moderators
* have moderation authority. Reports from anyone else are dropped.
* Authoritative content removals (`content-ban`) hide the targeted post;
* soft reports attach a content warning without removing the post.
*
* @param communityATag - The community's `A` tag value (`34550:<pubkey>:<d>`).
* @param reports - Candidate kind 1984 events. Events without a matching
* `A` tag are ignored.
* @param members - Validated membership map (pubkey -> CommunityMember).
* @param members - Map of pubkey -> CommunityMember for the founder and
* current moderators of the community.
*/
export function resolveCommunityModeration(
communityATag: string,
@@ -400,16 +375,9 @@ export function resolveCommunityModeration(
members: Map<string, CommunityMember>,
): CommunityModeration {
const contentBansByEventId = new Map<string, CommunityContentBan[]>();
const bannedPubkeys = new Set<string>();
const reportsByEventId = new Map<string, CommunityReport[]>();
const allReports: CommunityReport[] = [];
// Parse every event once. Drop anything that:
// - lacks an `A` tag matching this community (trust-boundary check;
// protects against mixed-community event sets)
// - fails structural classification
// - is not authored by a validated member (membership overlay rules)
const parsed: CommunityReport[] = [];
for (const event of reports) {
const hasMatchingATag = event.tags.some(
([n, v]) => n === 'A' && v === communityATag,
@@ -417,47 +385,11 @@ export function resolveCommunityModeration(
if (!hasMatchingATag) continue;
const p = parseCommunityReport(event);
if (!p) continue;
// Only founder/moderators can publish moderation actions against the
// organization. Anyone else's kind 1984 is treated as noise.
if (!members.has(p.reporterPubkey)) continue;
parsed.push(p);
}
// ── Pass 1: Resolve bans in authority order ────────────────────────
//
// Rank 0 means founder/moderator and rank 1 means member. Non-members
// are treated as lowest rank (Infinity), so members can only ban
// non-members while founder/moderators can ban anyone.
//
// Candidates are sorted by reporter rank ascending so leadership bans
// are resolved before member bans. A reporter banned by an earlier
// authoritative action must not retain moderation authority for later
// actions in the same pass.
interface BanCandidate {
parsed: CommunityReport;
reporterRank: number;
}
const banCandidates: BanCandidate[] = [];
for (const p of parsed) {
if (p.action !== 'content-ban' && p.action !== 'member-ban') continue;
// Reporter membership is guaranteed by the parse-time filter above.
const reporterRank = members.get(p.reporterPubkey)!.rank;
const targetRank = members.get(p.targetPubkey)?.rank ?? Infinity;
// Authority check: strict rank inequality.
if (reporterRank >= targetRank) continue;
banCandidates.push({ parsed: p, reporterRank });
}
banCandidates.sort((a, b) => a.reporterRank - b.reporterRank);
for (const { parsed: p } of banCandidates) {
if (bannedPubkeys.has(p.reporterPubkey)) continue;
if (p.action === 'content-ban' && p.targetEventId) {
if (p.action === 'content-ban') {
const existing = contentBansByEventId.get(p.targetEventId) ?? [];
existing.push({
eventId: p.targetEventId,
@@ -465,98 +397,16 @@ export function resolveCommunityModeration(
report: p,
});
contentBansByEventId.set(p.targetEventId, existing);
} else if (p.action === 'member-ban') {
bannedPubkeys.add(p.targetPubkey);
} else {
const existing = reportsByEventId.get(p.targetEventId) ?? [];
existing.push(p);
reportsByEventId.set(p.targetEventId, existing);
}
allReports.push(p);
}
// ── Pass 2: Attach soft reports, excluding banned reporters ────────
for (const p of parsed) {
if (p.action !== 'report') continue;
if (bannedPubkeys.has(p.reporterPubkey)) continue;
if (!p.targetEventId) continue; // defensive — 'report' action always has one
const existing = reportsByEventId.get(p.targetEventId) ?? [];
existing.push(p);
reportsByEventId.set(p.targetEventId, existing);
allReports.push(p);
}
return { contentBansByEventId, bannedPubkeys, reportsByEventId, allReports };
}
/**
* Whether a kind 8 badge award is a valid membership award for a community.
*
* Three conditions must hold (per NIP.md §Badge Awards):
* 1. The event is a kind 8 badge award.
* 2. The award author is the founder or a current moderator of the community.
* 3. The award contains an `a` tag referencing the community's member badge.
*
* This is the single source of truth for award authorization. Both the
* membership resolver and any discovery path that reaches awards through
* an unfiltered query (e.g. `#p`-based "communities I belong to" lookups)
* MUST apply this check before trusting the award.
*/
export function isAuthorizedAward(award: NostrEvent, community: ParsedCommunity): boolean {
if (award.kind !== BADGE_AWARD_KIND) return false;
if (!community.memberBadgeATag) return false;
if (award.pubkey !== community.founderPubkey && !community.moderatorPubkeys.includes(award.pubkey)) return false;
return award.tags.some(([n, v]) => n === 'a' && v === community.memberBadgeATag);
}
/**
* Resolve flat community membership from founder/moderators plus membership
* awards.
*
* Each award is validated via `isAuthorizedAward`. Callers SHOULD still query
* with `authors: [founder, ...moderators]` so the relay indexes the trust
* boundary, but this resolver enforces the same check client-side so that
* discovery paths which reach awards by other filters (e.g. `#p` on the
* viewer) stay consistent.
*/
export function resolveMembership(
community: ParsedCommunity,
awardEvents: NostrEvent[],
): CommunityMembership {
const validated = new Map<string, CommunityMember>();
validated.set(community.founderPubkey, {
pubkey: community.founderPubkey,
rank: 0,
});
for (const modPk of community.moderatorPubkeys) {
if (!validated.has(modPk)) {
validated.set(modPk, { pubkey: modPk, rank: 0 });
}
}
for (const award of awardEvents) {
if (!isAuthorizedAward(award, community)) continue;
const recipients = award.tags
.filter(([n]) => n === 'p')
.map(([, pk]) => pk)
.filter((pk): pk is string => !!pk && HEX_PUBKEY_RE.test(pk));
for (const recipientPk of recipients) {
if (validated.has(recipientPk)) continue;
validated.set(recipientPk, {
pubkey: recipientPk,
rank: 1,
awardEvent: award,
awardedBy: award.pubkey,
});
}
}
const members = Array.from(validated.values());
members.sort((a, b) => a.rank - b.rank);
return { members };
return { contentBansByEventId, bannedPubkeys: new Set(), reportsByEventId, allReports };
}
/**
+2 -190
View File
@@ -1,73 +1,34 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useSeoMeta } from '@unhead/react';
import { Globe2, HandHeart, Loader2, PlusCircle, Users } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useInView } from 'react-intersection-observer';
import { Globe2, HandHeart, PlusCircle, Users } from 'lucide-react';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
import { HeroBanner } from '@/components/HeroBanner';
import { LoginArea } from '@/components/auth/LoginArea';
import { MembersOnlyToggle } from '@/components/MembersOnlyToggle';
import { NoteCard } from '@/components/NoteCard';
import { FeedCard } from '@/components/FeedCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { CommunityModerationContext, type CommunityModerationContextValue } from '@/contexts/CommunityModerationContext';
import { HorizontalScroll } from '@/components/discovery/HorizontalScroll';
import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard';
import { SectionHeader } from '@/components/discovery/SectionHeader';
import { COMMUNITY_DEFINITION_KIND, EMPTY_MODERATION } from '@/lib/communityUtils';
import { COOL_PALETTE } from '@/lib/hopePalette';
import { cn } from '@/lib/utils';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useAppContext } from '@/hooks/useAppContext';
import { useCommunityActivityFeed } from '@/hooks/useCommunityActivityFeed';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations';
import { useGlobalActivity } from '@/hooks/useGlobalActivity';
import { useGlobalDonations } from '@/hooks/useGlobalDonations';
import { useManageableOrganizations } from '@/hooks/useManageableOrganizations';
import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter';
import { useToast } from '@/hooks/useToast';
import { formatSatsShort } from '@/lib/formatCampaignAmount';
// ─── Skeletons ─────────────────────────────────────────────────────────────────
function NoteCardSkeleton() {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="min-w-0 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
</div>
</div>
<div className="mt-2 space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<div className="flex items-center gap-6 mt-3 -ml-2">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-8" />
</div>
</div>
);
}
// ─── Page ──────────────────────────────────────────────────────────────────────
export function CommunitiesPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { toast } = useToast();
@@ -97,7 +58,7 @@ export function CommunitiesPage() {
<main className="min-h-screen pb-16 sidebar:pb-0">
<CommunitiesHero onCreateCommunity={handleCreateCommunity} />
<div className="max-w-5xl mx-auto space-y-2 sm:space-y-4">
<div className="max-w-5xl mx-auto space-y-2 sm:space-y-4 pb-8">
<section className="pt-6">
<SectionHeader title="My organizations" className="pb-3 sm:px-6" />
<MyCommunitiesShelf onCreateCommunity={handleCreateCommunity} />
@@ -110,31 +71,6 @@ export function CommunitiesPage() {
/>
<FeaturedOrganizationsShelf />
</section>
<section id="community-activity" className="pt-4 pb-8">
<div className="max-w-2xl mx-auto">
<div className="px-4 sm:px-6 mb-3 flex items-end justify-between gap-4">
<div>
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
Voices from everywhere
</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-xl">
New posts, campaigns, pledges, and definition updates from the organizations you belong to.
</p>
</div>
{user && <MembersOnlyToggle className="shrink-0" />}
</div>
<ActivitiesFeed
onRefresh={() =>
queryClient.invalidateQueries({
queryKey: ['community-activity-feed'],
exact: false,
})
}
/>
</div>
</section>
</div>
</main>
);
@@ -437,127 +373,3 @@ function EmptyShelf({
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// Activities feed
// ═══════════════════════════════════════════════════════════════════════════════
/** Extract the community a-tag from an event (uppercase A for NIP-22, lowercase a with 34550: prefix for goals). */
function getCommunityATag(event: NostrEvent): string | undefined {
return event.tags.find(([n]) => n === 'A')?.[1]
?? event.tags.find(([n, v]) => n === 'a' && v?.startsWith('34550:'))?.[1];
}
function ActivitiesFeed({ onRefresh }: { onRefresh: () => Promise<void> }) {
const { user } = useCurrentUser();
const {
data: activityEvents,
isLoading,
moderationByATag,
rankMapByATag,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useCommunityActivityFeed();
const { membersOnly } = useMembersOnlyFilter();
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
// Build per-community context values for NoteMoreMenu moderation actions.
// Keyed by community A tag — each NoteCard is wrapped in its own provider.
const contextByATag = useMemo(() => {
const map = new Map<string, CommunityModerationContextValue>();
for (const [aTag, rankMap] of rankMapByATag) {
const moderation = moderationByATag.get(aTag) ?? EMPTY_MODERATION;
map.set(aTag, { communityATag: aTag, moderation, rankMap });
}
return map;
}, [moderationByATag, rankMapByATag]);
// Apply the members-only presentation filter. Community definitions
// (kind 34550) are never filtered — they represent the community itself,
// not user-generated content. Only community-scoped content (kind 1111
// and future kinds) is filtered to authored-by-member when the toggle
// is active, matching the NIP's canonical-author guidance.
const displayedEvents = useMemo(() => {
if (!activityEvents) return activityEvents;
if (!membersOnly) return activityEvents;
return activityEvents.filter((event) => {
if (event.kind === COMMUNITY_DEFINITION_KIND) return true;
const aTag = getCommunityATag(event);
if (!aTag) return true; // No community scope — pass through
const rankMap = rankMapByATag.get(aTag);
if (!rankMap) return true; // Moderation data not resolved — avoid hiding
return rankMap.has(event.pubkey);
});
}, [activityEvents, membersOnly, rankMapByATag]);
if (!user) {
return (
<Card className="border-dashed mx-4 sm:mx-6">
<CardContent className="py-12 px-8 text-center space-y-4 flex flex-col items-center">
<div className="p-4 rounded-full bg-primary/10">
<Users className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h3 className="text-xl font-bold">Organization activity</h3>
<p className="text-muted-foreground text-sm">
Log in to see activity from your organizations.
</p>
</div>
<LoginArea className="max-w-60" />
</CardContent>
</Card>
);
}
return (
<PullToRefresh onRefresh={onRefresh}>
<>
{isLoading ? (
<FeedCard className="mt-2 divide-y divide-border/60">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</FeedCard>
) : displayedEvents && displayedEvents.length > 0 ? (
<FeedCard className="mt-2">
{displayedEvents.map((event) => {
const aTag = getCommunityATag(event);
const ctx = aTag ? contextByATag.get(aTag) ?? null : null;
return (
<CommunityModerationContext.Provider key={event.id} value={ctx}>
<NoteCard event={event} />
</CommunityModerationContext.Provider>
);
})}
</FeedCard>
) : membersOnly && activityEvents && activityEvents.length > 0 ? (
<FeedEmptyState message="No activity from members of your organizations yet. Toggle the shield icon to see all organization activity." />
) : (
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Users className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No activity yet</h2>
<p className="text-muted-foreground text-sm">
Browse the featured organizations above, or create your own with the button at the top of the page.
</p>
</div>
</div>
)}
{!isLoading && hasNextPage && (
<div ref={scrollRef} className="py-4 flex justify-center">
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
</div>
)}
</>
</PullToRefresh>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ import {
X,
} from 'lucide-react';
import { PersonSearch } from '@/components/AddMemberDialog';
import { PersonSearch } from '@/components/PersonSearch';
import { CoverImageField } from '@/components/CoverImageField';
import { FormSection } from '@/components/FormSection';
import { OrganizationContextChip } from '@/components/OrganizationContextChip';
+12 -38
View File
@@ -13,7 +13,7 @@ import {
X,
} from 'lucide-react';
import { PersonSearch } from '@/components/AddMemberDialog';
import { PersonSearch } from '@/components/PersonSearch';
import { CoverImageField } from '@/components/CoverImageField';
import { FormSection } from '@/components/FormSection';
import { Alert, AlertDescription } from '@/components/ui/alert';
@@ -30,7 +30,6 @@ import { useToast } from '@/hooks/useToast';
import type { SearchProfile } from '@/hooks/useSearchProfiles';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import {
BADGE_DEFINITION_KIND,
COMMUNITY_DEFINITION_KIND,
parseCommunityEvent,
type ParsedCommunity,
@@ -327,11 +326,15 @@ export function CreateCommunityPage() {
}
// Strip the tag names we're going to rewrite; preserve everything
// else (the `a` member-badge tag, any `relay` hints, …).
// else (any `relay` hints, alt-language tags, …). The legacy
// `['a', …, 'member']` member-badge tag is no longer relevant
// under Agora's founder/moderator-only model — we drop it on
// edit so old badge wiring doesn't linger.
const preserved = prev.tags.filter(
([n, , , role]) =>
([n, v, , role]) =>
!['d', 'name', 'description', 'image', 'alt'].includes(n) &&
!(n === 'p' && role === 'moderator'),
!(n === 'p' && role === 'moderator') &&
!(n === 'a' && typeof v === 'string' && v.startsWith('30009:') && role === 'member'),
);
const nextTags: string[][] = [
@@ -344,7 +347,6 @@ export function CreateCommunityPage() {
if (sanitizedImage) {
nextTags.push(['image', sanitizedImage]);
}
nextTags.push(['p', user.pubkey, '', 'moderator']);
for (const mod of extraModerators) {
nextTags.push(['p', mod.pubkey, '', 'moderator']);
}
@@ -377,36 +379,10 @@ export function CreateCommunityPage() {
);
}
// Same collision check for the implicitly-minted member badge.
const badgeDTag = `${slug}-member`;
const existingBadge = await nostr.query([
{
kinds: [BADGE_DEFINITION_KIND],
authors: [user.pubkey],
'#d': [badgeDTag],
limit: 1,
},
]);
if (existingBadge.length > 0) {
throw new Error(
'You already have a member badge with this identifier. Choose a different community name so the badge can be created safely.',
);
}
// Mint the implicit "Member of <community>" badge (kind 30009).
const badgeEvent: NostrEvent = await publishEvent({
kind: BADGE_DEFINITION_KIND,
content: '',
tags: [
['d', badgeDTag],
['name', 'Member'],
['description', `Member of ${trimmedName}`],
['alt', `Badge definition: Member of ${trimmedName}`],
],
});
const badgeATag = `${BADGE_DEFINITION_KIND}:${badgeEvent.pubkey}:${badgeDTag}`;
// Build the kind 34550 community-definition tag set.
// Build the kind 34550 community-definition tag set. Agora's
// organization model has no badge-based membership any more, so we
// do not mint a "Member of …" badge or attach an `a` tag with a
// `member` role marker.
const tags: string[][] = [
['d', slug],
['name', trimmedName],
@@ -417,8 +393,6 @@ export function CreateCommunityPage() {
if (sanitizedImage) {
tags.push(['image', sanitizedImage]);
}
tags.push(['a', badgeATag, '', 'member']);
tags.push(['p', user.pubkey, '', 'moderator']);
for (const mod of extraModerators) {
tags.push(['p', mod.pubkey, '', 'moderator']);
}