Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99897e1c9e | |||
| 3bb5f1d32b |
@@ -6,6 +6,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { ExternalReactionButton } from '@/components/ExternalReactionButton';
|
||||
import { FollowToggleButton } from '@/components/FollowButton';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { WikipediaIcon } from '@/components/icons/WikipediaIcon';
|
||||
@@ -18,6 +19,8 @@ import { useBlueskyPost } from '@/hooks/useBlueskyPost';
|
||||
import { useBookInfo } from '@/hooks/useBookInfo';
|
||||
import { useAddrEvent } from '@/hooks/useEvent';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCountryFollows } from '@/hooks/useCountryFollows';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useWeather } from '@/hooks/useWeather';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
@@ -665,6 +668,23 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
const info = getCountryInfo(code);
|
||||
const wikiTitle = getWikipediaTitle(code);
|
||||
const { data: wiki, isLoading: wikiLoading } = useWikipediaSummary(wikiTitle);
|
||||
const { user } = useCurrentUser();
|
||||
const { isFollowingCountry, toggleCountryFollow, isPending } = useCountryFollows();
|
||||
const { toast } = useToast();
|
||||
const isFollowing = info ? isFollowingCountry(code) : false;
|
||||
|
||||
const handleToggleFollow = useCallback(async (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!user || !info || isPending) return;
|
||||
|
||||
try {
|
||||
await toggleCountryFollow(code);
|
||||
toast({ title: isFollowing ? 'Country unfollowed' : 'Country followed' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to update country follow', variant: 'destructive' });
|
||||
}
|
||||
}, [user, info, isPending, toggleCountryFollow, code, toast, isFollowing]);
|
||||
|
||||
if (!info) {
|
||||
return (
|
||||
@@ -679,7 +699,7 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
{/* Flag + name */}
|
||||
<div className="p-6 sm:p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{info.subdivision && wiki?.thumbnail ? (
|
||||
<img
|
||||
src={wiki.thumbnail.source}
|
||||
@@ -691,20 +711,33 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
{info.flag}
|
||||
</span>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold leading-snug">
|
||||
{info.subdivisionName ?? info.name}
|
||||
</h2>
|
||||
{info.subdivision && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{info.name}{info.subdivisionName ? '' : ` · ${info.subdivision}`}
|
||||
</p>
|
||||
)}
|
||||
{wiki?.description && (
|
||||
<p className="text-sm text-muted-foreground capitalize">
|
||||
{wiki.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold leading-snug">
|
||||
{info.subdivisionName ?? info.name}
|
||||
</h2>
|
||||
{info.subdivision && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{info.name}{info.subdivisionName ? '' : ` · ${info.subdivision}`}
|
||||
</p>
|
||||
)}
|
||||
{wiki?.description && (
|
||||
<p className="text-sm text-muted-foreground capitalize">
|
||||
{wiki.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{user && (
|
||||
<FollowToggleButton
|
||||
size="sm"
|
||||
isFollowing={isFollowing}
|
||||
isPending={isPending}
|
||||
onClick={handleToggleFollow}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+74
-174
@@ -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 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 +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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user