diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index 316f7b2a..6d1ee4ba 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -1,4 +1,5 @@ -import { useMemo, useCallback, useState } from 'react'; +import { useMemo, useCallback, useEffect, useState } from 'react'; +import { useInView } from 'react-intersection-observer'; import { Link, useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { @@ -6,8 +7,10 @@ import { Bookmark, CalendarDays, Crown, + Loader2, MessageCircle, Pencil, + Rss, Shield, ShieldBan, Share2, @@ -27,9 +30,11 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { BanConfirmDialog } from '@/components/BanConfirmDialog'; import { ComposeBox } from '@/components/ComposeBox'; +import { FeedEmptyState } from '@/components/FeedEmptyState'; import { CreateGoalDialog } from '@/components/CreateGoalDialog'; import { MembersOnlyToggle } from '@/components/MembersOnlyToggle'; import { NoteCard } from '@/components/NoteCard'; +import { PullToRefresh } from '@/components/PullToRefresh'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList'; import { useAuthor } from '@/hooks/useAuthor'; @@ -40,13 +45,18 @@ import { useCommunityEvents } from '@/hooks/useCommunityEvents'; import { useCommunityMembers } from '@/hooks/useCommunityMembers'; import { useCommunityGoals } from '@/hooks/useCommunityGoals'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useFeed } from '@/hooks/useFeed'; import { useMembersOnlyFilter } from '@/hooks/useMembersOnlyFilter'; +import { useMuteList } from '@/hooks/useMuteList'; import { useNow } from '@/hooks/useNow'; +import { usePageRefresh } from '@/hooks/usePageRefresh'; 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 { isEventMuted } from '@/lib/muteHelpers'; +import { shouldHideFeedEvent, type FeedItem } from '@/lib/feedUtils'; import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; @@ -127,6 +137,94 @@ function ReplyCardSkeleton() { ); } +function CommunityMemberFeed({ authorPubkeys, isMembershipLoading }: { authorPubkeys: string[]; isMembershipLoading: boolean }) { + const { muteItems } = useMuteList(); + const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' }); + const queryKey = useMemo(() => ['feed', 'follows'], []); + const handleRefresh = usePageRefresh(queryKey); + const uniqueAuthors = useMemo(() => Array.from(new Set(authorPubkeys)), [authorPubkeys]); + + const { + data: rawData, + isPending, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useFeed('follows', { authors: uniqueAuthors }); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const feedItems = useMemo(() => { + if (!rawData?.pages) return []; + const seen = new Set(); + + return (rawData.pages as { items: FeedItem[] }[]) + .flatMap((page) => page.items) + .filter((item) => { + const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id; + if (!key || seen.has(key)) return false; + seen.add(key); + if (shouldHideFeedEvent(item.event)) return false; + if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false; + return true; + }); + }, [rawData?.pages, muteItems]); + + if (!isMembershipLoading && uniqueAuthors.length === 0) { + return ( +
+ No active members found. +
+ ); + } + + if (isMembershipLoading || isPending || (isLoading && !rawData)) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ); + } + + if (feedItems.length === 0) { + return ( + + + + ); + } + + return ( + +
+ {feedItems.map((item) => ( + + ))} + {hasNextPage && ( +
+ {isFetchingNextPage && ( +
+ +
+ )} +
+ )} +
+
+ ); +} + function getTag(tags: string[][], name: string): string | undefined { return tags.find(([n]) => n === name)?.[1]; } @@ -470,31 +568,38 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { {/* ── Tabs ── */} - + Members + + + Feed + Comments Fundraising Events @@ -548,6 +653,14 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { )} + {/* ── Feed tab ── */} + + + + {/* ── Comments tab ── */} diff --git a/src/hooks/useFeed.ts b/src/hooks/useFeed.ts index db403046..297dec36 100644 --- a/src/hooks/useFeed.ts +++ b/src/hooks/useFeed.ts @@ -34,6 +34,8 @@ interface FeedPage { interface UseFeedOptions { /** Override the kinds list instead of using feed settings. Used by kind-specific pages. */ kinds?: number[]; + /** Override the follows author list. Used by scoped member feeds. */ + authors?: string[]; /** Additional tag filters to apply (e.g. `{ '#m': ['application/x-webxdc'] }`). */ tagFilters?: Record; } @@ -51,15 +53,19 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use const allKinds = options?.kinds ?? getEnabledFeedKinds(feedSettings); const tagFilters = options?.tagFilters; + const authorsOverride = options?.authors; // Stable key so queries re-run when settings change. const kindsKey = [...allKinds].sort().join(','); + const authorsKey = authorsOverride ? [...authorsOverride].sort().join(',') : ''; const tagFiltersKey = tagFilters ? JSON.stringify(tagFilters) : ''; // For the follows tab, wait until the follow list is loaded before running any query. // Without this guard, the query falls through to the global branch while followList is still loading. // Allow query to run if not on follows tab, OR if follow list has loaded (even if empty). - const followsReady = tab !== 'follows' || (!!user && followList !== undefined); + const followsReady = authorsOverride + ? authorsOverride.length > 0 + : tab !== 'follows' || (!!user && followList !== undefined); // Load community pubkeys from localStorage const communityPubkeys = (() => { @@ -84,7 +90,7 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use // on page load because feedSettings is read from localStorage // synchronously — the encrypted settings sync at ~5s only calls // updateConfig if values actually differ (NostrSync changed guard). - queryKey: ['feed', tab, user?.pubkey ?? '', kindsKey, tagFiltersKey, communityPubkeys.length, feedSettings.followsFeedShowReplies], + queryKey: ['feed', tab, user?.pubkey ?? '', kindsKey, authorsKey, tagFiltersKey, communityPubkeys.length, feedSettings.followsFeedShowReplies], queryFn: async ({ pageParam }) => { const signal = AbortSignal.timeout(8000); const now = Math.floor(Date.now() / 1000); @@ -238,10 +244,12 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use cacheEvents(dedupedItems); return { items: dedupedItems, oldestQueryTimestamp, rawCount: validFilteredEvents.length }; - } else if (tab === 'follows' && user && followList !== undefined) { + } else if ((authorsOverride && authorsOverride.length > 0) || (tab === 'follows' && user && followList !== undefined)) { // Follows feed — posts, reposts, and extra kinds from people you follow // If followList is empty, just query own posts - const authors = followList.length > 0 ? [...followList, user.pubkey] : [user.pubkey]; + const authors = authorsOverride ?? (user && followList + ? (followList.length > 0 ? [...followList, user.pubkey] : [user.pubkey]) + : []); const fetchLimit = !feedSettings.followsFeedShowReplies ? PAGE_SIZE * OVER_FETCH_MULTIPLIER : PAGE_SIZE; const filter: Record = { kinds: allKinds, authors, limit: fetchLimit, ...tagFilters }; if (pageParam) {