Browse Source

added favorites feed

imwald
Silberengel 1 month ago
parent
commit
f498253e46
  1. 6
      src/components/BottomNavigationBar/NotificationsButton.tsx
  2. 7
      src/components/NoteCard/MainNoteCard.tsx
  3. 14
      src/components/NoteCard/RepostNoteCard.tsx
  4. 10
      src/components/NoteCard/index.tsx
  5. 56
      src/components/NoteList/index.tsx
  6. 29
      src/components/Sidebar/FavoritesButton.tsx
  7. 4
      src/components/Sidebar/FollowsLatestButton.tsx
  8. 6
      src/components/Sidebar/NotificationButton.tsx
  9. 2
      src/components/Sidebar/index.tsx
  10. 6
      src/constants.ts
  11. 8
      src/i18n/locales/en.ts
  12. 28
      src/pages/primary/NoteListPage/index.tsx
  13. 28
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  14. 189
      src/pages/primary/SpellsPage/index.tsx
  15. 2
      src/types/index.d.ts

6
src/components/BottomNavigationBar/NotificationsButton.tsx

@ -5,13 +5,15 @@ import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function NotificationsButton() { export default function NotificationsButton() {
const { navigate, current, currentPageProps, display } = usePrimaryPage() const { navigate, current, currentPageProps, display } = usePrimaryPage()
const { checkLogin } = useNostr() const { pubkey } = useNostr()
const spell = (currentPageProps as { spell?: string } | undefined)?.spell const spell = (currentPageProps as { spell?: string } | undefined)?.spell
if (!pubkey) return null
return ( return (
<BottomNavigationBarItem <BottomNavigationBarItem
active={current === 'spells' && display && spell === 'notifications'} active={current === 'spells' && display && spell === 'notifications'}
onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))} onClick={() => navigate('spells', { spell: 'notifications' })}
> >
<Bell /> <Bell />
</BottomNavigationBarItem> </BottomNavigationBarItem>

7
src/components/NoteCard/MainNoteCard.tsx

@ -20,7 +20,8 @@ export default function MainNoteCard({
originalNoteId, originalNoteId,
pinned = false, pinned = false,
hideParentNotePreview = false, hideParentNotePreview = false,
zapPollVoteHighlightOption zapPollVoteHighlightOption,
bottomNoteLabel
}: { }: {
event: Event event: Event
className?: string className?: string
@ -32,6 +33,7 @@ export default function MainNoteCard({
/** Hide the parent note preview (e.g. when showing quotes of current note). */ /** Hide the parent note preview (e.g. when showing quotes of current note). */
hideParentNotePreview?: boolean hideParentNotePreview?: boolean
zapPollVoteHighlightOption?: number zapPollVoteHighlightOption?: number
bottomNoteLabel?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigationOptional() const { navigateToNote } = useSmartNoteNavigationOptional()
@ -101,6 +103,9 @@ export default function MainNoteCard({
displayTopZapsAndLikes={isZapFeedCard} displayTopZapsAndLikes={isZapFeedCard}
/> />
) : null} ) : null}
{!embedded && bottomNoteLabel ? (
<div className="px-4 pt-1 text-xs text-muted-foreground">{bottomNoteLabel}</div>
) : null}
</div> </div>
{!embedded && <Separator />} {!embedded && <Separator />}
</div> </div>

14
src/components/NoteCard/RepostNoteCard.tsx

@ -14,12 +14,14 @@ export default function RepostNoteCard({
event, event,
className, className,
filterMutedNotes = true, filterMutedNotes = true,
pinned = false pinned = false,
bottomNoteLabel
}: { }: {
event: Event event: Event
className?: string className?: string
filterMutedNotes?: boolean filterMutedNotes?: boolean
pinned?: boolean pinned?: boolean
bottomNoteLabel?: string
}) { }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -90,5 +92,13 @@ export default function RepostNoteCard({
if (!targetEvent || shouldHide) return null if (!targetEvent || shouldHide) return null
return <MainNoteCard className={className} reposter={event.pubkey} event={targetEvent} pinned={pinned} /> return (
<MainNoteCard
className={className}
reposter={event.pubkey}
event={targetEvent}
pinned={pinned}
bottomNoteLabel={bottomNoteLabel}
/>
)
} }

10
src/components/NoteCard/index.tsx

@ -14,7 +14,8 @@ const NoteCard = memo(function NoteCard({
filterMutedNotes = true, filterMutedNotes = true,
pinned = false, pinned = false,
hideParentNotePreview = false, hideParentNotePreview = false,
zapPollVoteHighlightOption zapPollVoteHighlightOption,
bottomNoteLabel
}: { }: {
event: Event event: Event
className?: string 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). */ /** When true, hide the parent/root note preview (e.g. when showing quotes of the current note). */
hideParentNotePreview?: boolean hideParentNotePreview?: boolean
zapPollVoteHighlightOption?: number 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 { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -44,6 +47,7 @@ const NoteCard = memo(function NoteCard({
className={className} className={className}
filterMutedNotes={filterMutedNotes} filterMutedNotes={filterMutedNotes}
pinned={pinned} pinned={pinned}
bottomNoteLabel={bottomNoteLabel}
/> />
) )
} }
@ -54,6 +58,7 @@ const NoteCard = memo(function NoteCard({
pinned={pinned} pinned={pinned}
hideParentNotePreview={hideParentNotePreview} hideParentNotePreview={hideParentNotePreview}
zapPollVoteHighlightOption={zapPollVoteHighlightOption} zapPollVoteHighlightOption={zapPollVoteHighlightOption}
bottomNoteLabel={bottomNoteLabel}
/> />
) )
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {
@ -65,7 +70,8 @@ const NoteCard = memo(function NoteCard({
prevProps.filterMutedNotes === nextProps.filterMutedNotes && prevProps.filterMutedNotes === nextProps.filterMutedNotes &&
prevProps.pinned === nextProps.pinned && prevProps.pinned === nextProps.pinned &&
prevProps.hideParentNotePreview === nextProps.hideParentNotePreview && prevProps.hideParentNotePreview === nextProps.hideParentNotePreview &&
prevProps.zapPollVoteHighlightOption === nextProps.zapPollVoteHighlightOption prevProps.zapPollVoteHighlightOption === nextProps.zapPollVoteHighlightOption &&
prevProps.bottomNoteLabel === nextProps.bottomNoteLabel
) )
}) })

56
src/components/NoteList/index.tsx

@ -205,6 +205,44 @@ function buildNoteListMappedFilterForFullSearch(
return null 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( const NoteList = forwardRef(
( (
{ {
@ -2422,6 +2460,23 @@ const NoteList = forwardRef(
const listSourceEvents = timelineEventsForFilter const listSourceEvents = timelineEventsForFilter
const feedFullSearchActive = feedFullSearchEvents !== null 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<string, string>()
const map = new Map<string, string>()
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 = ( const list = (
<div className="min-h-screen"> <div className="min-h-screen">
@ -2441,6 +2496,7 @@ const NoteList = forwardRef(
className="w-full" className="w-full"
event={event} event={event}
filterMutedNotes={filterMutedNotes} filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(event.id)}
/> />
))} ))}
{listSourceEvents.length === 0 && {listSourceEvents.length === 0 &&

29
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 (
<SidebarItem
title="Favorites"
onClick={() => navigate('spells', { spell: 'favorites' })}
active={
display &&
current === 'spells' &&
primaryViewType === null &&
spell === 'favorites'
}
>
<Star strokeWidth={3} />
</SidebarItem>
)
}

4
src/components/Sidebar/FollowsLatestButton.tsx

@ -1,5 +1,6 @@
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useNostr } from '@/providers/NostrProvider'
import { UsersRound } from 'lucide-react' import { UsersRound } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import SidebarItem from './SidebarItem' import SidebarItem from './SidebarItem'
@ -8,6 +9,9 @@ export default function FollowsLatestButton() {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate, current, display } = usePrimaryPage() const { navigate, current, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView() const { primaryViewType } = usePrimaryNoteView()
const { pubkey } = useNostr()
if (!pubkey) return null
return ( return (
<SidebarItem <SidebarItem

6
src/components/Sidebar/NotificationButton.tsx

@ -7,13 +7,15 @@ import SidebarItem from './SidebarItem'
export default function NotificationButton() { export default function NotificationButton() {
const { navigate, current, currentPageProps, display } = usePrimaryPage() const { navigate, current, currentPageProps, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView() const { primaryViewType } = usePrimaryNoteView()
const { checkLogin } = useNostr() const { pubkey } = useNostr()
const spell = (currentPageProps as { spell?: string } | undefined)?.spell const spell = (currentPageProps as { spell?: string } | undefined)?.spell
if (!pubkey) return null
return ( return (
<SidebarItem <SidebarItem
title="Notifications" title="Notifications"
onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))} onClick={() => navigate('spells', { spell: 'notifications' })}
active={ active={
display && display &&
current === 'spells' && current === 'spells' &&

2
src/components/Sidebar/index.tsx

@ -10,6 +10,7 @@ import PostButton from './PostButton'
import RssButton from './RssButton' import RssButton from './RssButton'
import SearchButton from './SearchButton' import SearchButton from './SearchButton'
import FollowsLatestButton from './FollowsLatestButton' import FollowsLatestButton from './FollowsLatestButton'
import FavoritesButton from './FavoritesButton'
import SpellsButton from './SpellsButton' import SpellsButton from './SpellsButton'
import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysActiveStrip' import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysActiveStrip'
import PaneModeToggle from './PaneModeToggle' import PaneModeToggle from './PaneModeToggle'
@ -41,6 +42,7 @@ export default function PrimaryPageSidebar() {
<NotificationButton /> <NotificationButton />
<SearchButton /> <SearchButton />
<FollowsLatestButton /> <FollowsLatestButton />
<FavoritesButton />
<SpellsButton /> <SpellsButton />
<RssButton /> <RssButton />
<FavoriteRelaysActiveStripSidebar /> <FavoriteRelaysActiveStripSidebar />

6
src/constants.ts

@ -245,7 +245,6 @@ export const READ_ONLY_RELAY_URLS = [
'wss://relay.noswhere.com', 'wss://relay.noswhere.com',
'wss://search.nos.today', 'wss://search.nos.today',
'wss://trending.nostr.wine', 'wss://trending.nostr.wine',
'wss://sendit.nosflare.com',
'wss://relay.nip46.com' 'wss://relay.nip46.com'
] ]
@ -344,6 +343,10 @@ export const PROFILE_RELAY_URLS = [
'wss://thecitadel.nostr1.com' '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 // 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] 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', 'notifications',
'discussions', 'discussions',
'following', 'following',
'favorites',
'followPacks', 'followPacks',
'media', 'media',
'interests', 'interests',

8
src/i18n/locales/en.ts

@ -797,12 +797,19 @@ export default {
'No articles or publications match your search', 'No articles or publications match your search',
'articles and publications': 'articles and publications', 'articles and publications': 'articles and publications',
Interests: 'Interests', Interests: 'Interests',
Favorites: 'Favorites',
Calendar: 'Calendar', Calendar: 'Calendar',
'No subscribed interests yet.': 'No subscribed interests yet.':
'No subscribed interests yet. Add topics in settings to see them here.', '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.':
'No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.', '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 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.', '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.':
'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', 'Pin note': 'Pin note',
'Plain text description of the query': 'Plain text description of the query', '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 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 a group': 'Please select a group',
'Please select at least one relay': 'Please select at least one relay', 'Please select at least one relay': 'Please select at least one relay',
'Please set a start date': 'Please set a start date', 'Please set a start date': 'Please set a start date',

28
src/pages/primary/NoteListPage/index.tsx

@ -10,7 +10,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import type { TNoteListRef } from '@/components/NoteList' import type { TNoteListRef } from '@/components/NoteList'
import { NoteCardLoadingSkeleton } from '@/components/NoteCard' import { NoteCardLoadingSkeleton } from '@/components/NoteCard'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { Compass, Info, UsersRound } from 'lucide-react' import { Compass, Info, Star, UsersRound } from 'lucide-react'
import React, { import React, {
Dispatch, Dispatch,
forwardRef, forwardRef,
@ -219,10 +219,14 @@ function NoteListPageTitlebar({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { navigate, current, display } = usePrimaryPage() const { navigate, current, currentPageProps, display } = usePrimaryPage()
const { primaryViewType, setPrimaryNoteView } = usePrimaryNoteView() const { primaryViewType, setPrimaryNoteView } = usePrimaryNoteView()
const { pubkey } = useNostr()
const spell = (currentPageProps as { spell?: string } | undefined)?.spell
const exploreActive = display && current === 'explore' && primaryViewType === null const exploreActive = display && current === 'explore' && primaryViewType === null
const followsLatestActive = display && current === 'follows-latest' && primaryViewType === null const followsLatestActive = display && current === 'follows-latest' && primaryViewType === null
const favoritesActive =
display && current === 'spells' && spell === 'favorites' && primaryViewType === null
return ( return (
<div className="relative flex gap-1 items-center h-full justify-between"> <div className="relative flex gap-1 items-center h-full justify-between">
@ -246,6 +250,8 @@ function NoteListPageTitlebar({
> >
<Compass /> <Compass />
</Button> </Button>
{pubkey ? (
<>
<Button <Button
variant="ghost" variant="ghost"
size="titlebar-icon" size="titlebar-icon"
@ -262,6 +268,24 @@ function NoteListPageTitlebar({
> >
<UsersRound /> <UsersRound />
</Button> </Button>
<Button
variant="ghost"
size="titlebar-icon"
title={t('Favorites')}
aria-label={t('Favorites')}
className={favoritesActive ? 'bg-accent/50' : ''}
onClick={(e) => {
e.stopPropagation()
if (primaryViewType !== null) {
setPrimaryNoteView(null)
}
navigate('spells', { spell: 'favorites' })
}}
>
<Star />
</Button>
</>
) : null}
</> </>
)} )}
</div> </div>

28
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) { if (Array.isArray(f.ids) && f.ids.length > FAUX_SPELL_EVENT_LIMIT) {
f.ids = f.ids.slice(0, 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 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 * 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` 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). * 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. * 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 kindsList: number[] = DEFAULT_FEED_SHOW_KINDS
): TFeedSubRequest[] { ): TFeedSubRequest[] {
if (!relayUrls.length || !rawTopics.length || !kindsList.length) return [] 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)) new Set(rawTopics.map((t) => normalizeTopic(t)).filter((t) => t.length > 0))
).slice(0, INTERESTS_MAX_TOPICS) ).slice(0, INTERESTS_MAX_TOPICS)
if (!baseTopics.length) return [] const rawCasedTopics = Array.from(
const topics = Array.from( new Set(rawTopics.map((t) => canonicalizeRawTopicTagValue(t)).filter((t) => t.length > 0))
new Set( ).slice(0, INTERESTS_MAX_TOPICS)
baseTopics.flatMap((topic) => { if (!normalizedBaseTopics.length && !rawCasedTopics.length) return []
const topics = Array.from(new Set([
...normalizedBaseTopics.flatMap((topic) => {
const singular = normalizeTopic(topic) const singular = normalizeTopic(topic)
const plural = pluralizeTopic(singular) const plural = pluralizeTopic(singular)
return [singular, plural] return [singular, plural]
}) }),
) ...rawCasedTopics.flatMap((topic) => [topic, pluralizeTopic(topic)])
).slice(0, INTERESTS_MAX_TOPIC_TAG_VALUES) ])).slice(0, INTERESTS_MAX_TOPIC_TAG_VALUES)
if (!topics.length) return [] if (!topics.length) return []
return [ return [
{ {

189
src/pages/primary/SpellsPage/index.tsx

@ -101,7 +101,6 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog' import CreateSpellDialog from './CreateSpellDialog'
import { import {
appendCuratedReadOnlyRelays,
applyFauxSpellCapsToSubRequests, applyFauxSpellCapsToSubRequests,
buildBookmarksSubRequests, buildBookmarksSubRequests,
buildWebBookmarksSpellSubRequests, buildWebBookmarksSpellSubRequests,
@ -280,6 +279,8 @@ function fauxSpellLabelKey(name: FauxSpellName): string {
return 'Discussions' return 'Discussions'
case 'following': case 'following':
return 'Following' return 'Following'
case 'favorites':
return 'Favorites'
case 'followPacks': case 'followPacks':
return 'Follow Packs' return 'Follow Packs'
case 'media': case 'media':
@ -299,6 +300,7 @@ const FAUX_SPELL_ICON: Record<FauxSpellName, typeof Bell> = {
notifications: Bell, notifications: Bell,
discussions: MessageSquare, discussions: MessageSquare,
following: Users, following: Users,
favorites: Star,
followPacks: Gift, followPacks: Gift,
media: ImageIcon, media: ImageIcon,
interests: Hash, interests: Hash,
@ -398,6 +400,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([]) const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([])
const [followingFeedLoading, setFollowingFeedLoading] = useState(false) const [followingFeedLoading, setFollowingFeedLoading] = useState(false)
const [favoritesSubRequests, setFavoritesSubRequests] = useState<TFeedSubRequest[]>([])
const [favoritesFeedLoading, setFavoritesFeedLoading] = useState(false)
const loadSpells = useCallback(async () => { const loadSpells = useCallback(async () => {
const [events, ids] = await Promise.all([ const [events, ids] = await Promise.all([
@ -482,13 +486,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
relayList?.read ?? [], relayList?.read ?? [],
{ userWriteRelays: relayList?.write ?? [] } { userWriteRelays: relayList?.write ?? [] }
) )
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) if (!feedUrls.length) {
if (!urls.length) {
if (!cancelled) setFollowSetListEvents([]) if (!cancelled) setFollowSetListEvents([])
return return
} }
const events = await queryService.fetchEvents( const events = await queryService.fetchEvents(
urls, feedUrls,
{ authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 }, { authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 },
{ eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false }
) )
@ -774,11 +777,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
relayList?.read ?? [], relayList?.read ?? [],
{ userWriteRelays: relayList?.write ?? [] } { userWriteRelays: relayList?.write ?? [] }
) )
const withReadOnly = merged.map((r) => ({ if (!cancelled) setFollowingSubRequests(merged)
...r,
urls: appendCuratedReadOnlyRelays(r.urls, blockedRelays)
}))
if (!cancelled) setFollowingSubRequests(withReadOnly)
} catch { } catch {
if (!cancelled) setFollowingSubRequests([]) if (!cancelled) setFollowingSubRequests([])
} finally { } finally {
@ -798,6 +797,114 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
followSetListStableKey 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<string>([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 const interestTagsStableKey = interestListEvent
? JSON.stringify( ? JSON.stringify(
[...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) [...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
@ -822,7 +929,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
].join('\0') ].join('\0')
const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => { const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
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. */ /** 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 = const fauxSpellSkipSocialKindBlocked =
selectedFauxSpell === 'calendar' || selectedFauxSpell === 'calendar' ||
@ -845,40 +952,33 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return buildNotificationsSpellSubRequests(feedUrls, notificationsFeedPubkey) return buildNotificationsSpellSubRequests(feedUrls, notificationsFeedPubkey)
} }
if (selectedFauxSpell === 'discussions') { if (selectedFauxSpell === 'discussions') {
// Read-only prepended in appendCuratedReadOnlyRelays so FAUX_SPELL_MAX_RELAYS still includes aggr. if (!feedUrls.length) return []
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) return [{ urls: feedUrls, filter: buildDiscussionFilter() }]
if (!urls.length) return []
return [{ urls, filter: buildDiscussionFilter() }]
} }
if (selectedFauxSpell === 'media') { if (selectedFauxSpell === 'media') {
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) if (!feedUrls.length) return []
if (!urls.length) return [] return [{ urls: feedUrls, filter: buildMediaSpellFilter() }]
return [{ urls, filter: buildMediaSpellFilter() }]
} }
if (selectedFauxSpell === 'calendar') { if (selectedFauxSpell === 'calendar') {
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) if (!feedUrls.length) return []
if (!urls.length) return [] return [{ urls: feedUrls, filter: buildCalendarSpellFilter() }]
return [{ urls, filter: buildCalendarSpellFilter() }]
} }
if (selectedFauxSpell === 'interests') { if (selectedFauxSpell === 'interests') {
if (!pubkey || !interestListEvent) return [] if (!pubkey || !interestListEvent) return []
const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!) const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!)
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) return buildInterestsSubRequests(feedUrls, topics, DEFAULT_FEED_SHOW_KINDS)
return buildInterestsSubRequests(urls, topics, DEFAULT_FEED_SHOW_KINDS)
} }
if (selectedFauxSpell === 'bookmarks') { if (selectedFauxSpell === 'bookmarks') {
if (!pubkey) return [] if (!pubkey) return []
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) const idReqs = buildBookmarksSubRequests(bookmarkListEvent, feedUrls)
const idReqs = buildBookmarksSubRequests(bookmarkListEvent, urls) const webReqs = buildWebBookmarksSpellSubRequests(pubkey, feedUrls)
const webReqs = buildWebBookmarksSpellSubRequests(pubkey, urls)
return [...idReqs, ...webReqs] return [...idReqs, ...webReqs]
} }
if (selectedFauxSpell === 'followPacks') { if (selectedFauxSpell === 'followPacks') {
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) if (!feedUrls.length) return []
if (!urls.length) return []
return [ return [
{ {
urls, urls: feedUrls,
filter: { kinds: [ExtendedKind.FOLLOW_PACK], limit: FAUX_SPELL_EVENT_LIMIT } filter: { kinds: [ExtendedKind.FOLLOW_PACK], limit: FAUX_SPELL_EVENT_LIMIT }
} }
] ]
@ -887,11 +987,14 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
}, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey]) }, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey])
const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => { const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '') const base =
selectedFauxSpell === 'favorites'
? favoritesSubRequests
: isFollowFeedFauxSpellId(selectedFauxSpell ?? '')
? followingSubRequests ? followingSubRequests
: syncFauxSubRequests : syncFauxSubRequests
return applyFauxSpellCapsToSubRequests(base) return applyFauxSpellCapsToSubRequests(base)
}, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests]) }, [selectedFauxSpell, favoritesSubRequests, followingSubRequests, syncFauxSubRequests])
const spellSubRequests = useMemo<TFeedSubRequest[]>(() => { const spellSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedSpell) return [] if (!selectedSpell) return []
@ -1097,6 +1200,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'interests') { if (selectedFauxSpell === 'interests') {
return [...DEFAULT_FEED_SHOW_KINDS] return [...DEFAULT_FEED_SHOW_KINDS]
} }
if (selectedFauxSpell === 'favorites') {
return favoritesShowKinds
}
if (selectedFauxSpell === 'bookmarks') { if (selectedFauxSpell === 'bookmarks') {
const out = [...DEFAULT_FEED_SHOW_KINDS] const out = [...DEFAULT_FEED_SHOW_KINDS]
if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK) if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK)
@ -1108,7 +1214,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
.map((tag) => parseInt(tag[1], 10)) .map((tag) => parseInt(tag[1], 10))
.filter((n) => !Number.isNaN(n)) .filter((n) => !Number.isNaN(n))
return kinds.length ? kinds : [1] return kinds.length ? kinds : [1]
}, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey]) }, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey, favoritesShowKindsKey])
const spellMenuLabel = useCallback( const spellMenuLabel = useCallback(
(spell: Event) => (spell: Event) =>
@ -1225,17 +1331,19 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.') if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.')
if (selectedFauxSpell === 'bookmarks') if (selectedFauxSpell === 'bookmarks')
return t('No NIP-51 bookmarks or web bookmarks yet.') 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 (selectedFauxSpell === 'following') return t('No follows or relays to load yet.')
if (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty') if (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty')
return t('Nothing to load for this feed.') return t('Nothing to load for this feed.')
}, [selectedFauxSpell, fauxSubRequests.length, t]) }, [selectedFauxSpell, fauxSubRequests.length, t])
const showFollowFeedLoading = !!( const showAsyncFauxFeedLoading = !!(
pubkey && pubkey &&
selectedFauxSpell && selectedFauxSpell &&
isFollowFeedFauxSpellId(selectedFauxSpell) && (selectedFauxSpell === 'favorites'
(followingFeedLoading || ? favoritesFeedLoading
(isFollowSetSpellId(selectedFauxSpell) && followSetCatalogLoading)) : isFollowFeedFauxSpellId(selectedFauxSpell) &&
(followingFeedLoading || (isFollowSetSpellId(selectedFauxSpell) && followSetCatalogLoading)))
) )
const spellStarAddTitle = t('Spell star add title') const spellStarAddTitle = t('Spell star add title')
@ -1269,6 +1377,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if ( if (
(name === 'notifications' || (name === 'notifications' ||
name === 'following' || name === 'following' ||
name === 'favorites' ||
name === 'bookmarks' || name === 'bookmarks' ||
name === 'interests') && name === 'interests') &&
!pubkey !pubkey
@ -1675,7 +1784,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
<div className="py-8 text-center text-muted-foreground"> <div className="py-8 text-center text-muted-foreground">
{t('Please login to view bookmarks')} {t('Please login to view bookmarks')}
</div> </div>
) : showFollowFeedLoading ? ( ) : selectedFauxSpell === 'favorites' && !pubkey ? (
<div className="py-8 text-center text-muted-foreground">
{t('Please login to view favorites')}
</div>
) : showAsyncFauxFeedLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">{t('loading...')}</div> <div className="py-8 text-center text-sm text-muted-foreground">{t('loading...')}</div>
) : selectedFauxSpell && fauxSubRequests.length === 0 ? ( ) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div> <div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div>
@ -1706,7 +1819,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
: undefined : undefined
} }
clientSideKindFilter={ clientSideKindFilter={
selectedFauxSpell === 'notifications' || selectedFauxSpell === 'bookmarks' selectedFauxSpell === 'notifications' ||
selectedFauxSpell === 'bookmarks' ||
selectedFauxSpell === 'favorites'
} }
useFilterAsIs={fauxNoteListUseFilterAsIs} useFilterAsIs={fauxNoteListUseFilterAsIs}
oneShotFetch={false} oneShotFetch={false}

2
src/types/index.d.ts vendored

@ -6,6 +6,8 @@ export type TSubRequestFilter = Omit<Filter, 'since' | 'until'> & { limit: numbe
export type TFeedSubRequest = { export type TFeedSubRequest = {
urls: string[] urls: string[]
filter: Omit<Filter, 'since' | 'until'> filter: Omit<Filter, 'since' | 'until'>
/** Optional UI hint used by feed UIs (e.g. Favorites) to explain why an event was included. */
reasonLabel?: string
} }
export type TProfile = { export type TProfile = {

Loading…
Cancel
Save