diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index 4d9947e1..9b987f1d 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -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) => ( - handleSetActiveTab(`hashtag:${tag}`)} - /> - ))} - {showSavedFeedTabs && geotags.map((tag) => ( - handleSetActiveTab(`geotag:${tag}`)} - > - - - {tag} - - - ))} )} {/* Feed content — saved feed tab gets its own stream */} {user &&
} - {activeHashtag ? ( - - ) : activeGeotag ? ( - - ) : activeSavedFeed ? ( + {activeSavedFeed ? ( ) : ( @@ -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({ - 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 ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- ); - } - - if (filteredEvents.length === 0) { - return ( - - - - ); - } - - return ( - -
- {filteredEvents.map((event) => ( - - ))} -
-
- ); -} - -/** 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({ - queryKey, - queryFn: async ({ signal }) => { - const ditto = nostr.group(DITTO_RELAYS); - const filter = { kinds, limit: 40 } as Record; - filter['#g'] = [tag]; - return ditto.query([filter as Parameters[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 ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- ); - } - - if (filteredEvents.length === 0) { - return ( - - - - ); - } - - return ( - -
- {filteredEvents.map((event) => ( - - ))} -
-
- ); -} - function NoteCardSkeleton() { return (
diff --git a/src/hooks/useFollowingFeed.ts b/src/hooks/useFollowingFeed.ts index 88ddbb2f..4d6b7eb2 100644 --- a/src/hooks/useFollowingFeed.ts +++ b/src/hooks/useFollowingFeed.ts @@ -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({ + 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, }; } diff --git a/src/hooks/useInterests.ts b/src/hooks/useInterests.ts index 60e936ad..d03b1996 100644 --- a/src/hooks/useInterests.ts +++ b/src/hooks/useInterests.ts @@ -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. */