diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index e5eafdb8..94e0e1ea 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -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 }) {
{/* Flag + name */}
-
+
{info.subdivision && wiki?.thumbnail ? ( )} -
-

- {info.subdivisionName ?? info.name} -

- {info.subdivision && ( -

- {info.name}{info.subdivisionName ? '' : ` · ${info.subdivision}`} -

- )} - {wiki?.description && ( -

- {wiki.description} -

- )} +
+
+
+

+ {info.subdivisionName ?? info.name} +

+ {info.subdivision && ( +

+ {info.name}{info.subdivisionName ? '' : ` · ${info.subdivision}`} +

+ )} + {wiki?.description && ( +

+ {wiki.description} +

+ )} +
+ {user && ( + + )} +
diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index e3901df8..4d9947e1 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -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 && ( - handleSetActiveTab('follows')} /> + handleSetActiveTab('follows')} /> + {!isKindSpecificPage && !tagFilters && ( + handleSetActiveTab('network')} /> + )} {!isKindSpecificPage && showWorldFeed && ( handleSetActiveTab('world')} /> )} @@ -340,20 +358,22 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
)}
+ ) : isHomeFollowingActive && !emptyMessage ? ( + handleSetActiveTab('world')} /> ) : ( handleSetActiveTab('global') : undefined } @@ -624,3 +644,25 @@ function NoteCardSkeleton() {
); } + +function FollowingEmptyState({ onExploreWorld }: { onExploreWorld: () => void }) { + return ( +
+
+ +
+
+

No countries yet

+

+ Explore the World tab and follow countries to build your Following feed. +

+
+
+ +
+
+ ); +} diff --git a/src/hooks/useCommunityActivityFeed.ts b/src/hooks/useCommunityActivityFeed.ts index 5772b333..f85cdf4d 100644 --- a/src/hooks/useCommunityActivityFeed.ts +++ b/src/hooks/useCommunityActivityFeed.ts @@ -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, rankMapByATag: (latestPage?.rankMapByATag ?? EMPTY_RANK_MAP_BY_A_TAG) as Map>, - 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]); } diff --git a/src/hooks/useCommunityBookmarks.ts b/src/hooks/useCommunityBookmarks.ts index 74eb295c..7215ff9d 100644 --- a/src/hooks/useCommunityBookmarks.ts +++ b/src/hooks/useCommunityBookmarks.ts @@ -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', }); diff --git a/src/hooks/useCountryFollows.ts b/src/hooks/useCountryFollows.ts new file mode 100644 index 00000000..bb5928e1 --- /dev/null +++ b/src/hooks/useCountryFollows.ts @@ -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 { + await interests.addInterest.mutateAsync(createCountryIdentifier(code)); + queryClient.invalidateQueries({ queryKey: ['following-country-feed'] }); + queryClient.invalidateQueries({ queryKey: ['following-feed'] }); + } + + async function unfollowCountry(code: string): Promise { + await interests.removeInterest.mutateAsync(createCountryIdentifier(code)); + queryClient.invalidateQueries({ queryKey: ['following-country-feed'] }); + queryClient.invalidateQueries({ queryKey: ['following-feed'] }); + } + + async function toggleCountryFollow(code: string): Promise { + 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, + }; +} diff --git a/src/hooks/useFeed.ts b/src/hooks/useFeed.ts index 297dec36..a49cc543 100644 --- a/src/hooks/useFeed.ts +++ b/src/hooks/useFeed.ts @@ -38,10 +38,12 @@ interface UseFeedOptions { authors?: string[]; /** Additional tag filters to apply (e.g. `{ '#m': ['application/x-webxdc'] }`). */ tagFilters?: Record; + /** 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. diff --git a/src/hooks/useFollowActions.ts b/src/hooks/useFollowActions.ts index bde3cc65..520b0afe 100644 --- a/src/hooks/useFollowActions.ts +++ b/src/hooks/useFollowActions.ts @@ -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); } diff --git a/src/hooks/useFollowingFeed.ts b/src/hooks/useFollowingFeed.ts new file mode 100644 index 00000000..88ddbb2f --- /dev/null +++ b/src/hooks/useFollowingFeed.ts @@ -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({ + 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(); + 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, + }; +} diff --git a/src/hooks/useInterests.ts b/src/hooks/useInterests.ts index 790a562c..60e936ad 100644 --- a/src/hooks/useInterests.ts +++ b/src/hooks/useInterests.ts @@ -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,