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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user