diff --git a/src/components/AddMemberDialog.tsx b/src/components/AddMemberDialog.tsx index 98f5ed2d..ee68c165 100644 --- a/src/components/AddMemberDialog.tsx +++ b/src/components/AddMemberDialog.tsx @@ -14,6 +14,7 @@ import { 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 { getAvatarShape } from '@/lib/avatarShape'; @@ -23,12 +24,17 @@ import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles'; +import { PortalContainerProvider } from '@/hooks/usePortalContainer'; import { genUserName } from '@/lib/genUserName'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; import { COMMUNITY_DEFINITION_KIND, BADGE_DEFINITION_KIND, BADGE_AWARD_KIND, + EMPTY_MODERATION, + type CommunityMember, + type CommunityMembership, + type CommunityModeration, type ParsedCommunity, } from '@/lib/communityUtils'; import { cn } from '@/lib/utils'; @@ -42,6 +48,12 @@ interface PendingMember { role: MemberRole; } +interface CommunityMembersCacheValue { + membership: CommunityMembership; + moderation: CommunityModeration; + rankMap: Map; +} + interface AddMemberDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -72,8 +84,13 @@ export function AddMemberDialog({ const [badgeImageUrl, setBadgeImageUrl] = useState(''); const [badgeImagePreview, setBadgeImagePreview] = useState(''); const [isPublishing, setIsPublishing] = useState(false); + const [portalContainer, setPortalContainer] = useState(undefined); const fileInputRef = useRef(null); + const dialogContentRef = useCallback((node: HTMLElement | null) => { + setPortalContainer(node ?? undefined); + }, []); + // Mutations const { mutateAsync: publishEvent } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); @@ -129,6 +146,55 @@ export function AddMemberDialog({ )); }, [isFounder]); + const applyOptimisticMembership = useCallback((members: PendingMember[], awardEvents: Map) => { + queryClient.setQueryData(['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]); + // ── Badge image upload ──────────────────────────────────────────────────── const handleBadgeFileSelect = useCallback(async (file: File) => { @@ -229,9 +295,10 @@ export function AddMemberDialog({ } // Step 3: Publish badge awards for each member + const memberAwardEvents = new Map(); if (newMembers.length > 0 && badgeATag) { for (const member of newMembers) { - await publishEvent({ + const awardEvent = await publishEvent({ kind: BADGE_AWARD_KIND, content: '', tags: [ @@ -240,11 +307,11 @@ export function AddMemberDialog({ ['alt', `Badge award: Member in ${community.name}`], ], } as Omit); + memberAwardEvents.set(member.profile.pubkey, awardEvent); } } - // Invalidate queries - queryClient.invalidateQueries({ queryKey: ['community-members'], exact: false }); + applyOptimisticMembership(pendingMembers, memberAwardEvents); queryClient.invalidateQueries({ queryKey: ['my-communities'], exact: false }); if (!hasBadge && newMembers.length > 0) { queryClient.invalidateQueries({ queryKey: ['badge-feed'] }); @@ -264,14 +331,15 @@ export function AddMemberDialog({ } }, [ user, pendingMembers, existingBadgeATag, hasBadge, community, communityEvent, - badgeImageUrl, nostr, publishEvent, queryClient, toast, handleOpenChange, + badgeImageUrl, nostr, publishEvent, queryClient, toast, handleOpenChange, applyOptimisticMembership, ]); if (!user) return null; return ( - + + @@ -284,19 +352,19 @@ export function AddMemberDialog({ - {/* Search input lives outside ScrollArea so its dropdown isn't clipped */} -
- m.profile.pubkey), - ]} - /> -
- - +
+ {/* People search */} +
+ + m.profile.pubkey), + ]} + /> +
{/* Pending members list */} {pendingMembers.length > 0 && ( @@ -375,6 +443,7 @@ export function AddMemberDialog({
+
); @@ -392,7 +461,6 @@ function PersonSearch({ }) { const [query, setQuery] = useState(''); const [dropdownOpen, setDropdownOpen] = useState(false); - const containerRef = useRef(null); const inputRef = useRef(null); const { data: profiles, isFetching } = useSearchProfiles(query); @@ -411,16 +479,6 @@ function PersonSearch({ } }, [filteredProfiles, query]); - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setDropdownOpen(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - const handleSelect = useCallback((profile: SearchProfile) => { onAdd(profile); setQuery(''); @@ -429,47 +487,49 @@ function PersonSearch({ }, [onAdd]); return ( -
-
- - {isFetching && query.trim() && ( - - )} - setQuery(e.target.value)} - onFocus={() => { - if (query.trim().length > 0 && filteredProfiles.length > 0) { - setDropdownOpen(true); - } - }} - placeholder="Search people to add..." - 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" - /> -
+ + +
+ + {isFetching && query.trim() && ( + + )} + setQuery(e.target.value)} + onFocus={() => { + if (query.trim().length > 0 && filteredProfiles.length > 0) { + setDropdownOpen(true); + } + }} + placeholder="Search people to add..." + 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" + /> +
+
- {/* Results — opens downward */} - {dropdownOpen && filteredProfiles.length > 0 && ( -
+ e.preventDefault()} + className="z-[270] w-[var(--radix-popover-trigger-width)] rounded-xl border-border p-0 shadow-lg overflow-hidden" + > + {filteredProfiles.length > 0 ? (
{filteredProfiles.map((profile) => ( ))}
-
- )} - - {/* No results */} - {dropdownOpen && query.trim().length >= 2 && !isFetching && filteredProfiles.length === 0 && ( -
+ ) : query.trim().length >= 2 && !isFetching ? (
No people found
-
- )} -
+ ) : null} + + ); } diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index 49561ac4..83c7048f 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -17,7 +17,6 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; import { AddMemberDialog } from '@/components/AddMemberDialog'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { BanConfirmDialog } from '@/components/BanConfirmDialog'; @@ -162,6 +161,16 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { const isFounder = !!user && user.pubkey === event.pubkey; const canAddMembers = !!viewerMember && viewerMember.rank === 0; + const handleAddMembersClick = useCallback(() => { + setAddMemberOpen(true); + }, []); + + useLayoutOptions({ + showFAB: canAddMembers && activeTab === 'members', + onFabClick: handleAddMembersClick, + fabIcon: , + }); + // Batch-fetch profiles for all members const allMemberPubkeys = useMemo( () => membership?.members.map((m) => m.pubkey) ?? [], @@ -455,20 +464,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { ))} )} - - {/* Add member button — visible to founder and moderators */} - {canAddMembers && ( -
- -
- )} {/* ── Comments tab ── */} diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 0a08854b..9dfd0339 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -198,7 +198,7 @@ export function SearchPage() { const setActiveTab = useCallback((tab: TabType) => { setSearchParams((prev) => { const next = new URLSearchParams(prev); - if (tab === 'posts') { + if (tab === 'communities') { next.delete('tab'); } else { next.set('tab', tab); @@ -1205,4 +1205,3 @@ function SaveDestinationRow({ ); } -