diff --git a/src/components/BottomNavigationBar/NotificationsButton.tsx b/src/components/BottomNavigationBar/NotificationsButton.tsx index 2b349c63..f2023c0c 100644 --- a/src/components/BottomNavigationBar/NotificationsButton.tsx +++ b/src/components/BottomNavigationBar/NotificationsButton.tsx @@ -5,13 +5,15 @@ import BottomNavigationBarItem from './BottomNavigationBarItem' export default function NotificationsButton() { const { navigate, current, currentPageProps, display } = usePrimaryPage() - const { checkLogin } = useNostr() + const { pubkey } = useNostr() const spell = (currentPageProps as { spell?: string } | undefined)?.spell + if (!pubkey) return null + return ( checkLogin(() => navigate('spells', { spell: 'notifications' }))} + onClick={() => navigate('spells', { spell: 'notifications' })} > diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 8159737f..3796bb18 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -20,7 +20,8 @@ export default function MainNoteCard({ originalNoteId, pinned = false, hideParentNotePreview = false, - zapPollVoteHighlightOption + zapPollVoteHighlightOption, + bottomNoteLabel }: { event: Event className?: string @@ -32,6 +33,7 @@ export default function MainNoteCard({ /** Hide the parent note preview (e.g. when showing quotes of current note). */ hideParentNotePreview?: boolean zapPollVoteHighlightOption?: number + bottomNoteLabel?: string }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigationOptional() @@ -101,6 +103,9 @@ export default function MainNoteCard({ displayTopZapsAndLikes={isZapFeedCard} /> ) : null} + {!embedded && bottomNoteLabel ? ( +
{bottomNoteLabel}
+ ) : null} {!embedded && } diff --git a/src/components/NoteCard/RepostNoteCard.tsx b/src/components/NoteCard/RepostNoteCard.tsx index 8db12deb..1a392454 100644 --- a/src/components/NoteCard/RepostNoteCard.tsx +++ b/src/components/NoteCard/RepostNoteCard.tsx @@ -14,12 +14,14 @@ export default function RepostNoteCard({ event, className, filterMutedNotes = true, - pinned = false + pinned = false, + bottomNoteLabel }: { event: Event className?: string filterMutedNotes?: boolean pinned?: boolean + bottomNoteLabel?: string }) { const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() @@ -90,5 +92,13 @@ export default function RepostNoteCard({ if (!targetEvent || shouldHide) return null - return + return ( + + ) } diff --git a/src/components/NoteCard/index.tsx b/src/components/NoteCard/index.tsx index 103f0d40..483fa2ec 100644 --- a/src/components/NoteCard/index.tsx +++ b/src/components/NoteCard/index.tsx @@ -14,7 +14,8 @@ const NoteCard = memo(function NoteCard({ filterMutedNotes = true, pinned = false, hideParentNotePreview = false, - zapPollVoteHighlightOption + zapPollVoteHighlightOption, + bottomNoteLabel }: { event: Event className?: string @@ -23,6 +24,8 @@ const NoteCard = memo(function NoteCard({ /** When true, hide the parent/root note preview (e.g. when showing quotes of the current note). */ hideParentNotePreview?: boolean zapPollVoteHighlightOption?: number + /** Optional label rendered at the bottom of the card (e.g. why this event is in a composed feed). */ + bottomNoteLabel?: string }) { const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() @@ -44,6 +47,7 @@ const NoteCard = memo(function NoteCard({ className={className} filterMutedNotes={filterMutedNotes} pinned={pinned} + bottomNoteLabel={bottomNoteLabel} /> ) } @@ -54,6 +58,7 @@ const NoteCard = memo(function NoteCard({ pinned={pinned} hideParentNotePreview={hideParentNotePreview} zapPollVoteHighlightOption={zapPollVoteHighlightOption} + bottomNoteLabel={bottomNoteLabel} /> ) }, (prevProps, nextProps) => { @@ -65,7 +70,8 @@ const NoteCard = memo(function NoteCard({ prevProps.filterMutedNotes === nextProps.filterMutedNotes && prevProps.pinned === nextProps.pinned && prevProps.hideParentNotePreview === nextProps.hideParentNotePreview && - prevProps.zapPollVoteHighlightOption === nextProps.zapPollVoteHighlightOption + prevProps.zapPollVoteHighlightOption === nextProps.zapPollVoteHighlightOption && + prevProps.bottomNoteLabel === nextProps.bottomNoteLabel ) }) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 2220ef33..e7b57b7f 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -205,6 +205,44 @@ function buildNoteListMappedFilterForFullSearch( return null } +function eventTagValues(event: Event, tagName: string): string[] { + return event.tags + .filter((tag) => tag[0] === tagName && typeof tag[1] === 'string') + .map((tag) => tag[1] as string) +} + +function eventMatchesSubRequestFilter(event: Event, filter: Filter): boolean { + const ids = Array.isArray(filter.ids) ? filter.ids : undefined + if (ids && ids.length > 0 && !ids.includes(event.id)) return false + + const authors = Array.isArray(filter.authors) ? filter.authors : undefined + if (authors && authors.length > 0 && !authors.includes(event.pubkey)) return false + + const kindsFilter = Array.isArray(filter.kinds) ? filter.kinds : undefined + if (kindsFilter && kindsFilter.length > 0 && !kindsFilter.includes(event.kind)) return false + + const tagFilterEntries = Object.entries(filter).filter(([key]) => key.startsWith('#')) + for (const [key, values] of tagFilterEntries) { + if (!Array.isArray(values) || values.length === 0) continue + const tagName = key.slice(1) + const eventValues = eventTagValues(event, tagName) + if (eventValues.length === 0) return false + const matched = + tagName.toLowerCase() === 't' + ? (() => { + const allowed = new Set(values.map((v) => String(v).toLowerCase())) + return eventValues.some((v) => allowed.has(v.toLowerCase())) + })() + : (() => { + const allowed = new Set(values.map((v) => String(v))) + return eventValues.some((v) => allowed.has(v)) + })() + if (!matched) return false + } + + return true +} + const NoteList = forwardRef( ( { @@ -2422,6 +2460,23 @@ const NoteList = forwardRef( const listSourceEvents = timelineEventsForFilter const feedFullSearchActive = feedFullSearchEvents !== null + const eventReasonLabelMap = useMemo(() => { + const reqs = subRequestsRef.current.filter((req) => req.reasonLabel && req.reasonLabel.trim().length > 0) + if (!reqs.length || !clientFilteredEvents.length) return new Map() + const map = new Map() + for (const event of clientFilteredEvents) { + const labels: string[] = [] + for (const req of reqs) { + if (eventMatchesSubRequestFilter(event, req.filter as Filter)) { + labels.push(req.reasonLabel as string) + } + } + if (labels.length) { + map.set(event.id, Array.from(new Set(labels)).join(' ยท ')) + } + } + return map + }, [clientFilteredEvents, subRequestsKey]) const list = (
@@ -2441,6 +2496,7 @@ const NoteList = forwardRef( className="w-full" event={event} filterMutedNotes={filterMutedNotes} + bottomNoteLabel={eventReasonLabelMap.get(event.id)} /> ))} {listSourceEvents.length === 0 && diff --git a/src/components/Sidebar/FavoritesButton.tsx b/src/components/Sidebar/FavoritesButton.tsx new file mode 100644 index 00000000..7fb64754 --- /dev/null +++ b/src/components/Sidebar/FavoritesButton.tsx @@ -0,0 +1,29 @@ +import { usePrimaryPage } from '@/contexts/primary-page-context' +import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' +import { useNostr } from '@/providers/NostrProvider' +import { Star } from 'lucide-react' +import SidebarItem from './SidebarItem' + +export default function FavoritesButton() { + const { navigate, current, currentPageProps, display } = usePrimaryPage() + const { primaryViewType } = usePrimaryNoteView() + const { pubkey } = useNostr() + const spell = (currentPageProps as { spell?: string } | undefined)?.spell + + if (!pubkey) return null + + return ( + navigate('spells', { spell: 'favorites' })} + active={ + display && + current === 'spells' && + primaryViewType === null && + spell === 'favorites' + } + > + + + ) +} diff --git a/src/components/Sidebar/FollowsLatestButton.tsx b/src/components/Sidebar/FollowsLatestButton.tsx index 94700581..8bbb526a 100644 --- a/src/components/Sidebar/FollowsLatestButton.tsx +++ b/src/components/Sidebar/FollowsLatestButton.tsx @@ -1,5 +1,6 @@ import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' +import { useNostr } from '@/providers/NostrProvider' import { UsersRound } from 'lucide-react' import { useTranslation } from 'react-i18next' import SidebarItem from './SidebarItem' @@ -8,6 +9,9 @@ export default function FollowsLatestButton() { const { t } = useTranslation() const { navigate, current, display } = usePrimaryPage() const { primaryViewType } = usePrimaryNoteView() + const { pubkey } = useNostr() + + if (!pubkey) return null return ( checkLogin(() => navigate('spells', { spell: 'notifications' }))} + onClick={() => navigate('spells', { spell: 'notifications' })} active={ display && current === 'spells' && diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 8179ffb1..8f390b11 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -10,6 +10,7 @@ import PostButton from './PostButton' import RssButton from './RssButton' import SearchButton from './SearchButton' import FollowsLatestButton from './FollowsLatestButton' +import FavoritesButton from './FavoritesButton' import SpellsButton from './SpellsButton' import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysActiveStrip' import PaneModeToggle from './PaneModeToggle' @@ -41,6 +42,7 @@ export default function PrimaryPageSidebar() { + diff --git a/src/constants.ts b/src/constants.ts index 3cf6e3d7..922a1330 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -245,7 +245,6 @@ export const READ_ONLY_RELAY_URLS = [ 'wss://relay.noswhere.com', 'wss://search.nos.today', 'wss://trending.nostr.wine', - 'wss://sendit.nosflare.com', 'wss://relay.nip46.com' ] @@ -344,6 +343,10 @@ export const PROFILE_RELAY_URLS = [ 'wss://thecitadel.nostr1.com' ] + export const FOLLOWS_HISTORY_RELAY_URLS = [ + 'wss://hist.nostr.land' + ] + // Combined relay URLs for profile fetching - includes both FAST_READ_RELAY_URLS and SEARCHABLE_RELAY_URLS export const PROFILE_FETCH_RELAY_URLS = [...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS, ...PROFILE_RELAY_URLS] @@ -606,6 +609,7 @@ export const FAUX_SPELL_ORDER = [ 'notifications', 'discussions', 'following', + 'favorites', 'followPacks', 'media', 'interests', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a0d877ac..9770313c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -797,12 +797,19 @@ export default { 'No articles or publications match your search', 'articles and publications': 'articles and publications', Interests: 'Interests', + Favorites: 'Favorites', Calendar: 'Calendar', 'No subscribed interests yet.': 'No subscribed interests yet. Add topics in settings to see them here.', 'No bookmarked notes with id tags yet.': 'No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.', 'No follows or relays to load yet.': 'No follows or relays to load yet.', + 'No favorites yet.': 'No favorites yet. Add follows, follow sets, interests, or bookmarks.', + 'Added from interests': 'Added from interests', + 'Added from bookmarks list': 'Added from bookmarks list', + 'Added from your web bookmarks': 'Added from your web bookmarks', + 'Added from follows and contact lists': 'Added from follows and contact lists', + 'Added from follows web bookmarks': 'Added from follows web bookmarks', 'Nothing to load for this feed.': 'Nothing to load for this feed.', 'No posts loaded for this feed. Try refreshing.': 'No posts loaded for this feed. Try refreshing.', @@ -1505,6 +1512,7 @@ export default { 'Pin note': 'Pin note', 'Plain text description of the query': 'Plain text description of the query', 'Please login to view bookmarks': 'Please login to view bookmarks', + 'Please login to view favorites': 'Please login to view favorites', 'Please select a group': 'Please select a group', 'Please select at least one relay': 'Please select at least one relay', 'Please set a start date': 'Please set a start date', diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 54163fd9..d63524a8 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -10,7 +10,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' import type { TNoteListRef } from '@/components/NoteList' import { NoteCardLoadingSkeleton } from '@/components/NoteCard' import { TPageRef } from '@/types' -import { Compass, Info, UsersRound } from 'lucide-react' +import { Compass, Info, Star, UsersRound } from 'lucide-react' import React, { Dispatch, forwardRef, @@ -219,10 +219,14 @@ function NoteListPageTitlebar({ }) { const { t } = useTranslation() const { isSmallScreen } = useScreenSize() - const { navigate, current, display } = usePrimaryPage() + const { navigate, current, currentPageProps, display } = usePrimaryPage() const { primaryViewType, setPrimaryNoteView } = usePrimaryNoteView() + const { pubkey } = useNostr() + const spell = (currentPageProps as { spell?: string } | undefined)?.spell const exploreActive = display && current === 'explore' && primaryViewType === null const followsLatestActive = display && current === 'follows-latest' && primaryViewType === null + const favoritesActive = + display && current === 'spells' && spell === 'favorites' && primaryViewType === null return (
@@ -246,22 +250,42 @@ function NoteListPageTitlebar({ > - + {pubkey ? ( + <> + + + + ) : null} )}
diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 29ac38d2..9787510e 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -48,7 +48,7 @@ export function applyFauxSpellCapsToSubRequests(requests: TFeedSubRequest[]): TF if (Array.isArray(f.ids) && f.ids.length > FAUX_SPELL_EVENT_LIMIT) { f.ids = f.ids.slice(0, FAUX_SPELL_EVENT_LIMIT) } - return { urls, filter: f } + return { ...r, urls, filter: f } }) } @@ -68,9 +68,9 @@ export const NOTIFICATION_SPELL_LOADING_SAFETY_MS = 90_000 const INTERESTS_MAX_TOPICS = 80 /** - * Max distinct `t` tag values in one filter after singular/plural expansion. + * Max distinct `t` tag values in one filter after case + singular/plural expansion. */ -const INTERESTS_MAX_TOPIC_TAG_VALUES = INTERESTS_MAX_TOPICS * 2 +const INTERESTS_MAX_TOPIC_TAG_VALUES = INTERESTS_MAX_TOPICS * 4 /** * Put {@link READ_ONLY_RELAY_URLS} (e.g. aggr) **first**, then curated relays. Faux spells cap URL count @@ -173,6 +173,10 @@ function pluralizeTopic(topic: string): string { return `${topic}s` } +function canonicalizeRawTopicTagValue(topic: string): string { + return topic.trim().replace(/^#+/u, '').replace(/\s+/g, '-') +} + /** * One subrequest for all interests: NIP-01 treats multiple `#t` values as OR (any topic matches). * Expand every topic to singular+plural so feeds match either spelling on relays. @@ -183,19 +187,21 @@ export function buildInterestsSubRequests( kindsList: number[] = DEFAULT_FEED_SHOW_KINDS ): TFeedSubRequest[] { if (!relayUrls.length || !rawTopics.length || !kindsList.length) return [] - const baseTopics = Array.from( + const normalizedBaseTopics = Array.from( new Set(rawTopics.map((t) => normalizeTopic(t)).filter((t) => t.length > 0)) ).slice(0, INTERESTS_MAX_TOPICS) - if (!baseTopics.length) return [] - const topics = Array.from( - new Set( - baseTopics.flatMap((topic) => { - const singular = normalizeTopic(topic) - const plural = pluralizeTopic(singular) - return [singular, plural] - }) - ) - ).slice(0, INTERESTS_MAX_TOPIC_TAG_VALUES) + const rawCasedTopics = Array.from( + new Set(rawTopics.map((t) => canonicalizeRawTopicTagValue(t)).filter((t) => t.length > 0)) + ).slice(0, INTERESTS_MAX_TOPICS) + if (!normalizedBaseTopics.length && !rawCasedTopics.length) return [] + const topics = Array.from(new Set([ + ...normalizedBaseTopics.flatMap((topic) => { + const singular = normalizeTopic(topic) + const plural = pluralizeTopic(singular) + return [singular, plural] + }), + ...rawCasedTopics.flatMap((topic) => [topic, pluralizeTopic(topic)]) + ])).slice(0, INTERESTS_MAX_TOPIC_TAG_VALUES) if (!topics.length) return [] return [ { diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 29721a3d..fa21311c 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -101,7 +101,6 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe import { useTranslation } from 'react-i18next' import CreateSpellDialog from './CreateSpellDialog' import { - appendCuratedReadOnlyRelays, applyFauxSpellCapsToSubRequests, buildBookmarksSubRequests, buildWebBookmarksSpellSubRequests, @@ -280,6 +279,8 @@ function fauxSpellLabelKey(name: FauxSpellName): string { return 'Discussions' case 'following': return 'Following' + case 'favorites': + return 'Favorites' case 'followPacks': return 'Follow Packs' case 'media': @@ -299,6 +300,7 @@ const FAUX_SPELL_ICON: Record = { notifications: Bell, discussions: MessageSquare, following: Users, + favorites: Star, followPacks: Gift, media: ImageIcon, interests: Hash, @@ -398,6 +400,8 @@ const SpellsPage = forwardRef(function SpellsPage( const [followingSubRequests, setFollowingSubRequests] = useState([]) const [followingFeedLoading, setFollowingFeedLoading] = useState(false) + const [favoritesSubRequests, setFavoritesSubRequests] = useState([]) + const [favoritesFeedLoading, setFavoritesFeedLoading] = useState(false) const loadSpells = useCallback(async () => { const [events, ids] = await Promise.all([ @@ -482,13 +486,12 @@ const SpellsPage = forwardRef(function SpellsPage( relayList?.read ?? [], { userWriteRelays: relayList?.write ?? [] } ) - const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) - if (!urls.length) { + if (!feedUrls.length) { if (!cancelled) setFollowSetListEvents([]) return } const events = await queryService.fetchEvents( - urls, + feedUrls, { authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 }, { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } ) @@ -774,11 +777,7 @@ const SpellsPage = forwardRef(function SpellsPage( relayList?.read ?? [], { userWriteRelays: relayList?.write ?? [] } ) - const withReadOnly = merged.map((r) => ({ - ...r, - urls: appendCuratedReadOnlyRelays(r.urls, blockedRelays) - })) - if (!cancelled) setFollowingSubRequests(withReadOnly) + if (!cancelled) setFollowingSubRequests(merged) } catch { if (!cancelled) setFollowingSubRequests([]) } finally { @@ -798,6 +797,114 @@ const SpellsPage = forwardRef(function SpellsPage( followSetListStableKey ]) + const favoritesShowKinds = useMemo(() => { + const out = [...kindFilterShowKinds] + if (!out.includes(nostrKinds.Repost)) out.push(nostrKinds.Repost) + if (!out.includes(ExtendedKind.GENERIC_REPOST)) out.push(ExtendedKind.GENERIC_REPOST) + if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK) + return out.sort((a, b) => a - b) + }, [kindFilterShowKinds]) + + const favoritesShowKindsKey = useMemo(() => JSON.stringify(favoritesShowKinds), [favoritesShowKinds]) + + useEffect(() => { + if (selectedFauxSpell !== 'favorites' || !pubkey) { + setFavoritesSubRequests([]) + setFavoritesFeedLoading(false) + return + } + + let cancelled = false + setFavoritesFeedLoading(true) + void (async () => { + try { + const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + relayList?.read ?? [], + { + userWriteRelays: relayList?.write ?? [], + applySocialKindBlockedFilter: false + } + ) + const topics = interestListEvent?.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!) ?? [] + const interestReqs = buildInterestsSubRequests(feedUrls, topics, favoritesShowKinds).map((r) => ({ + ...r, + reasonLabel: t('Added from interests') + })) + const idReqs = buildBookmarksSubRequests(bookmarkListEvent, feedUrls).map((r) => ({ + ...r, + reasonLabel: t('Added from bookmarks list') + })) + const ownWebReqs = buildWebBookmarksSpellSubRequests(pubkey, feedUrls).map((r) => ({ + ...r, + reasonLabel: t('Added from your web bookmarks') + })) + + const authorSet = new Set([pubkey, ...contacts]) + for (const ev of followSetListEvents) { + if (ev.pubkey !== pubkey) continue + for (const author of pubkeysFromFollowSetEvent(ev)) authorSet.add(author) + } + + const authorPubkeys = [...authorSet] + const followAndContactReqs = authorPubkeys.length + ? await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) + : [] + const followAndContactAugmented = augmentSubRequestsWithFavoritesFastReadAndInbox( + followAndContactReqs, + favoriteRelays, + blockedRelays, + relayList?.read ?? [], + { userWriteRelays: relayList?.write ?? [] } + ).map((r) => ({ ...r, reasonLabel: t('Added from follows and contact lists') })) + + const followsWebBookmarkReqs: TFeedSubRequest[] = authorPubkeys.length + ? [ + { + urls: feedUrls, + filter: { + authors: authorPubkeys, + kinds: [ExtendedKind.WEB_BOOKMARK], + limit: FAUX_SPELL_EVENT_LIMIT + }, + reasonLabel: t('Added from follows web bookmarks') + } + ] + : [] + + if (!cancelled) { + setFavoritesSubRequests([ + ...interestReqs, + ...idReqs, + ...ownWebReqs, + ...followsWebBookmarkReqs, + ...followAndContactAugmented + ]) + } + } catch { + if (!cancelled) setFavoritesSubRequests([]) + } finally { + if (!cancelled) setFavoritesFeedLoading(false) + } + })() + + return () => { + cancelled = true + } + }, [ + selectedFauxSpell, + pubkey, + contactsSyncKey, + followSetListStableKey, + sortedFavoriteRelaysKey, + sortedBlockedRelaysKey, + relayMailboxStableKey, + interestListEvent?.id, + bookmarkListEvent?.id, + favoritesShowKindsKey + ]) + const interestTagsStableKey = interestListEvent ? JSON.stringify( [...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) @@ -822,7 +929,7 @@ const SpellsPage = forwardRef(function SpellsPage( ].join('\0') const syncFauxSubRequests = useMemo(() => { - if (!selectedFauxSpell || isFollowFeedFauxSpellId(selectedFauxSpell)) return [] + if (!selectedFauxSpell || isFollowFeedFauxSpellId(selectedFauxSpell) || selectedFauxSpell === 'favorites') return [] /** Widen relay pool: these faux spells do not target social kinds (1 / 11 / 1111); skipping strip keeps fast-read mirrors in the stack. */ const fauxSpellSkipSocialKindBlocked = selectedFauxSpell === 'calendar' || @@ -845,40 +952,33 @@ const SpellsPage = forwardRef(function SpellsPage( return buildNotificationsSpellSubRequests(feedUrls, notificationsFeedPubkey) } if (selectedFauxSpell === 'discussions') { - // Read-only prepended in appendCuratedReadOnlyRelays so FAUX_SPELL_MAX_RELAYS still includes aggr. - const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) - if (!urls.length) return [] - return [{ urls, filter: buildDiscussionFilter() }] + if (!feedUrls.length) return [] + return [{ urls: feedUrls, filter: buildDiscussionFilter() }] } if (selectedFauxSpell === 'media') { - const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) - if (!urls.length) return [] - return [{ urls, filter: buildMediaSpellFilter() }] + if (!feedUrls.length) return [] + return [{ urls: feedUrls, filter: buildMediaSpellFilter() }] } if (selectedFauxSpell === 'calendar') { - const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) - if (!urls.length) return [] - return [{ urls, filter: buildCalendarSpellFilter() }] + if (!feedUrls.length) return [] + return [{ urls: feedUrls, filter: buildCalendarSpellFilter() }] } if (selectedFauxSpell === 'interests') { if (!pubkey || !interestListEvent) return [] const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!) - const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) - return buildInterestsSubRequests(urls, topics, DEFAULT_FEED_SHOW_KINDS) + return buildInterestsSubRequests(feedUrls, topics, DEFAULT_FEED_SHOW_KINDS) } if (selectedFauxSpell === 'bookmarks') { if (!pubkey) return [] - const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) - const idReqs = buildBookmarksSubRequests(bookmarkListEvent, urls) - const webReqs = buildWebBookmarksSpellSubRequests(pubkey, urls) + const idReqs = buildBookmarksSubRequests(bookmarkListEvent, feedUrls) + const webReqs = buildWebBookmarksSpellSubRequests(pubkey, feedUrls) return [...idReqs, ...webReqs] } if (selectedFauxSpell === 'followPacks') { - const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) - if (!urls.length) return [] + if (!feedUrls.length) return [] return [ { - urls, + urls: feedUrls, filter: { kinds: [ExtendedKind.FOLLOW_PACK], limit: FAUX_SPELL_EVENT_LIMIT } } ] @@ -887,11 +987,14 @@ const SpellsPage = forwardRef(function SpellsPage( }, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey]) const fauxSubRequests = useMemo(() => { - const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '') - ? followingSubRequests - : syncFauxSubRequests + const base = + selectedFauxSpell === 'favorites' + ? favoritesSubRequests + : isFollowFeedFauxSpellId(selectedFauxSpell ?? '') + ? followingSubRequests + : syncFauxSubRequests return applyFauxSpellCapsToSubRequests(base) - }, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests]) + }, [selectedFauxSpell, favoritesSubRequests, followingSubRequests, syncFauxSubRequests]) const spellSubRequests = useMemo(() => { if (!selectedSpell) return [] @@ -1097,6 +1200,9 @@ const SpellsPage = forwardRef(function SpellsPage( if (selectedFauxSpell === 'interests') { return [...DEFAULT_FEED_SHOW_KINDS] } + if (selectedFauxSpell === 'favorites') { + return favoritesShowKinds + } if (selectedFauxSpell === 'bookmarks') { const out = [...DEFAULT_FEED_SHOW_KINDS] if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK) @@ -1108,7 +1214,7 @@ const SpellsPage = forwardRef(function SpellsPage( .map((tag) => parseInt(tag[1], 10)) .filter((n) => !Number.isNaN(n)) return kinds.length ? kinds : [1] - }, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey]) + }, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey, favoritesShowKindsKey]) const spellMenuLabel = useCallback( (spell: Event) => @@ -1225,17 +1331,19 @@ const SpellsPage = forwardRef(function SpellsPage( if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.') if (selectedFauxSpell === 'bookmarks') return t('No NIP-51 bookmarks or web bookmarks yet.') + if (selectedFauxSpell === 'favorites') return t('No favorites yet.') if (selectedFauxSpell === 'following') return t('No follows or relays to load yet.') if (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty') return t('Nothing to load for this feed.') }, [selectedFauxSpell, fauxSubRequests.length, t]) - const showFollowFeedLoading = !!( + const showAsyncFauxFeedLoading = !!( pubkey && selectedFauxSpell && - isFollowFeedFauxSpellId(selectedFauxSpell) && - (followingFeedLoading || - (isFollowSetSpellId(selectedFauxSpell) && followSetCatalogLoading)) + (selectedFauxSpell === 'favorites' + ? favoritesFeedLoading + : isFollowFeedFauxSpellId(selectedFauxSpell) && + (followingFeedLoading || (isFollowSetSpellId(selectedFauxSpell) && followSetCatalogLoading))) ) const spellStarAddTitle = t('Spell star add title') @@ -1269,6 +1377,7 @@ const SpellsPage = forwardRef(function SpellsPage( if ( (name === 'notifications' || name === 'following' || + name === 'favorites' || name === 'bookmarks' || name === 'interests') && !pubkey @@ -1675,7 +1784,11 @@ const SpellsPage = forwardRef(function SpellsPage(
{t('Please login to view bookmarks')}
- ) : showFollowFeedLoading ? ( + ) : selectedFauxSpell === 'favorites' && !pubkey ? ( +
+ {t('Please login to view favorites')} +
+ ) : showAsyncFauxFeedLoading ? (
{t('loading...')}
) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
{fauxFeedEmptyMessage}
@@ -1706,7 +1819,9 @@ const SpellsPage = forwardRef(function SpellsPage( : undefined } clientSideKindFilter={ - selectedFauxSpell === 'notifications' || selectedFauxSpell === 'bookmarks' + selectedFauxSpell === 'notifications' || + selectedFauxSpell === 'bookmarks' || + selectedFauxSpell === 'favorites' } useFilterAsIs={fauxNoteListUseFilterAsIs} oneShotFetch={false} diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 6572a31e..b538abf4 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -6,6 +6,8 @@ export type TSubRequestFilter = Omit & { limit: numbe export type TFeedSubRequest = { urls: string[] filter: Omit + /** Optional UI hint used by feed UIs (e.g. Favorites) to explain why an event was included. */ + reasonLabel?: string } export type TProfile = {