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:
+16
-158
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user