Add community member feed tab
This commit is contained in:
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user