Add Following feed combining people, communities, and countries

- Split the home feed's old Follows tab into 'Following' (combined) and
  'Network' (people-only, original behavior preserved).
- Add country follows via NIP-51 kind 10015 i tags (iso3166:XX), with
  a Follow/Unfollow button on country pages reusing FollowToggleButton.
- New useFollowingFeed merges network + community activity + followed
  country events, sorted strictly by recency. A recency floor (oldest
  loaded network item, or now-14d when network is empty) prevents
  sparse sources from surfacing old events too early.
- Empty state on Following is country-centric and routes to the World
  tab to encourage country discovery.
- Invalidate the new feed query keys on follow/unfollow and
  community-bookmark mutations.
This commit is contained in:
lemon
2026-05-13 13:20:32 -07:00
parent 2fc7a9ac41
commit 3bb5f1d32b
9 changed files with 409 additions and 52 deletions
+48 -15
View File
@@ -6,6 +6,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { ExternalReactionButton } from '@/components/ExternalReactionButton';
import { FollowToggleButton } from '@/components/FollowButton';
import { LinkEmbed } from '@/components/LinkEmbed';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
@@ -18,6 +19,8 @@ import { useBlueskyPost } from '@/hooks/useBlueskyPost';
import { useBookInfo } from '@/hooks/useBookInfo';
import { useAddrEvent } from '@/hooks/useEvent';
import { useAuthor } from '@/hooks/useAuthor';
import { useCountryFollows } from '@/hooks/useCountryFollows';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useWeather } from '@/hooks/useWeather';
import { useToast } from '@/hooks/useToast';
@@ -665,6 +668,23 @@ export function CountryContentHeader({ code }: { code: string }) {
const info = getCountryInfo(code);
const wikiTitle = getWikipediaTitle(code);
const { data: wiki, isLoading: wikiLoading } = useWikipediaSummary(wikiTitle);
const { user } = useCurrentUser();
const { isFollowingCountry, toggleCountryFollow, isPending } = useCountryFollows();
const { toast } = useToast();
const isFollowing = info ? isFollowingCountry(code) : false;
const handleToggleFollow = useCallback(async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!user || !info || isPending) return;
try {
await toggleCountryFollow(code);
toast({ title: isFollowing ? 'Country unfollowed' : 'Country followed' });
} catch {
toast({ title: 'Failed to update country follow', variant: 'destructive' });
}
}, [user, info, isPending, toggleCountryFollow, code, toast, isFollowing]);
if (!info) {
return (
@@ -679,7 +699,7 @@ export function CountryContentHeader({ code }: { code: string }) {
<div className="rounded-2xl border border-border overflow-hidden">
{/* Flag + name */}
<div className="p-6 sm:p-8">
<div className="flex items-center gap-4">
<div className="flex items-start gap-4">
{info.subdivision && wiki?.thumbnail ? (
<img
src={wiki.thumbnail.source}
@@ -691,20 +711,33 @@ export function CountryContentHeader({ code }: { code: string }) {
{info.flag}
</span>
)}
<div className="space-y-1">
<h2 className="text-2xl sm:text-3xl font-bold leading-snug">
{info.subdivisionName ?? info.name}
</h2>
{info.subdivision && (
<p className="text-sm text-muted-foreground">
{info.name}{info.subdivisionName ? '' : ` · ${info.subdivision}`}
</p>
)}
{wiki?.description && (
<p className="text-sm text-muted-foreground capitalize">
{wiki.description}
</p>
)}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h2 className="text-2xl sm:text-3xl font-bold leading-snug">
{info.subdivisionName ?? info.name}
</h2>
{info.subdivision && (
<p className="text-sm text-muted-foreground">
{info.name}{info.subdivisionName ? '' : ` · ${info.subdivision}`}
</p>
)}
{wiki?.description && (
<p className="text-sm text-muted-foreground capitalize">
{wiki.description}
</p>
)}
</div>
{user && (
<FollowToggleButton
size="sm"
isFollowing={isFollowing}
isPending={isPending}
onClick={handleToggleFollow}
className="shrink-0"
/>
)}
</div>
</div>
</div>
+61 -19
View File
@@ -9,11 +9,12 @@ import { NoteCard } from '@/components/NoteCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { FeedEmptyState } from '@/components/FeedEmptyState';
import { Skeleton } from '@/components/ui/skeleton';
import { Loader2, MapPin } from 'lucide-react';
import { Globe2, Loader2, MapPin } from 'lucide-react';
import LoginDialog from '@/components/auth/LoginDialog';
import { useOnboarding } from '@/hooks/useOnboarding';
import { useFeed } from '@/hooks/useFeed';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useFollowingFeed } from '@/hooks/useFollowingFeed';
import { DITTO_RELAYS } from '@/lib/appRelays';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
@@ -29,13 +30,14 @@ import { isEventMuted } from '@/lib/muteHelpers';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
import { TabButton } from '@/components/TabButton';
import { Button } from '@/components/ui/button';
import { useNavHidden } from '@/contexts/LayoutContext';
import { cn } from '@/lib/utils';
import type { FeedItem } from '@/lib/feedUtils';
import type { NostrEvent } from '@nostrify/nostrify';
import type { SavedFeed } from '@/contexts/AppContext';
type CoreFeedTab = 'follows' | 'global' | 'communities' | 'world';
type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world';
type FeedTab = CoreFeedTab | string; // string = saved feed id
interface FeedProps {
@@ -120,6 +122,10 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
// Is the active tab a geotag interest?
const activeGeotag = activeTab.startsWith('geotag:') ? activeTab.slice(7) : null;
// 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.
const isKindSpecificPage = !!kinds;
// When logged out (and not on a kind-specific page), show the World feed.
const useWorldForLoggedOut = !user && !kinds;
@@ -131,25 +137,37 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const isWorldActive = useWorldForLoggedOut || !!useWorldTab;
// Standard feed query (used when logged in, or on kind-specific pages, or core tabs)
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world';
type UseFeedTab = 'follows' | 'global' | 'communities';
const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters;
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world';
type UseFeedTab = 'follows' | 'network' | 'global' | 'communities';
const feedTabForQuery: UseFeedTab =
activeTab === 'follows' || activeTab === 'global' || activeTab === 'communities'
? (activeTab as UseFeedTab)
activeTab === 'follows'
? (isHomeFollowingActive ? 'network' : 'network')
: activeTab === 'network' || activeTab === 'global' || activeTab === 'communities'
? (activeTab as UseFeedTab)
: 'global';
const standardFeedOptions = (kinds || tagFilters)
? { kinds, tagFilters, enabled: !isHomeFollowingActive }
: { enabled: !isHomeFollowingActive };
const feedQuery = useFeed(
isCoreFeedTab && !isWorldActive ? feedTabForQuery : 'global',
(kinds || tagFilters) ? { kinds, tagFilters } : undefined,
standardFeedOptions,
);
const followingFeed = useFollowingFeed(isHomeFollowingActive);
// World feed: all country-tagged events with diversity cap + live streaming.
const worldFeed = useWorldFeed(isWorldActive);
const { flushStreamBuffer } = worldFeed;
// For non-world tabs, use the standard feed query
const queryKey = useMemo(
() => isWorldActive ? ['world-feed'] : ['feed', activeTab],
[isWorldActive, activeTab],
() => isWorldActive
? ['world-feed']
: isHomeFollowingActive
? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
: ['feed', activeTab],
[isWorldActive, isHomeFollowingActive, activeTab],
);
const handleRefresh = usePageRefresh(queryKey);
@@ -166,7 +184,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
fetchNextPage: fetchNextPageStandard,
hasNextPage: hasNextPageStandard,
isFetchingNextPage: isFetchingNextPageStandard,
} = feedQuery;
} = isHomeFollowingActive ? followingFeed : feedQuery;
// Unify pagination interface
const fetchNextPage = isWorldActive ? worldFeed.fetchNextPage : fetchNextPageStandard;
@@ -175,10 +193,10 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
// Auto-fetch page 2 as soon as page 1 arrives for smoother scrolling
useEffect(() => {
if (!isWorldActive && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
if (!isHomeFollowingActive && !isWorldActive && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
fetchNextPage();
}
}, [isWorldActive, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
}, [isHomeFollowingActive, isWorldActive, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
// Intersection observer for infinite scroll
const { ref: scrollRef, inView } = useInView({
@@ -219,9 +237,6 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
? worldFeed.isLoading
: (isPending || (isLoading && !rawData));
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
// Extra tabs (Ditto, Community, saved feeds, hashtags) are only for the home feed.
const isKindSpecificPage = !!kinds;
const showSavedFeedTabs = user && !isKindSpecificPage && !tagFilters;
return (
@@ -241,7 +256,10 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
{/* Tabs (logged in) */}
{user && (
<SubHeaderBar>
<TabButton label="Follows" active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
{!isKindSpecificPage && !tagFilters && (
<TabButton label="Network" active={activeTab === 'network'} onClick={() => handleSetActiveTab('network')} />
)}
{!isKindSpecificPage && showWorldFeed && (
<TabButton label="World" active={activeTab === 'world'} onClick={() => handleSetActiveTab('world')} />
)}
@@ -340,20 +358,22 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
</div>
)}
</div>
) : isHomeFollowingActive && !emptyMessage ? (
<FollowingEmptyState onExploreWorld={() => handleSetActiveTab('world')} />
) : (
<FeedEmptyState
message={
emptyMessage ?? (
activeTab === 'follows'
activeTab === 'follows' || activeTab === 'network'
? 'Your feed is empty. Follow some people to see their posts here.'
: activeTab === 'world'
? 'No world posts yet. Check back soon for global activity.'
: 'No posts found. Check your relay connections or come back soon.'
)
}
showDiscover={!emptyMessage && activeTab === 'follows'}
showDiscover={!emptyMessage && (activeTab === 'follows' || activeTab === 'network')}
onSwitchToGlobal={
activeTab === 'follows' && showGlobalFeed
(activeTab === 'follows' || activeTab === 'network') && showGlobalFeed
? () => handleSetActiveTab('global')
: undefined
}
@@ -624,3 +644,25 @@ function NoteCardSkeleton() {
</div>
);
}
function FollowingEmptyState({ onExploreWorld }: { onExploreWorld: () => void }) {
return (
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<Globe2 className="size-8 text-primary" />
</div>
<div className="space-y-2 max-w-xs">
<h2 className="text-xl font-bold">No countries yet</h2>
<p className="text-muted-foreground text-sm">
Explore the World tab and follow countries to build your Following feed.
</p>
</div>
<div className="flex flex-col gap-2 w-full max-w-xs">
<Button className="rounded-full" onClick={onExploreWorld}>
<Globe2 className="size-4 mr-2" />
Explore World
</Button>
</div>
</div>
);
}
+4 -4
View File
@@ -68,7 +68,7 @@ const INITIAL_PAGE_PARAM: ActivityFeedPageParam = {
* Also returns per-community `moderationByATag` and `rankMapByATag` so
* callers can provide `CommunityModerationContext` to `NoteMoreMenu`.
*/
export function useCommunityActivityFeed() {
export function useCommunityActivityFeed(enabled = true) {
const { nostr } = useNostr();
const queryClient = useQueryClient();
const { data: myCommunities, isLoading: communitiesLoading } = useMyCommunities();
@@ -259,7 +259,7 @@ export function useCommunityActivityFeed() {
} satisfies ActivityFeedPageParam;
},
initialPageParam: INITIAL_PAGE_PARAM,
enabled: !communitiesLoading && aTags.length > 0,
enabled: enabled && !communitiesLoading && aTags.length > 0,
staleTime: 2 * 60_000,
gcTime: 30 * 60_000,
placeholderData: (prev) => prev,
@@ -283,12 +283,12 @@ export function useCommunityActivityFeed() {
data: query.data ? events : undefined,
moderationByATag: (latestPage?.moderationByATag ?? EMPTY_MODERATION_BY_A_TAG) as Map<string, CommunityModeration>,
rankMapByATag: (latestPage?.rankMapByATag ?? EMPTY_RANK_MAP_BY_A_TAG) as Map<string, Map<string, CommunityMember>>,
isLoading: communitiesLoading || query.isLoading,
isLoading: enabled && (communitiesLoading || query.isLoading),
isError: query.isError,
error: query.error,
hasNextPage: query.hasNextPage,
isFetchingNextPage: query.isFetchingNextPage,
fetchNextPage: query.fetchNextPage,
};
}, [query.data, communitiesLoading, query.isLoading, query.isError, query.error, query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
}, [query.data, enabled, communitiesLoading, query.isLoading, query.isError, query.error, query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]);
}
+2
View File
@@ -112,6 +112,8 @@ export function useCommunityBookmarks() {
onSuccess: ({ removed }) => {
queryClient.invalidateQueries({ queryKey: ['community-bookmarks', user?.pubkey] });
queryClient.invalidateQueries({ queryKey: ['my-communities'] });
queryClient.invalidateQueries({ queryKey: ['community-activity-feed'] });
queryClient.invalidateQueries({ queryKey: ['following-feed'] });
toast({
title: removed ? 'Community unfollowed' : 'Community followed',
});
+56
View File
@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useInterests } from '@/hooks/useInterests';
import { createCountryIdentifier, parseCountryIdentifier } from '@/lib/countryIdentifiers';
export function useCountryFollows() {
const queryClient = useQueryClient();
const interests = useInterests('i');
const followedCountries = useMemo(
() => interests.hashtags
.map((identifier) => parseCountryIdentifier(identifier))
.filter((code): code is string => !!code),
[interests.hashtags],
);
function isFollowingCountry(code: string): boolean {
try {
const parsed = parseCountryIdentifier(createCountryIdentifier(code));
return !!parsed && followedCountries.includes(parsed);
} catch {
return false;
}
}
async function followCountry(code: string): Promise<void> {
await interests.addInterest.mutateAsync(createCountryIdentifier(code));
queryClient.invalidateQueries({ queryKey: ['following-country-feed'] });
queryClient.invalidateQueries({ queryKey: ['following-feed'] });
}
async function unfollowCountry(code: string): Promise<void> {
await interests.removeInterest.mutateAsync(createCountryIdentifier(code));
queryClient.invalidateQueries({ queryKey: ['following-country-feed'] });
queryClient.invalidateQueries({ queryKey: ['following-feed'] });
}
async function toggleCountryFollow(code: string): Promise<void> {
if (isFollowingCountry(code)) {
await unfollowCountry(code);
} else {
await followCountry(code);
}
}
return {
followedCountries,
isFollowingCountry,
followCountry,
unfollowCountry,
toggleCountryFollow,
isPending: interests.addInterest.isPending || interests.removeInterest.isPending,
isLoading: interests.isLoading,
};
}
+10 -7
View File
@@ -38,10 +38,12 @@ interface UseFeedOptions {
authors?: string[];
/** Additional tag filters to apply (e.g. `{ '#m': ['application/x-webxdc'] }`). */
tagFilters?: Record<string, string[]>;
/** Whether the query should run. */
enabled?: boolean;
}
/** Hook to fetch the global, followed, or communities feed with infinite scroll pagination. */
export function useFeed(tab: 'follows' | 'global' | 'communities', options?: UseFeedOptions) {
/** Hook to fetch the global, followed/network, or communities feed with infinite scroll pagination. */
export function useFeed(tab: 'follows' | 'network' | 'global' | 'communities', options?: UseFeedOptions) {
const { nostr } = useNostr();
const queryClient = useQueryClient();
const { user } = useCurrentUser();
@@ -60,12 +62,13 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
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.
// For follow-list based tabs, 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 isFollowListTab = tab === 'follows' || tab === 'network';
const followsReady = authorsOverride
? authorsOverride.length > 0
: tab !== 'follows' || (!!user && followList !== undefined);
: !isFollowListTab || (!!user && followList !== undefined);
// Load community pubkeys from localStorage
const communityPubkeys = (() => {
@@ -244,8 +247,8 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
cacheEvents(dedupedItems);
return { items: dedupedItems, oldestQueryTimestamp, rawCount: validFilteredEvents.length };
} else if ((authorsOverride && authorsOverride.length > 0) || (tab === 'follows' && user && followList !== undefined)) {
// Follows feed — posts, reposts, and extra kinds from people you follow
} else if ((authorsOverride && authorsOverride.length > 0) || (isFollowListTab && user && followList !== undefined)) {
// Follows/network feed — posts, reposts, and extra kinds from people you follow
// If followList is empty, just query own posts
const authors = authorsOverride ?? (user && followList
? (followList.length > 0 ? [...followList, user.pubkey] : [user.pubkey])
@@ -368,7 +371,7 @@ export function useFeed(tab: 'follows' | 'global' | 'communities', options?: Use
return lastPage.oldestQueryTimestamp - 1;
},
initialPageParam: undefined as number | undefined,
enabled: followsReady,
enabled: followsReady && (options?.enabled ?? true),
staleTime: 60 * 1000,
// No refetchInterval — automatic background refetches cause the entire
// feed to re-sort and jump. Users can pull-to-refresh for fresh content.
+2
View File
@@ -153,6 +153,8 @@ export function useFollowActions(): UseFollowActionsReturn {
// ⑥ Invalidate cached follow-list queries so UI updates
queryClient.invalidateQueries({ queryKey: ['follow-list'] });
queryClient.invalidateQueries({ queryKey: ['feed'] });
queryClient.invalidateQueries({ queryKey: ['following-feed'] });
} finally {
setIsPending(false);
}
+209
View File
@@ -0,0 +1,209 @@
import { useCallback, useMemo } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { useCommunityActivityFeed } from '@/hooks/useCommunityActivityFeed';
import { useCountryFollows } from '@/hooks/useCountryFollows';
import { useFeed } from '@/hooks/useFeed';
import { useFeedRelays } from '@/hooks/useFeedRelays';
import { getCountryFilterValues, parseCountryIdentifier } from '@/lib/countryIdentifiers';
import type { FeedItem } from '@/lib/feedUtils';
const COUNTRY_PAGE_SIZE = 40;
const CHALLENGE_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge'];
/**
* Sliding window used as a fallback recency floor for the Following feed
* when the network feed has no events yet. Without this, an inactive
* follow list lets very old community/country events take over 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
* "current" — items older than this should only be reachable via
* pagination on dedicated tabs (Network, Communities, country pages).
*/
const RECENCY_WINDOW_SECONDS = 14 * 24 * 60 * 60;
interface CountryFeedPage {
events: NostrEvent[];
oldestTimestamp: number | null;
totalFetched: number;
}
function eventCountryCode(event: NostrEvent): string | undefined {
const iTag = event.tags.find(([name]) => name === 'i')?.[1];
const parsedITag = iTag ? parseCountryIdentifier(iTag) : undefined;
if (parsedITag) return parsedITag;
const locationTag = event.tags.find(([name]) => name === 'location')?.[1];
return locationTag ? parseCountryIdentifier(`iso3166:${locationTag}`) : undefined;
}
function useFollowedCountriesFeed(countryCodes: string[], enabled: boolean) {
const feedRelays = useFeedRelays();
const countryKey = countryCodes.join(',');
return useInfiniteQuery<CountryFeedPage, Error>({
queryKey: ['following-country-feed', countryKey],
queryFn: async ({ pageParam, signal: querySignal }) => {
if (countryCodes.length === 0) {
return { events: [], oldestTimestamp: null, totalFetched: 0 };
}
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(5000)]);
const until = pageParam as number | undefined;
const followed = new Set(countryCodes.map((code) => code.toUpperCase()));
const filterValues = countryCodes.flatMap((code) => getCountryFilterValues(code, true));
const filters: NostrFilter[] = [
{ kinds: [1111, 1068], '#i': filterValues, limit: COUNTRY_PAGE_SIZE, ...(until && { until }) },
{ kinds: [36639], '#t': CHALLENGE_T_ALIASES, limit: Math.floor(COUNTRY_PAGE_SIZE / 4), ...(until && { until }) },
];
const events = await feedRelays.query(filters, { signal });
const filteredEvents = events
.filter((event) => {
const code = eventCountryCode(event);
return !!code && followed.has(code);
})
.sort((a, b) => b.created_at - a.created_at);
const pageEvents = filteredEvents.slice(0, COUNTRY_PAGE_SIZE);
const oldestTimestamp = pageEvents.length > 0
? pageEvents[pageEvents.length - 1].created_at
: null;
return {
events: pageEvents,
oldestTimestamp,
totalFetched: filteredEvents.length,
};
},
initialPageParam: undefined as number | undefined,
getNextPageParam: (lastPage) => {
if (lastPage.totalFetched < COUNTRY_PAGE_SIZE || !lastPage.oldestTimestamp) return undefined;
return lastPage.oldestTimestamp - 1;
},
enabled: enabled && countryCodes.length > 0,
staleTime: 30_000,
refetchOnWindowFocus: false,
placeholderData: (previousData) => previousData,
});
}
/**
* Combined "Following" feed: people you follow + your communities' activity +
* the countries you follow. Items are sorted strictly by recency
* (`sortTimestamp` desc) with no per-source prioritisation.
*
* 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
* is still loading. The cutoff is the more recent of:
*
* - the oldest event currently loaded from the network feed, or
* - `now - RECENCY_WINDOW_SECONDS` (so an empty network feed still
* applies a recency floor).
*/
export function useFollowingFeed(enabled = true) {
const networkFeed = useFeed('network', { enabled });
const communityFeed = useCommunityActivityFeed(enabled);
const { followedCountries, isLoading: countryFollowsLoading } = useCountryFollows();
const countryFeed = useFollowedCountriesFeed(followedCountries, enabled);
const hasFollowedCountries = followedCountries.length > 0;
const data = useMemo(() => {
const networkItems = (networkFeed.data?.pages as unknown as { items: FeedItem[] }[] | undefined)
?.flatMap((page) => page.items) ?? [];
const communityItems = (communityFeed.data ?? []).map((event): FeedItem => ({
event,
sortTimestamp: event.created_at,
}));
const countryItems = (countryFeed.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
// community or country with little recent activity) from out-ranking a
// newer item that simply hasn't loaded into the network feed yet.
const nowSeconds = Math.floor(Date.now() / 1000);
const networkOldest = networkItems.length > 0
? Math.min(...networkItems.map((item) => item.sortTimestamp))
: null;
const windowFloor = nowSeconds - RECENCY_WINDOW_SECONDS;
const recencyFloor = networkOldest !== null
? Math.max(networkOldest, windowFloor)
: windowFloor;
// Network items pass through untouched — they define their own
// recency floor. Community and country items are filtered to drop
// anything older than the floor.
const trimmedExternal = [...communityItems, ...countryItems]
.filter((item) => item.sortTimestamp >= recencyFloor);
const merged = [...networkItems, ...trimmedExternal];
const seen = new Map<string, FeedItem>();
for (const item of merged) {
const key = item.repostedBy
? `repost-${item.repostedBy}-${item.event.id}`
: item.event.id;
const existing = seen.get(key);
if (!existing || item.sortTimestamp > existing.sortTimestamp) {
seen.set(key, item);
}
}
const sorted = Array.from(seen.values()).sort(
(a, b) => b.sortTimestamp - a.sortTimestamp,
);
return { pages: [{ items: sorted }] };
}, [networkFeed.data?.pages, communityFeed.data, countryFeed.data?.pages]);
const networkHasNextPage = networkFeed.hasNextPage;
const networkFetchNextPage = networkFeed.fetchNextPage;
const communityHasNextPage = communityFeed.hasNextPage;
const communityFetchNextPage = communityFeed.fetchNextPage;
const countryHasNextPage = countryFeed.hasNextPage;
const countryFetchNextPage = countryFeed.fetchNextPage;
const fetchNextPage = useCallback(async () => {
await Promise.all([
networkHasNextPage ? networkFetchNextPage() : Promise.resolve(),
communityHasNextPage ? communityFetchNextPage() : Promise.resolve(),
countryHasNextPage ? countryFetchNextPage() : Promise.resolve(),
]);
}, [
networkHasNextPage,
networkFetchNextPage,
communityHasNextPage,
communityFetchNextPage,
countryHasNextPage,
countryFetchNextPage,
]);
return {
data,
isPending: enabled && (
networkFeed.isPending
|| communityFeed.isLoading
|| countryFollowsLoading
|| (hasFollowedCountries && countryFeed.isPending)
),
isLoading: enabled && (
networkFeed.isLoading
|| communityFeed.isLoading
|| countryFollowsLoading
|| (hasFollowedCountries && countryFeed.isLoading)
),
fetchNextPage,
hasNextPage: !!networkFeed.hasNextPage
|| !!communityFeed.hasNextPage
|| !!countryFeed.hasNextPage,
isFetchingNextPage: networkFeed.isFetchingNextPage
|| communityFeed.isFetchingNextPage
|| countryFeed.isFetchingNextPage,
};
}
+17 -7
View File
@@ -10,7 +10,17 @@ import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
export function useInterests(tagName: 't' | 'g' = 't') {
type InterestTagName = 't' | 'g' | 'i';
function normalizeInterest(tagName: InterestTagName, value: string): string {
const stripped = value.replace(/^#/, '').trim();
if (tagName === 'i' && stripped.toLowerCase().startsWith('iso3166:')) {
return `iso3166:${stripped.slice('iso3166:'.length).toUpperCase()}`;
}
return stripped.toLowerCase();
}
export function useInterests(tagName: InterestTagName = 't') {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
@@ -36,12 +46,12 @@ export function useInterests(tagName: 't' | 'g' = 't') {
/** All interests for this tag type, normalized to lowercase. */
const hashtags: string[] = (interestsQuery.data?.tags ?? [])
.filter(([name]) => name === tagName)
.map(([, value]) => value.toLowerCase())
.map(([, value]) => normalizeInterest(tagName, value))
.filter((v, i, arr) => arr.indexOf(v) === i); // deduplicate
/** Check if the user follows a specific interest. */
function hasInterest(tag: string): boolean {
return hashtags.includes(tag.toLowerCase());
return hashtags.includes(normalizeInterest(tagName, tag));
}
const invalidate = () => {
@@ -52,7 +62,7 @@ export function useInterests(tagName: 't' | 'g' = 't') {
const addInterest = useMutation({
mutationFn: async (tag: string) => {
if (!user) throw new Error('Must be logged in');
const normalized = tag.toLowerCase().replace(/^#/, '');
const normalized = normalizeInterest(tagName, tag);
if (!normalized) throw new Error('Empty tag');
// Fetch the freshest kind 10015 from relays before mutating
@@ -64,7 +74,7 @@ export function useInterests(tagName: 't' | 'g' = 't') {
const currentTags = prev?.tags ?? [];
// Don't add duplicates
if (currentTags.some(([n, v]) => n === tagName && v.toLowerCase() === normalized)) return;
if (currentTags.some(([n, v]) => n === tagName && normalizeInterest(tagName, v) === normalized)) return;
const newTags = [...currentTags, [tagName, normalized]];
await publishEvent({
@@ -81,7 +91,7 @@ export function useInterests(tagName: 't' | 'g' = 't') {
const removeInterest = useMutation({
mutationFn: async (tag: string) => {
if (!user) throw new Error('Must be logged in');
const normalized = tag.toLowerCase().replace(/^#/, '');
const normalized = normalizeInterest(tagName, tag);
// Fetch the freshest kind 10015 from relays before mutating
const prev = await fetchFreshEvent(nostr, {
@@ -92,7 +102,7 @@ export function useInterests(tagName: 't' | 'g' = 't') {
if (!prev) return;
const newTags = prev.tags.filter(
([name, value]) => !(name === tagName && value.toLowerCase() === normalized),
([name, value]) => !(name === tagName && normalizeInterest(tagName, value) === normalized),
);
await publishEvent({
kind: 10015,