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' @@ -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 (
<BottomNavigationBarItem
active={current === 'spells' && display && spell === 'notifications'}
onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))}
onClick={() => navigate('spells', { spell: 'notifications' })}
>
<Bell />
</BottomNavigationBarItem>

7
src/components/NoteCard/MainNoteCard.tsx

@ -20,7 +20,8 @@ export default function MainNoteCard({ @@ -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({ @@ -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({ @@ -101,6 +103,9 @@ export default function MainNoteCard({
displayTopZapsAndLikes={isZapFeedCard}
/>
) : null}
{!embedded && bottomNoteLabel ? (
<div className="px-4 pt-1 text-xs text-muted-foreground">{bottomNoteLabel}</div>
) : null}
</div>
{!embedded && <Separator />}
</div>

14
src/components/NoteCard/RepostNoteCard.tsx

@ -14,12 +14,14 @@ export default function RepostNoteCard({ @@ -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({ @@ -90,5 +92,13 @@ export default function RepostNoteCard({
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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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
)
})

56
src/components/NoteList/index.tsx

@ -205,6 +205,44 @@ function buildNoteListMappedFilterForFullSearch( @@ -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( @@ -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<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 = (
<div className="min-h-screen">
@ -2441,6 +2496,7 @@ const NoteList = forwardRef( @@ -2441,6 +2496,7 @@ const NoteList = forwardRef(
className="w-full"
event={event}
filterMutedNotes={filterMutedNotes}
bottomNoteLabel={eventReasonLabelMap.get(event.id)}
/>
))}
{listSourceEvents.length === 0 &&

29
src/components/Sidebar/FavoritesButton.tsx

@ -0,0 +1,29 @@ @@ -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 @@ @@ -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() { @@ -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 (
<SidebarItem

6
src/components/Sidebar/NotificationButton.tsx

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

2
src/components/Sidebar/index.tsx

@ -10,6 +10,7 @@ import PostButton from './PostButton' @@ -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() { @@ -41,6 +42,7 @@ export default function PrimaryPageSidebar() {
<NotificationButton />
<SearchButton />
<FollowsLatestButton />
<FavoritesButton />
<SpellsButton />
<RssButton />
<FavoriteRelaysActiveStripSidebar />

6
src/constants.ts

@ -245,7 +245,6 @@ export const READ_ONLY_RELAY_URLS = [ @@ -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 = [ @@ -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 = [ @@ -606,6 +609,7 @@ export const FAUX_SPELL_ORDER = [
'notifications',
'discussions',
'following',
'favorites',
'followPacks',
'media',
'interests',

8
src/i18n/locales/en.ts

@ -797,12 +797,19 @@ export default { @@ -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 { @@ -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',

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

@ -10,7 +10,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -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({ @@ -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 (
<div className="relative flex gap-1 items-center h-full justify-between">
@ -246,6 +250,8 @@ function NoteListPageTitlebar({ @@ -246,6 +250,8 @@ function NoteListPageTitlebar({
>
<Compass />
</Button>
{pubkey ? (
<>
<Button
variant="ghost"
size="titlebar-icon"
@ -262,6 +268,24 @@ function NoteListPageTitlebar({ @@ -262,6 +268,24 @@ function NoteListPageTitlebar({
>
<UsersRound />
</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>

28
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -48,7 +48,7 @@ export function applyFauxSpellCapsToSubRequests(requests: TFeedSubRequest[]): TF @@ -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 @@ -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 { @@ -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( @@ -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 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]
})
)
).slice(0, INTERESTS_MAX_TOPIC_TAG_VALUES)
}),
...rawCasedTopics.flatMap((topic) => [topic, pluralizeTopic(topic)])
])).slice(0, INTERESTS_MAX_TOPIC_TAG_VALUES)
if (!topics.length) return []
return [
{

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

@ -101,7 +101,6 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe @@ -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 { @@ -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<FauxSpellName, typeof Bell> = { @@ -299,6 +300,7 @@ const FAUX_SPELL_ICON: Record<FauxSpellName, typeof Bell> = {
notifications: Bell,
discussions: MessageSquare,
following: Users,
favorites: Star,
followPacks: Gift,
media: ImageIcon,
interests: Hash,
@ -398,6 +400,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -398,6 +400,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([])
const [followingFeedLoading, setFollowingFeedLoading] = useState(false)
const [favoritesSubRequests, setFavoritesSubRequests] = useState<TFeedSubRequest[]>([])
const [favoritesFeedLoading, setFavoritesFeedLoading] = useState(false)
const loadSpells = useCallback(async () => {
const [events, ids] = await Promise.all([
@ -482,13 +486,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -482,13 +486,12 @@ const SpellsPage = forwardRef<TPageRef>(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<TPageRef>(function SpellsPage( @@ -774,11 +777,7 @@ const SpellsPage = forwardRef<TPageRef>(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<TPageRef>(function SpellsPage( @@ -798,6 +797,114 @@ const SpellsPage = forwardRef<TPageRef>(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<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
? JSON.stringify(
[...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
@ -822,7 +929,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -822,7 +929,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
].join('\0')
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. */
const fauxSpellSkipSocialKindBlocked =
selectedFauxSpell === 'calendar' ||
@ -845,40 +952,33 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -845,40 +952,33 @@ const SpellsPage = forwardRef<TPageRef>(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<TPageRef>(function SpellsPage( @@ -887,11 +987,14 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
}, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey])
const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '')
const base =
selectedFauxSpell === 'favorites'
? favoritesSubRequests
: isFollowFeedFauxSpellId(selectedFauxSpell ?? '')
? followingSubRequests
: syncFauxSubRequests
return applyFauxSpellCapsToSubRequests(base)
}, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests])
}, [selectedFauxSpell, favoritesSubRequests, followingSubRequests, syncFauxSubRequests])
const spellSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedSpell) return []
@ -1097,6 +1200,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1097,6 +1200,9 @@ const SpellsPage = forwardRef<TPageRef>(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<TPageRef>(function SpellsPage( @@ -1108,7 +1214,7 @@ const SpellsPage = forwardRef<TPageRef>(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<TPageRef>(function SpellsPage( @@ -1225,17 +1331,19 @@ const SpellsPage = forwardRef<TPageRef>(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<TPageRef>(function SpellsPage( @@ -1269,6 +1377,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (
(name === 'notifications' ||
name === 'following' ||
name === 'favorites' ||
name === 'bookmarks' ||
name === 'interests') &&
!pubkey
@ -1675,7 +1784,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1675,7 +1784,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
<div className="py-8 text-center text-muted-foreground">
{t('Please login to view bookmarks')}
</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>
) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div>
@ -1706,7 +1819,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1706,7 +1819,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
: undefined
}
clientSideKindFilter={
selectedFauxSpell === 'notifications' || selectedFauxSpell === 'bookmarks'
selectedFauxSpell === 'notifications' ||
selectedFauxSpell === 'bookmarks' ||
selectedFauxSpell === 'favorites'
}
useFilterAsIs={fauxNoteListUseFilterAsIs}
oneShotFetch={false}

2
src/types/index.d.ts vendored

@ -6,6 +6,8 @@ export type TSubRequestFilter = Omit<Filter, 'since' | 'until'> & { limit: numbe @@ -6,6 +6,8 @@ export type TSubRequestFilter = Omit<Filter, 'since' | 'until'> & { limit: numbe
export type TFeedSubRequest = {
urls: string[]
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 = {

Loading…
Cancel
Save