Compare commits

...

2 Commits

Author SHA1 Message Date
lemon 99897e1c9e 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.
2026-05-13 18:31:24 -07:00
lemon 3bb5f1d32b 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.
2026-05-13 17:49:36 -07:00
9 changed files with 519 additions and 207 deletions
+35 -2
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,7 +711,9 @@ export function CountryContentHeader({ code }: { code: string }) {
{info.flag}
</span>
)}
<div className="space-y-1">
<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>
@@ -706,6 +728,17 @@ export function CountryContentHeader({ code }: { code: string }) {
</p>
)}
</div>
{user && (
<FollowToggleButton
size="sm"
isFollowing={isFollowing}
isPending={isPending}
onClick={handleToggleFollow}
className="shrink-0"
/>
)}
</div>
</div>
</div>
{/* Current weather */}
+73 -173
View File
@@ -1,7 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useInView } from 'react-intersection-observer';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { usePageRefresh } from '@/hooks/usePageRefresh';
import { ComposeBox } from '@/components/ComposeBox';
import { LandingHero } from '@/components/LandingHero';
@@ -9,33 +7,30 @@ 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 } 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 { DITTO_RELAYS } from '@/lib/appRelays';
import { useFollowingFeed } from '@/hooks/useFollowingFeed';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedTab } from '@/hooks/useFeedTab';
import { useInterests } from '@/hooks/useInterests';
import { useMuteList } from '@/hooks/useMuteList';
import { useTabFeed } from '@/hooks/useProfileFeed';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
import { useWorldFeed } from '@/hooks/useWorldFeed';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { isRepostKind, shouldHideFeedEvent } from '@/lib/feedUtils';
import { shouldHideFeedEvent } from '@/lib/feedUtils';
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 {
@@ -57,8 +52,6 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
const { user } = useCurrentUser();
const { muteItems } = useMuteList();
const { savedFeeds } = useSavedFeeds();
const { hashtags } = useInterests();
const { hashtags: geotags } = useInterests('g');
const navHidden = useNavHidden();
// Tab settings from localStorage
@@ -101,6 +94,9 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
if (!kinds) {
// Migrate legacy 'ditto' tab to '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;
}
if (rawActiveTab === 'global') return 'global';
@@ -114,11 +110,19 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
[savedFeeds, activeTab],
);
// Is the active tab a hashtag interest?
const activeHashtag = activeTab.startsWith('hashtag:') ? activeTab.slice(8) : null;
// Migrate legacy hashtag:/geotag: tabs (which used to render their own
// sub-feeds) back to the home Following feed. Followed hashtags/geotags
// now contribute to the combined Following feed instead of getting
// dedicated tabs.
useEffect(() => {
if (rawActiveTab.startsWith('hashtag:') || rawActiveTab.startsWith('geotag:')) {
handleSetActiveTab('follows');
}
}, [rawActiveTab, handleSetActiveTab]);
// 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) 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 +135,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 === '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 +182,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 +191,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 +235,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 +254,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')} />
)}
@@ -259,37 +275,12 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
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>
)}
{/* Feed content — saved feed tab gets its own stream */}
{user && <div style={{ height: ARC_OVERHANG_PX }} />}
{activeHashtag ? (
<HashtagFeedContent tag={activeHashtag} />
) : activeGeotag ? (
<GeotagFeedContent tag={activeGeotag} />
) : activeSavedFeed ? (
{activeSavedFeed ? (
<SavedFeedContent feed={activeSavedFeed} />
) : (
<PullToRefresh onRefresh={isWorldActive ? handleWorldRefresh : handleRefresh}>
@@ -340,20 +331,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
}
@@ -486,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() {
return (
<div className="px-4 py-3 border-b border-border">
@@ -624,3 +502,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);
}
+302
View File
@@ -0,0 +1,302 @@
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 { useFeedSettings } from '@/hooks/useFeedSettings';
import { useInterests } from '@/hooks/useInterests';
import { getCountryFilterValues, parseCountryIdentifier } from '@/lib/countryIdentifiers';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import { isRepostKind, type FeedItem } from '@/lib/feedUtils';
import { buildTagFilterValues } from '@/lib/tagFilterValues';
const COUNTRY_PAGE_SIZE = 40;
const HASHTAG_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/hashtag 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/hashtag
* pages).
*/
const RECENCY_WINDOW_SECONDS = 14 * 24 * 60 * 60;
interface CountryFeedPage {
events: NostrEvent[];
oldestTimestamp: number | null;
totalFetched: number;
}
interface HashtagFeedPage {
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,
});
}
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 +
* the countries you follow + the hashtags 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 { feedSettings } = useFeedSettings();
const hashtagKinds = useMemo(
() => getEnabledFeedKinds(feedSettings).filter((k) => !isRepostKind(k)),
[feedSettings],
);
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 { hashtags: followedHashtags, isLoading: hashtagFollowsLoading } = useInterests('t');
const hashtagFeed = useFollowedHashtagsFeed(followedHashtags, hashtagKinds, enabled);
const hasFollowedHashtags = followedHashtags.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 }));
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
// community/country/hashtag 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, country, and hashtag items are filtered
// to drop anything older than the floor.
const trimmedExternal = [...communityItems, ...countryItems, ...hashtagItems]
.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, hashtagFeed.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 hashtagHasNextPage = hashtagFeed.hasNextPage;
const hashtagFetchNextPage = hashtagFeed.fetchNextPage;
const fetchNextPage = useCallback(async () => {
await Promise.all([
networkHasNextPage ? networkFetchNextPage() : Promise.resolve(),
communityHasNextPage ? communityFetchNextPage() : Promise.resolve(),
countryHasNextPage ? countryFetchNextPage() : Promise.resolve(),
hashtagHasNextPage ? hashtagFetchNextPage() : Promise.resolve(),
]);
}, [
networkHasNextPage,
networkFetchNextPage,
communityHasNextPage,
communityFetchNextPage,
countryHasNextPage,
countryFetchNextPage,
hashtagHasNextPage,
hashtagFetchNextPage,
]);
return {
data,
isPending: enabled && (
networkFeed.isPending
|| communityFeed.isLoading
|| countryFollowsLoading
|| (hasFollowedCountries && countryFeed.isPending)
|| hashtagFollowsLoading
|| (hasFollowedHashtags && hashtagFeed.isPending)
),
isLoading: enabled && (
networkFeed.isLoading
|| communityFeed.isLoading
|| countryFollowsLoading
|| (hasFollowedCountries && countryFeed.isLoading)
|| hashtagFollowsLoading
|| (hasFollowedHashtags && hashtagFeed.isLoading)
),
fetchNextPage,
hasNextPage: !!networkFeed.hasNextPage
|| !!communityFeed.hasNextPage
|| !!countryFeed.hasNextPage
|| !!hashtagFeed.hasNextPage,
isFetchingNextPage: networkFeed.isFetchingNextPage
|| communityFeed.isFetchingNextPage
|| countryFeed.isFetchingNextPage
|| hashtagFeed.isFetchingNextPage,
};
}
+21 -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,23 +46,27 @@ 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 = () => {
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. */
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 +78,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 +95,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 +106,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,