Fold followed hashtags into the Following feed and drop tag tabs

- useFollowingFeed now also queries posts for the user's followed
  hashtag interests (NIP-51 kind 10015 t tags) and merges them into
  the combined Following feed, subject to the same recency floor.
- Drop the per-hashtag and per-geotag tabs from the home feed
  subheader. Legacy 'hashtag:'/'geotag:' session-storage values fall
  back to the Following tab.
- Invalidate the new following-feed query keys when interests change
  so the Following feed refreshes immediately on follow/unfollow.
- Remove the now-dead HashtagFeedContent and GeotagFeedContent
  components and their unused imports.
This commit is contained in:
lemon
2026-05-13 18:31:24 -07:00
parent 3bb5f1d32b
commit 99897e1c9e
3 changed files with 127 additions and 172 deletions
+16 -158
View File
@@ -1,7 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { usePageRefresh } from '@/hooks/usePageRefresh'; import { usePageRefresh } from '@/hooks/usePageRefresh';
import { ComposeBox } from '@/components/ComposeBox'; import { ComposeBox } from '@/components/ComposeBox';
import { LandingHero } from '@/components/LandingHero'; import { LandingHero } from '@/components/LandingHero';
@@ -9,23 +7,19 @@ import { NoteCard } from '@/components/NoteCard';
import { PullToRefresh } from '@/components/PullToRefresh'; import { PullToRefresh } from '@/components/PullToRefresh';
import { FeedEmptyState } from '@/components/FeedEmptyState'; import { FeedEmptyState } from '@/components/FeedEmptyState';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Globe2, Loader2, MapPin } from 'lucide-react'; import { Globe2, Loader2 } from 'lucide-react';
import LoginDialog from '@/components/auth/LoginDialog'; import LoginDialog from '@/components/auth/LoginDialog';
import { useOnboarding } from '@/hooks/useOnboarding'; import { useOnboarding } from '@/hooks/useOnboarding';
import { useFeed } from '@/hooks/useFeed'; import { useFeed } from '@/hooks/useFeed';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useFollowingFeed } from '@/hooks/useFollowingFeed'; import { useFollowingFeed } from '@/hooks/useFollowingFeed';
import { DITTO_RELAYS } from '@/lib/appRelays';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab'; import { useFeedTab } from '@/hooks/useFeedTab';
import { useInterests } from '@/hooks/useInterests';
import { useMuteList } from '@/hooks/useMuteList'; import { useMuteList } from '@/hooks/useMuteList';
import { useTabFeed } from '@/hooks/useProfileFeed'; import { useTabFeed } from '@/hooks/useProfileFeed';
import { useSavedFeeds } from '@/hooks/useSavedFeeds'; import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter'; import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
import { useWorldFeed } from '@/hooks/useWorldFeed'; import { useWorldFeed } from '@/hooks/useWorldFeed';
import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { shouldHideFeedEvent } from '@/lib/feedUtils';
import { isRepostKind, shouldHideFeedEvent } from '@/lib/feedUtils';
import { isEventMuted } from '@/lib/muteHelpers'; import { isEventMuted } from '@/lib/muteHelpers';
import { SubHeaderBar } from '@/components/SubHeaderBar'; import { SubHeaderBar } from '@/components/SubHeaderBar';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground'; import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
@@ -34,7 +28,6 @@ import { Button } from '@/components/ui/button';
import { useNavHidden } from '@/contexts/LayoutContext'; import { useNavHidden } from '@/contexts/LayoutContext';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { FeedItem } from '@/lib/feedUtils'; import type { FeedItem } from '@/lib/feedUtils';
import type { NostrEvent } from '@nostrify/nostrify';
import type { SavedFeed } from '@/contexts/AppContext'; import type { SavedFeed } from '@/contexts/AppContext';
type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world'; type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world';
@@ -59,8 +52,6 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { muteItems } = useMuteList(); const { muteItems } = useMuteList();
const { savedFeeds } = useSavedFeeds(); const { savedFeeds } = useSavedFeeds();
const { hashtags } = useInterests();
const { hashtags: geotags } = useInterests('g');
const navHidden = useNavHidden(); const navHidden = useNavHidden();
// Tab settings from localStorage // Tab settings from localStorage
@@ -103,6 +94,9 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
if (!kinds) { if (!kinds) {
// Migrate legacy 'ditto' tab to 'world' // Migrate legacy 'ditto' tab to 'world'
if (rawActiveTab === 'ditto') return 'world'; if (rawActiveTab === 'ditto') return 'world';
// Legacy hashtag:/geotag: tabs are now part of the combined Following
// feed; surface them there instead of rendering a missing sub-feed.
if (rawActiveTab.startsWith('hashtag:') || rawActiveTab.startsWith('geotag:')) return 'follows';
return rawActiveTab; return rawActiveTab;
} }
if (rawActiveTab === 'global') return 'global'; if (rawActiveTab === 'global') return 'global';
@@ -116,14 +110,18 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
[savedFeeds, activeTab], [savedFeeds, activeTab],
); );
// Is the active tab a hashtag interest? // Migrate legacy hashtag:/geotag: tabs (which used to render their own
const activeHashtag = activeTab.startsWith('hashtag:') ? activeTab.slice(8) : null; // sub-feeds) back to the home Following feed. Followed hashtags/geotags
// now contribute to the combined Following feed instead of getting
// Is the active tab a geotag interest? // dedicated tabs.
const activeGeotag = activeTab.startsWith('geotag:') ? activeTab.slice(7) : null; useEffect(() => {
if (rawActiveTab.startsWith('hashtag:') || rawActiveTab.startsWith('geotag:')) {
handleSetActiveTab('follows');
}
}, [rawActiveTab, handleSetActiveTab]);
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs. // Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
// Extra tabs (World, Community, saved feeds, hashtags) are only for the home feed. // Extra tabs (World, Community, saved feeds) are only for the home feed.
const isKindSpecificPage = !!kinds; const isKindSpecificPage = !!kinds;
// When logged out (and not on a kind-specific page), show the World feed. // When logged out (and not on a kind-specific page), show the World feed.
@@ -277,37 +275,12 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
onClick={() => handleSetActiveTab(feed.id)} onClick={() => handleSetActiveTab(feed.id)}
/> />
))} ))}
{showSavedFeedTabs && hashtags.map((tag) => (
<TabButton
key={`hashtag:${tag}`}
label={`#${tag}`}
active={activeTab === `hashtag:${tag}`}
onClick={() => handleSetActiveTab(`hashtag:${tag}`)}
/>
))}
{showSavedFeedTabs && geotags.map((tag) => (
<TabButton
key={`geotag:${tag}`}
label={tag}
active={activeTab === `geotag:${tag}`}
onClick={() => handleSetActiveTab(`geotag:${tag}`)}
>
<span className="flex items-center justify-center gap-1">
<MapPin className="size-3.5" />
{tag}
</span>
</TabButton>
))}
</SubHeaderBar> </SubHeaderBar>
)} )}
{/* Feed content — saved feed tab gets its own stream */} {/* Feed content — saved feed tab gets its own stream */}
{user && <div style={{ height: ARC_OVERHANG_PX }} />} {user && <div style={{ height: ARC_OVERHANG_PX }} />}
{activeHashtag ? ( {activeSavedFeed ? (
<HashtagFeedContent tag={activeHashtag} />
) : activeGeotag ? (
<GeotagFeedContent tag={activeGeotag} />
) : activeSavedFeed ? (
<SavedFeedContent feed={activeSavedFeed} /> <SavedFeedContent feed={activeSavedFeed} />
) : ( ) : (
<PullToRefresh onRefresh={isWorldActive ? handleWorldRefresh : handleRefresh}> <PullToRefresh onRefresh={isWorldActive ? handleWorldRefresh : handleRefresh}>
@@ -506,121 +479,6 @@ function SavedFeedContent({ feed }: { feed: SavedFeed }) {
); );
} }
/** Renders a feed of posts tagged with a specific hashtag. */
function HashtagFeedContent({ tag }: { tag: string }) {
const { nostr } = useNostr();
const { muteItems } = useMuteList();
const { feedSettings } = useFeedSettings();
const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
const kindsKey = [...kinds].sort().join(',');
const queryKey = useMemo(() => ['hashtag-feed', tag, kindsKey], [tag, kindsKey]);
const handleRefresh = usePageRefresh(queryKey);
const { data: events, isLoading } = useQuery<NostrEvent[]>({
queryKey,
queryFn: async ({ signal }) => {
const ditto = nostr.group(DITTO_RELAYS);
return ditto.query(
[{ kinds, '#t': [tag.toLowerCase()], limit: 40 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) },
);
},
});
const filteredEvents = useMemo((): NostrEvent[] => {
if (!events) return [];
if (muteItems.length === 0) return events;
return events.filter((e) => !isEventMuted(e, muteItems));
}, [events, muteItems]);
if (isLoading && filteredEvents.length === 0) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</div>
);
}
if (filteredEvents.length === 0) {
return (
<PullToRefresh onRefresh={handleRefresh}>
<FeedEmptyState message={`No posts found with #${tag}.`} />
</PullToRefresh>
);
}
return (
<PullToRefresh onRefresh={handleRefresh}>
<div>
{filteredEvents.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</div>
</PullToRefresh>
);
}
/** Renders a feed of posts tagged with a specific geohash. */
function GeotagFeedContent({ tag }: { tag: string }) {
const { nostr } = useNostr();
const { muteItems } = useMuteList();
const { feedSettings } = useFeedSettings();
const kinds = getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k));
const kindsKey = [...kinds].sort().join(',');
const queryKey = useMemo(() => ['geotag-feed', tag, kindsKey], [tag, kindsKey]);
const handleRefresh = usePageRefresh(queryKey);
const { data: events, isLoading } = useQuery<NostrEvent[]>({
queryKey,
queryFn: async ({ signal }) => {
const ditto = nostr.group(DITTO_RELAYS);
const filter = { kinds, limit: 40 } as Record<string, unknown>;
filter['#g'] = [tag];
return ditto.query([filter as Parameters<typeof ditto.query>[0][number]], {
signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]),
});
},
});
const filteredEvents = useMemo((): NostrEvent[] => {
if (!events) return [];
if (muteItems.length === 0) return events;
return events.filter((e) => !isEventMuted(e, muteItems));
}, [events, muteItems]);
if (isLoading && filteredEvents.length === 0) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<NoteCardSkeleton key={i} />
))}
</div>
);
}
if (filteredEvents.length === 0) {
return (
<PullToRefresh onRefresh={handleRefresh}>
<FeedEmptyState message={`No posts found near ${tag}.`} />
</PullToRefresh>
);
}
return (
<PullToRefresh onRefresh={handleRefresh}>
<div>
{filteredEvents.map((event) => (
<NoteCard key={event.id} event={event} />
))}
</div>
</PullToRefresh>
);
}
function NoteCardSkeleton() { function NoteCardSkeleton() {
return ( return (
<div className="px-4 py-3 border-b border-border"> <div className="px-4 py-3 border-b border-border">
+107 -14
View File
@@ -6,21 +6,27 @@ import { useCommunityActivityFeed } from '@/hooks/useCommunityActivityFeed';
import { useCountryFollows } from '@/hooks/useCountryFollows'; import { useCountryFollows } from '@/hooks/useCountryFollows';
import { useFeed } from '@/hooks/useFeed'; import { useFeed } from '@/hooks/useFeed';
import { useFeedRelays } from '@/hooks/useFeedRelays'; import { useFeedRelays } from '@/hooks/useFeedRelays';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useInterests } from '@/hooks/useInterests';
import { getCountryFilterValues, parseCountryIdentifier } from '@/lib/countryIdentifiers'; import { getCountryFilterValues, parseCountryIdentifier } from '@/lib/countryIdentifiers';
import type { FeedItem } from '@/lib/feedUtils'; import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { isRepostKind, type FeedItem } from '@/lib/feedUtils';
import { buildTagFilterValues } from '@/lib/tagFilterValues';
const COUNTRY_PAGE_SIZE = 40; const COUNTRY_PAGE_SIZE = 40;
const HASHTAG_PAGE_SIZE = 40;
const CHALLENGE_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge']; const CHALLENGE_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge'];
/** /**
* Sliding window used as a fallback recency floor for the Following feed * Sliding window used as a fallback recency floor for the Following feed
* when the network feed has no events yet. Without this, an inactive * when the network feed has no events yet. Without this, an inactive
* follow list lets very old community/country events take over the top * follow list lets very old community/country/hashtag events take over
* of the feed before the network feed has a chance to populate. * the top of the feed before the network feed has a chance to populate.
* *
* 14 days is intentionally short to keep the Following feed feeling * 14 days is intentionally short to keep the Following feed feeling
* "current" — items older than this should only be reachable via * "current" — items older than this should only be reachable via
* pagination on dedicated tabs (Network, Communities, country pages). * pagination on dedicated tabs (Network, Communities, country/hashtag
* pages).
*/ */
const RECENCY_WINDOW_SECONDS = 14 * 24 * 60 * 60; const RECENCY_WINDOW_SECONDS = 14 * 24 * 60 * 60;
@@ -30,6 +36,12 @@ interface CountryFeedPage {
totalFetched: number; totalFetched: number;
} }
interface HashtagFeedPage {
events: NostrEvent[];
oldestTimestamp: number | null;
totalFetched: number;
}
function eventCountryCode(event: NostrEvent): string | undefined { function eventCountryCode(event: NostrEvent): string | undefined {
const iTag = event.tags.find(([name]) => name === 'i')?.[1]; const iTag = event.tags.find(([name]) => name === 'i')?.[1];
const parsedITag = iTag ? parseCountryIdentifier(iTag) : undefined; const parsedITag = iTag ? parseCountryIdentifier(iTag) : undefined;
@@ -91,10 +103,66 @@ function useFollowedCountriesFeed(countryCodes: string[], enabled: boolean) {
}); });
} }
function useFollowedHashtagsFeed(hashtags: string[], kinds: number[], enabled: boolean) {
const feedRelays = useFeedRelays();
const hashtagsKey = hashtags.join(',');
const kindsKey = [...kinds].sort().join(',');
return useInfiniteQuery<HashtagFeedPage, Error>({
queryKey: ['following-hashtag-feed', hashtagsKey, kindsKey],
queryFn: async ({ pageParam, signal: querySignal }) => {
if (hashtags.length === 0 || kinds.length === 0) {
return { events: [], oldestTimestamp: null, totalFetched: 0 };
}
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(5000)]);
const until = pageParam as number | undefined;
// Hashtags on Nostr are case-sensitive at the relay level but the UI
// treats them as case-insensitive. Pass through the same expansion
// used by the dedicated hashtag page so we don't miss posts that
// tag, e.g., `#Bitcoin` instead of `#bitcoin`.
const filterValues = Array.from(new Set(
hashtags.flatMap((tag) => buildTagFilterValues(tag, '#t')),
));
const filter: NostrFilter = {
kinds,
'#t': filterValues,
limit: HASHTAG_PAGE_SIZE,
...(until && { until }),
};
const events = await feedRelays.query([filter], { signal });
const sorted = [...events].sort((a, b) => b.created_at - a.created_at);
const pageEvents = sorted.slice(0, HASHTAG_PAGE_SIZE);
const oldestTimestamp = pageEvents.length > 0
? pageEvents[pageEvents.length - 1].created_at
: null;
return {
events: pageEvents,
oldestTimestamp,
totalFetched: sorted.length,
};
},
initialPageParam: undefined as number | undefined,
getNextPageParam: (lastPage) => {
if (lastPage.totalFetched < HASHTAG_PAGE_SIZE || !lastPage.oldestTimestamp) return undefined;
return lastPage.oldestTimestamp - 1;
},
enabled: enabled && hashtags.length > 0 && kinds.length > 0,
staleTime: 30_000,
refetchOnWindowFocus: false,
placeholderData: (previousData) => previousData,
});
}
/** /**
* Combined "Following" feed: people you follow + your communities' activity + * Combined "Following" feed: people you follow + your communities' activity +
* the countries you follow. Items are sorted strictly by recency * the countries you follow + the hashtags you follow. Items are sorted
* (`sortTimestamp` desc) with no per-source prioritisation. * strictly by recency (`sortTimestamp` desc) with no per-source
* prioritisation.
* *
* Older content from sources with sparse activity is filtered out so the * 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 * top of the feed doesn't drift back in time while a higher-volume source
@@ -105,11 +173,20 @@ function useFollowedCountriesFeed(countryCodes: string[], enabled: boolean) {
* applies a recency floor). * applies a recency floor).
*/ */
export function useFollowingFeed(enabled = true) { export function useFollowingFeed(enabled = true) {
const { feedSettings } = useFeedSettings();
const hashtagKinds = useMemo(
() => getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k)),
[feedSettings],
);
const networkFeed = useFeed('network', { enabled }); const networkFeed = useFeed('network', { enabled });
const communityFeed = useCommunityActivityFeed(enabled); const communityFeed = useCommunityActivityFeed(enabled);
const { followedCountries, isLoading: countryFollowsLoading } = useCountryFollows(); const { followedCountries, isLoading: countryFollowsLoading } = useCountryFollows();
const countryFeed = useFollowedCountriesFeed(followedCountries, enabled); const countryFeed = useFollowedCountriesFeed(followedCountries, enabled);
const hasFollowedCountries = followedCountries.length > 0; const hasFollowedCountries = followedCountries.length > 0;
const { hashtags: followedHashtags, isLoading: hashtagFollowsLoading } = useInterests('t');
const hashtagFeed = useFollowedHashtagsFeed(followedHashtags, hashtagKinds, enabled);
const hasFollowedHashtags = followedHashtags.length > 0;
const data = useMemo(() => { const data = useMemo(() => {
const networkItems = (networkFeed.data?.pages as unknown as { items: FeedItem[] }[] | undefined) const networkItems = (networkFeed.data?.pages as unknown as { items: FeedItem[] }[] | undefined)
@@ -124,9 +201,14 @@ export function useFollowingFeed(enabled = true) {
.flatMap((page) => page.events) .flatMap((page) => page.events)
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at })); .map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
const hashtagItems = (hashtagFeed.data?.pages ?? [])
.flatMap((page) => page.events)
.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
// Recency floor: prevent an older event from a sparse source (e.g. a // Recency floor: prevent an older event from a sparse source (e.g. a
// community or country with little recent activity) from out-ranking a // community/country/hashtag with little recent activity) from
// newer item that simply hasn't loaded into the network feed yet. // out-ranking a newer item that simply hasn't loaded into the network
// feed yet.
const nowSeconds = Math.floor(Date.now() / 1000); const nowSeconds = Math.floor(Date.now() / 1000);
const networkOldest = networkItems.length > 0 const networkOldest = networkItems.length > 0
? Math.min(...networkItems.map((item) => item.sortTimestamp)) ? Math.min(...networkItems.map((item) => item.sortTimestamp))
@@ -137,9 +219,9 @@ export function useFollowingFeed(enabled = true) {
: windowFloor; : windowFloor;
// Network items pass through untouched — they define their own // Network items pass through untouched — they define their own
// recency floor. Community and country items are filtered to drop // recency floor. Community, country, and hashtag items are filtered
// anything older than the floor. // to drop anything older than the floor.
const trimmedExternal = [...communityItems, ...countryItems] const trimmedExternal = [...communityItems, ...countryItems, ...hashtagItems]
.filter((item) => item.sortTimestamp >= recencyFloor); .filter((item) => item.sortTimestamp >= recencyFloor);
const merged = [...networkItems, ...trimmedExternal]; const merged = [...networkItems, ...trimmedExternal];
@@ -160,7 +242,7 @@ export function useFollowingFeed(enabled = true) {
); );
return { pages: [{ items: sorted }] }; return { pages: [{ items: sorted }] };
}, [networkFeed.data?.pages, communityFeed.data, countryFeed.data?.pages]); }, [networkFeed.data?.pages, communityFeed.data, countryFeed.data?.pages, hashtagFeed.data?.pages]);
const networkHasNextPage = networkFeed.hasNextPage; const networkHasNextPage = networkFeed.hasNextPage;
const networkFetchNextPage = networkFeed.fetchNextPage; const networkFetchNextPage = networkFeed.fetchNextPage;
@@ -168,12 +250,15 @@ export function useFollowingFeed(enabled = true) {
const communityFetchNextPage = communityFeed.fetchNextPage; const communityFetchNextPage = communityFeed.fetchNextPage;
const countryHasNextPage = countryFeed.hasNextPage; const countryHasNextPage = countryFeed.hasNextPage;
const countryFetchNextPage = countryFeed.fetchNextPage; const countryFetchNextPage = countryFeed.fetchNextPage;
const hashtagHasNextPage = hashtagFeed.hasNextPage;
const hashtagFetchNextPage = hashtagFeed.fetchNextPage;
const fetchNextPage = useCallback(async () => { const fetchNextPage = useCallback(async () => {
await Promise.all([ await Promise.all([
networkHasNextPage ? networkFetchNextPage() : Promise.resolve(), networkHasNextPage ? networkFetchNextPage() : Promise.resolve(),
communityHasNextPage ? communityFetchNextPage() : Promise.resolve(), communityHasNextPage ? communityFetchNextPage() : Promise.resolve(),
countryHasNextPage ? countryFetchNextPage() : Promise.resolve(), countryHasNextPage ? countryFetchNextPage() : Promise.resolve(),
hashtagHasNextPage ? hashtagFetchNextPage() : Promise.resolve(),
]); ]);
}, [ }, [
networkHasNextPage, networkHasNextPage,
@@ -182,6 +267,8 @@ export function useFollowingFeed(enabled = true) {
communityFetchNextPage, communityFetchNextPage,
countryHasNextPage, countryHasNextPage,
countryFetchNextPage, countryFetchNextPage,
hashtagHasNextPage,
hashtagFetchNextPage,
]); ]);
return { return {
@@ -191,19 +278,25 @@ export function useFollowingFeed(enabled = true) {
|| communityFeed.isLoading || communityFeed.isLoading
|| countryFollowsLoading || countryFollowsLoading
|| (hasFollowedCountries && countryFeed.isPending) || (hasFollowedCountries && countryFeed.isPending)
|| hashtagFollowsLoading
|| (hasFollowedHashtags && hashtagFeed.isPending)
), ),
isLoading: enabled && ( isLoading: enabled && (
networkFeed.isLoading networkFeed.isLoading
|| communityFeed.isLoading || communityFeed.isLoading
|| countryFollowsLoading || countryFollowsLoading
|| (hasFollowedCountries && countryFeed.isLoading) || (hasFollowedCountries && countryFeed.isLoading)
|| hashtagFollowsLoading
|| (hasFollowedHashtags && hashtagFeed.isLoading)
), ),
fetchNextPage, fetchNextPage,
hasNextPage: !!networkFeed.hasNextPage hasNextPage: !!networkFeed.hasNextPage
|| !!communityFeed.hasNextPage || !!communityFeed.hasNextPage
|| !!countryFeed.hasNextPage, || !!countryFeed.hasNextPage
|| !!hashtagFeed.hasNextPage,
isFetchingNextPage: networkFeed.isFetchingNextPage isFetchingNextPage: networkFeed.isFetchingNextPage
|| communityFeed.isFetchingNextPage || communityFeed.isFetchingNextPage
|| countryFeed.isFetchingNextPage, || countryFeed.isFetchingNextPage
|| hashtagFeed.isFetchingNextPage,
}; };
} }
+4
View File
@@ -56,6 +56,10 @@ export function useInterests(tagName: InterestTagName = 't') {
const invalidate = () => { const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['interests', user?.pubkey] }); queryClient.invalidateQueries({ queryKey: ['interests', user?.pubkey] });
// The Following feed pulls from `t`/`g`/`i` interests, so any mutation
// here can change what shows up there.
queryClient.invalidateQueries({ queryKey: ['following-hashtag-feed'] });
queryClient.invalidateQueries({ queryKey: ['following-country-feed'] });
}; };
/** Add an interest. */ /** Add an interest. */