Fold followed hashtags into the Following feed and drop tag tabs

- useFollowingFeed now also queries posts for the user's followed
  hashtag interests (NIP-51 kind 10015 t tags) and merges them into
  the combined Following feed, subject to the same recency floor.
- Drop the per-hashtag and per-geotag tabs from the home feed
  subheader. Legacy 'hashtag:'/'geotag:' session-storage values fall
  back to the Following tab.
- Invalidate the new following-feed query keys when interests change
  so the Following feed refreshes immediately on follow/unfollow.
- Remove the now-dead HashtagFeedContent and GeotagFeedContent
  components and their unused imports.
This commit is contained in:
lemon
2026-05-13 18:31:24 -07:00
parent 3bb5f1d32b
commit 99897e1c9e
3 changed files with 127 additions and 172 deletions
+16 -158
View File
@@ -1,7 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { 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,23 +7,19 @@ import { NoteCard } from '@/components/NoteCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { FeedEmptyState } from '@/components/FeedEmptyState';
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 { 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';
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';
@@ -34,7 +28,6 @@ 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' | 'network' | 'global' | 'communities' | 'world';
@@ -59,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
@@ -103,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';
@@ -116,14 +110,18 @@ 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;
// Is the active tab a geotag interest?
const activeGeotag = activeTab.startsWith('geotag:') ? activeTab.slice(7) : 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]);
// 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;
// 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)}
/>
))}
{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}>
@@ -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() {
return (
<div className="px-4 py-3 border-b border-border">
+107 -14
View File
@@ -6,21 +6,27 @@ import { useCommunityActivityFeed } from '@/hooks/useCommunityActivityFeed';
import { useCountryFollows } from '@/hooks/useCountryFollows';
import { 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 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 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 events take over the top
* of the feed before the network feed has a chance to populate.
* 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 pages).
* pagination on dedicated tabs (Network, Communities, country/hashtag
* pages).
*/
const RECENCY_WINDOW_SECONDS = 14 * 24 * 60 * 60;
@@ -30,6 +36,12 @@ interface CountryFeedPage {
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;
@@ -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 +
* the countries you follow. Items are sorted strictly by recency
* (`sortTimestamp` desc) with no per-source prioritisation.
* 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
@@ -105,11 +173,20 @@ function useFollowedCountriesFeed(countryCodes: string[], enabled: boolean) {
* 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)
@@ -124,9 +201,14 @@ export function useFollowingFeed(enabled = true) {
.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 or country with little recent activity) from out-ranking a
// newer item that simply hasn't loaded into the network feed yet.
// 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))
@@ -137,9 +219,9 @@ export function useFollowingFeed(enabled = true) {
: 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]
// 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];
@@ -160,7 +242,7 @@ export function useFollowingFeed(enabled = true) {
);
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 networkFetchNextPage = networkFeed.fetchNextPage;
@@ -168,12 +250,15 @@ export function useFollowingFeed(enabled = true) {
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,
@@ -182,6 +267,8 @@ export function useFollowingFeed(enabled = true) {
communityFetchNextPage,
countryHasNextPage,
countryFetchNextPage,
hashtagHasNextPage,
hashtagFetchNextPage,
]);
return {
@@ -191,19 +278,25 @@ export function useFollowingFeed(enabled = true) {
|| 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,
|| !!countryFeed.hasNextPage
|| !!hashtagFeed.hasNextPage,
isFetchingNextPage: networkFeed.isFetchingNextPage
|| communityFeed.isFetchingNextPage
|| countryFeed.isFetchingNextPage,
|| countryFeed.isFetchingNextPage
|| hashtagFeed.isFetchingNextPage,
};
}
+4
View File
@@ -56,6 +56,10 @@ export function useInterests(tagName: InterestTagName = 't') {
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. */