Add community member feed tab

This commit is contained in:
lemon
2026-05-07 09:41:29 -07:00
parent 98c2d69c02
commit 4830443c26
2 changed files with 131 additions and 10 deletions
+119 -6
View File
@@ -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<string>();
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 (
<div className="py-12 text-center text-muted-foreground text-sm px-5">
No active members found.
</div>
);
}
if (isMembershipLoading || isPending || (isLoading && !rawData)) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
);
}
if (feedItems.length === 0) {
return (
<PullToRefresh onRefresh={handleRefresh}>
<FeedEmptyState message="No posts from active community members yet." />
</PullToRefresh>
);
}
return (
<PullToRefresh onRefresh={handleRefresh}>
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
{hasNextPage && (
<div ref={scrollRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
</PullToRefresh>
);
}
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 ── */}
<CommunityModerationContext.Provider value={moderationCtx}>
<Tabs value={activeTab} onValueChange={setActiveTab} className="-mx-5">
<TabsList className="w-full rounded-none border-b border-border bg-transparent p-0 h-auto">
<TabsList className="w-full justify-start overflow-x-auto scrollbar-none rounded-none border-b border-border bg-transparent p-0 h-auto">
<TabsTrigger
value="members"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<Users className="size-4 mr-1.5" />
Members
</TabsTrigger>
<TabsTrigger
value="feed"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<Rss className="size-4 mr-1.5" />
Feed
</TabsTrigger>
<TabsTrigger
value="comments"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<MessageCircle className="size-4 mr-1.5" />
Comments
</TabsTrigger>
<TabsTrigger
value="fundraising"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<Target className="size-4 mr-1.5" />
Fundraising
</TabsTrigger>
<TabsTrigger
value="events"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none pb-3 pt-2"
className="flex-none min-w-fit rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 pb-3 pt-2"
>
<CalendarDays className="size-4 mr-1.5" />
Events
@@ -548,6 +653,14 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
)}
</TabsContent>
{/* ── Feed tab ── */}
<TabsContent value="feed" className="mt-0">
<CommunityMemberFeed
authorPubkeys={allMemberPubkeys}
isMembershipLoading={membersLoading}
/>
</TabsContent>
{/* ── Comments tab ── */}
<TabsContent value="comments" className="mt-0">
<ComposeBox compact replyTo={event} />
+12 -4
View File
@@ -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<string, string[]>;
}
@@ -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<string, unknown> = { kinds: allKinds, authors, limit: fetchLimit, ...tagFilters };
if (pageParam) {