Browse Source

clear out less-popular components

imwald
Silberengel 1 month ago
parent
commit
74ce0717ba
  1. 5
      src/PageManager.tsx
  2. 17
      src/components/BottomNavigationBar/DiscussionsButton.tsx
  3. 2
      src/components/BottomNavigationBar/index.tsx
  4. 24
      src/components/Sidebar/DiscussionsButton.tsx
  5. 23
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  6. 2
      src/components/Sidebar/index.tsx
  7. 389
      src/pages/primary/SpellsPage/SpellPickerContent.tsx
  8. 86
      src/pages/primary/SpellsPage/fauxSpellConfig.ts
  9. 915
      src/pages/primary/SpellsPage/index.tsx
  10. 453
      src/pages/primary/SpellsPage/useSpellsPageFeed.ts
  11. 89
      src/providers/FeedProvider.tsx

5
src/PageManager.tsx

@ -229,7 +229,10 @@ function mergePrimaryPageEntry(
const element = map[entry.name] const element = map[entry.name]
const exists = prev.find((p) => p.name === entry.name) const exists = prev.find((p) => p.name === entry.name)
if (exists) { if (exists) {
if (entry.props) { /** Popstate sync passes `{ props: undefined }` when the URL has no `?spell=` — must clear stale props. */
if (Object.prototype.hasOwnProperty.call(entry, 'props')) {
exists.props = entry.props
} else if (entry.props) {
exists.props = { ...(exists.props || {}), ...entry.props } exists.props = { ...(exists.props || {}), ...entry.props }
} }
return [...prev] return [...prev]

17
src/components/BottomNavigationBar/DiscussionsButton.tsx

@ -1,17 +0,0 @@
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { MessageCircle } from 'lucide-react'
import BottomNavigationBarItem from './BottomNavigationBarItem'
export default function DiscussionsButton() {
const { navigate, current, currentPageProps, display } = usePrimaryPage()
const spell = (currentPageProps as { spell?: string } | undefined)?.spell
return (
<BottomNavigationBarItem
active={current === 'spells' && display && spell === 'discussions'}
onClick={() => navigate('spells', { spell: 'discussions' })}
>
<MessageCircle />
</BottomNavigationBarItem>
)
}

2
src/components/BottomNavigationBar/index.tsx

@ -1,7 +1,6 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import RssButton from './RssButton' import RssButton from './RssButton'
import HomeButton from './HomeButton' import HomeButton from './HomeButton'
import DiscussionsButton from './DiscussionsButton'
import NotificationsButton from './NotificationsButton' import NotificationsButton from './NotificationsButton'
import SearchButton from './SearchButton' import SearchButton from './SearchButton'
import SpellsButton from './SpellsButton' import SpellsButton from './SpellsButton'
@ -19,7 +18,6 @@ export default function BottomNavigationBar() {
}} }}
> >
<WriteButton /> <WriteButton />
<DiscussionsButton />
<RssButton /> <RssButton />
<HomeButton /> <HomeButton />
<SpellsButton /> <SpellsButton />

24
src/components/Sidebar/DiscussionsButton.tsx

@ -1,24 +0,0 @@
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { MessageCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import SidebarItem from './SidebarItem'
export default function DiscussionsButton() {
const { t } = useTranslation()
const { navigate, current, currentPageProps, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView()
const spell = (currentPageProps as { spell?: string } | undefined)?.spell
return (
<SidebarItem
title={t('Discussions')}
onClick={() => navigate('spells', { spell: 'discussions' })}
active={
display && current === 'spells' && primaryViewType === null && spell === 'discussions'
}
>
<MessageCircle strokeWidth={3} />
</SidebarItem>
)
}

23
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -116,7 +116,6 @@ export default function SidebarCalendarWeekWidget() {
indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs), indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs),
indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400) indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400)
]) ])
if (cancelled) return
const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive]) const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive])
const sessionSnap = client.getSessionEventsMatchingSearch( const sessionSnap = client.getSessionEventsMatchingSearch(
@ -125,8 +124,13 @@ export default function SidebarCalendarWeekWidget() {
[...CALENDAR_EVENT_KINDS] [...CALENDAR_EVENT_KINDS]
) )
const mergedLocal = dedupeCalendarEvents([...localBaseline, ...sessionSnap]) const mergedLocal = dedupeCalendarEvents([...localBaseline, ...sessionSnap])
setRawEvents(mergedLocal) /** Always paint IDB + session first; a superseded effect must not skip this (relayKey churn would leave the list blank). */
setLoading(false) if (!cancelled) {
setRawEvents(mergedLocal)
setLoading(false)
}
if (cancelled) return
if (!relayUrls.length) { if (!relayUrls.length) {
lateMergeTimer = window.setTimeout(() => { lateMergeTimer = window.setTimeout(() => {
@ -189,7 +193,10 @@ export default function SidebarCalendarWeekWidget() {
fromFollowing.push(...(merged[i] ?? [])) fromFollowing.push(...(merged[i] ?? []))
} }
} catch { } catch {
/* keep IndexedDB + session; relays may be slow or unreachable */ /** Relay REQ failed or timed out — keep the snapshot we already painted (re-apply in case of races). */
if (!cancelled) {
setRawEvents(mergedLocal)
}
} }
if (cancelled) return if (cancelled) return
@ -198,9 +205,11 @@ export default function SidebarCalendarWeekWidget() {
SESSION_CALENDAR_MERGE_CAP, SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS] [...CALENDAR_EVENT_KINDS]
) )
setRawEvents( if (!cancelled) {
dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSessionAfterNet, ...localBaseline]) setRawEvents(
) dedupeCalendarEvents([...localBaseline, ...fromSessionAfterNet, ...batch, ...fromFollowing])
)
}
lateMergeTimer = window.setTimeout(() => { lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null lateMergeTimer = null
if (cancelled) return if (cancelled) return

2
src/components/Sidebar/index.tsx

@ -2,7 +2,6 @@ import Icon from '@/assets/Icon'
import Logo from '@/assets/Logo' import Logo from '@/assets/Logo'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import HelpAndAccountMenu from '@/components/HelpAndAccountMenu' import HelpAndAccountMenu from '@/components/HelpAndAccountMenu'
import DiscussionsButton from './DiscussionsButton'
import FeedButton from './FeedButton' import FeedButton from './FeedButton'
import HomeButton from './HomeButton' import HomeButton from './HomeButton'
import NotificationButton from './NotificationButton' import NotificationButton from './NotificationButton'
@ -38,7 +37,6 @@ export default function PrimaryPageSidebar() {
<ReadOnlySessionIndicator variant="sidebar" /> <ReadOnlySessionIndicator variant="sidebar" />
<HomeButton /> <HomeButton />
<FeedButton /> <FeedButton />
<DiscussionsButton />
<NotificationButton /> <NotificationButton />
<SearchButton /> <SearchButton />
<FavoritesButton /> <FavoritesButton />

389
src/pages/primary/SpellsPage/SpellPickerContent.tsx

@ -0,0 +1,389 @@
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
import { formatPubkey } from '@/lib/pubkey'
import { getSpellName } from '@/services/spell.service'
import { FAUX_SPELL_ORDER } from '@/constants'
import { Check, Star } from 'lucide-react'
import type { Event } from 'nostr-tools'
import type { TFunction } from 'i18next'
import {
encodeFollowSetSpellId,
fauxSpellLabelKey,
FAUX_SPELL_ICON,
FOLLOW_SET_SPELL_ROW_ICON,
getFollowSetDTag,
labelFollowSetEvent
} from './fauxSpellConfig'
function spellPickerPrimaryAndSecondary(
spell: Event,
accountPubkey: string | undefined,
labelFor: (e: Event) => string,
options?: { omitAuthorNpub?: boolean }
) {
const primary = labelFor(spell)
const isOwn = !!(accountPubkey && spell.pubkey === accountPubkey)
const shortTitle = primary.trim().length < 4
const secondaryParts: string[] = []
if (!isOwn && !options?.omitAuthorNpub) secondaryParts.push(formatPubkey(spell.pubkey))
if (shortTitle) secondaryParts.push(`${spell.id.slice(0, 8)}`)
return {
primary,
secondary: secondaryParts.length > 0 ? secondaryParts.join(' · ') : null
}
}
export function groupSpellsByPubkeySorted(spells: Event[]): { pubkey: string; spells: Event[] }[] {
const map = new Map<string, Event[]>()
for (const s of spells) {
const list = map.get(s.pubkey)
if (list) list.push(s)
else map.set(s.pubkey, [s])
}
for (const list of map.values()) {
list.sort((a, b) =>
getSpellName(a).localeCompare(getSpellName(b), undefined, { sensitivity: 'base' })
)
}
return [...map.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([pk, list]) => ({ pubkey: pk, spells: list }))
}
function SpellSheetAuthorHeader({ userId }: { userId: string }) {
return (
<div className="flex items-center gap-2 border-b border-border/60 bg-muted/40 px-3 py-2">
<UserAvatar userId={userId} size="small" className="shrink-0" />
<Username
userId={userId}
className="min-w-0 text-sm font-semibold"
skeletonClassName="h-4 w-28"
/>
</div>
)
}
function SpellSheetOptionRow({
spell,
selected,
accountPubkey,
labelFor,
onPick,
groupedUnderAuthor = false,
starred = false,
onToggleStar,
starTitleAdd,
starTitleRemove,
t
}: {
spell: Event
selected: boolean
accountPubkey: string | undefined
labelFor: (e: Event) => string
onPick: (e: Event) => void
groupedUnderAuthor?: boolean
starred?: boolean
onToggleStar?: (spell: Event) => void
starTitleAdd?: string
starTitleRemove?: string
t: TFunction
}) {
const { primary, secondary } = spellPickerPrimaryAndSecondary(spell, accountPubkey, labelFor, {
omitAuthorNpub: groupedUnderAuthor
})
return (
<div
className={cn(
'flex w-full items-stretch gap-0.5 rounded-lg transition-colors',
selected && 'bg-accent/50'
)}
>
<button
type="button"
role="option"
aria-selected={selected}
className={cn(
'flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors',
'hover:bg-accent/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'rounded-lg'
)}
onClick={() => onPick(spell)}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{selected ? <Check className="size-4" aria-hidden /> : null}
</span>
<div className="flex min-w-0 flex-1 flex-col items-stretch gap-0.5">
<span className="truncate text-left font-medium leading-tight">{primary}</span>
{secondary ? (
<span className="truncate text-left text-xs text-muted-foreground">{secondary}</span>
) : null}
</div>
</button>
{onToggleStar ? (
<button
type="button"
className="flex shrink-0 items-center justify-center rounded-lg px-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={starred ? starTitleRemove ?? t('Remove from favorites') : starTitleAdd ?? t('Add to favorites')}
aria-label={starred ? starTitleRemove ?? t('Remove from favorites') : starTitleAdd ?? t('Add to favorites')}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onToggleStar(spell)
}}
>
<Star
className={cn('size-4', starred && 'fill-amber-400 text-amber-500')}
aria-hidden
/>
</button>
) : null}
</div>
)
}
export type SpellPickerContentProps = {
t: TFunction
pubkey: string | null | undefined
selectedSpell: Event | null
selectedFauxSpell: string | null
favoriteSpellSet: Set<string>
starredSpellsForPicker: Event[]
ownSpells: Event[]
followSpells: Event[]
otherSpells: Event[]
followSetListEvents: Event[]
spellMenuLabel: (spell: Event) => string
spellStarAddTitle: string
spellStarRemoveTitle: string
pickSpell: (spell: Event | null) => void
pickFauxSpell: (name: string | null) => void
clearSpellSelection: () => void
toggleFavoriteSpell: (spell: Event) => void
}
export function SpellPickerContent({
t,
pubkey,
selectedSpell,
selectedFauxSpell,
favoriteSpellSet,
starredSpellsForPicker,
ownSpells,
followSpells,
otherSpells,
followSetListEvents,
spellMenuLabel,
spellStarAddTitle,
spellStarRemoveTitle,
pickSpell,
pickFauxSpell,
clearSpellSelection,
toggleFavoriteSpell
}: SpellPickerContentProps) {
const followSpellGroups = groupSpellsByPubkeySorted(followSpells)
const otherSpellGroups = groupSpellsByPubkeySorted(otherSpells)
return (
<>
{starredSpellsForPicker.length > 0 ? (
<>
<p className="px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('Starred spells')}
</p>
{starredSpellsForPicker.map((spell) => (
<SpellSheetOptionRow
key={spell.id}
spell={spell}
selected={selectedSpell?.id === spell.id}
accountPubkey={pubkey ?? undefined}
labelFor={(e) => getSpellName(e)}
onPick={pickSpell}
starred
t={t}
onToggleStar={(s) => void toggleFavoriteSpell(s)}
starTitleAdd={spellStarAddTitle}
starTitleRemove={spellStarRemoveTitle}
/>
))}
<Separator className="my-2" />
</>
) : null}
{FAUX_SPELL_ORDER.flatMap((name) => {
if (
(name === 'notifications' ||
name === 'following' ||
name === 'heatMap' ||
name === 'bookmarks' ||
name === 'interests') &&
!pubkey
) {
return []
}
const Icon = FAUX_SPELL_ICON[name]
const selected = selectedFauxSpell === name
const builtinRow = (
<button
key={name}
type="button"
role="option"
aria-selected={selected}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selected && 'bg-accent/50'
)}
onClick={() => pickFauxSpell(name)}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{selected ? <Check className="size-4" aria-hidden /> : null}
</span>
<Icon className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left font-medium">
{t(fauxSpellLabelKey(name))}
</span>
</button>
)
if (name !== 'following' || !pubkey || followSetListEvents.length === 0) {
return [builtinRow]
}
const setRows = followSetListEvents.flatMap((ev) => {
const d = getFollowSetDTag(ev)
if (!d) return []
const spellId = encodeFollowSetSpellId(d)
const setSelected = selectedFauxSpell === spellId
return [
<button
key={spellId}
type="button"
role="option"
aria-selected={setSelected}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 pl-8 text-left text-sm transition-colors',
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
setSelected && 'bg-accent/50'
)}
onClick={() => pickFauxSpell(spellId)}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{setSelected ? <Check className="size-4" aria-hidden /> : null}
</span>
<FOLLOW_SET_SPELL_ROW_ICON className="size-4 shrink-0 opacity-80" />
<span className="min-w-0 flex-1 truncate text-left font-medium">
{labelFollowSetEvent(ev)}
</span>
</button>
]
})
return [builtinRow, ...setRows]
})}
<button
type="button"
role="option"
aria-selected={!selectedSpell && !selectedFauxSpell}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
!selectedSpell && !selectedFauxSpell && 'bg-accent/50'
)}
onClick={clearSpellSelection}
>
<span className="flex size-4 shrink-0 items-center justify-center">
{!selectedSpell && !selectedFauxSpell ? <Check className="size-4" aria-hidden /> : null}
</span>
<span className="min-w-0 flex-1 truncate text-left font-normal text-muted-foreground">
{t('Select a spell…')}
</span>
</button>
{ownSpells.length > 0 ? (
<>
<Separator className="my-2" />
<p className="px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('spellPickerSectionYours')}
</p>
{ownSpells.map((spell) => (
<SpellSheetOptionRow
key={spell.id}
spell={spell}
selected={selectedSpell?.id === spell.id}
accountPubkey={pubkey ?? undefined}
labelFor={spellMenuLabel}
onPick={pickSpell}
t={t}
starred={favoriteSpellSet.has(spell.id.toLowerCase())}
onToggleStar={(s) => void toggleFavoriteSpell(s)}
starTitleAdd={spellStarAddTitle}
starTitleRemove={spellStarRemoveTitle}
/>
))}
</>
) : null}
{followSpells.length > 0 ? (
<>
<Separator className="my-2" />
<p className="px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('Spells from follows', { count: followSpells.length })}
</p>
{followSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => (
<div key={authorPk} className="mt-2 overflow-hidden rounded-lg border border-border/60">
<SpellSheetAuthorHeader userId={authorPk} />
<div className="px-0.5 py-0.5">
{groupSpells.map((spell) => (
<SpellSheetOptionRow
key={spell.id}
spell={spell}
selected={selectedSpell?.id === spell.id}
accountPubkey={pubkey ?? undefined}
labelFor={spellMenuLabel}
onPick={pickSpell}
t={t}
groupedUnderAuthor
starred={favoriteSpellSet.has(spell.id.toLowerCase())}
onToggleStar={(s) => void toggleFavoriteSpell(s)}
starTitleAdd={spellStarAddTitle}
starTitleRemove={spellStarRemoveTitle}
/>
))}
</div>
</div>
))}
</>
) : null}
{otherSpells.length > 0 ? (
<>
<Separator className="my-2" />
<p className="px-3 pb-1 pt-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('Other spells', { count: otherSpells.length })}
</p>
{otherSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => (
<div key={authorPk} className="mt-2 overflow-hidden rounded-lg border border-border/60">
<SpellSheetAuthorHeader userId={authorPk} />
<div className="px-0.5 py-0.5">
{groupSpells.map((spell) => (
<SpellSheetOptionRow
key={spell.id}
spell={spell}
selected={selectedSpell?.id === spell.id}
accountPubkey={pubkey ?? undefined}
labelFor={spellMenuLabel}
onPick={pickSpell}
t={t}
groupedUnderAuthor
starred={favoriteSpellSet.has(spell.id.toLowerCase())}
onToggleStar={(s) => void toggleFavoriteSpell(s)}
starTitleAdd={spellStarAddTitle}
starTitleRemove={spellStarRemoveTitle}
/>
))}
</div>
</div>
))}
</>
) : null}
</>
)
}

86
src/pages/primary/SpellsPage/fauxSpellConfig.ts

@ -0,0 +1,86 @@
import {
decodeFollowSetSpellId,
encodeFollowSetSpellId,
getFollowSetDTag,
isFollowSetSpellId,
labelFollowSetEvent
} from '@/lib/follow-set-spell'
import { FAUX_SPELL_ORDER } from '@/constants'
import {
Bell,
Bookmark,
CalendarDays,
Flame,
Gift,
Hash,
Image as ImageIcon,
MessageSquare,
Users,
type LucideIcon
} from 'lucide-react'
export type FauxSpellName = (typeof FAUX_SPELL_ORDER)[number]
export function isBuiltinFauxSpell(s: string): s is FauxSpellName {
return (FAUX_SPELL_ORDER as readonly string[]).includes(s)
}
/** URL / picker param: built-in faux name or encoded follow-set spell id. */
export function isFauxSpellPageParam(s: string): boolean {
if (isBuiltinFauxSpell(s)) return true
if (!isFollowSetSpellId(s)) return false
return decodeFollowSetSpellId(s) != null
}
export function isFollowFeedFauxSpellId(s: string | null): boolean {
return s === 'following' || (!!s && isFollowSetSpellId(s))
}
export function fauxSpellLabelKey(name: FauxSpellName): string {
switch (name) {
case 'notifications':
return 'Notifications'
case 'discussions':
return 'Discussions'
case 'following':
return 'Following'
case 'heatMap':
return 'Heat map'
case 'followPacks':
return 'Follow Packs'
case 'media':
return 'Media'
case 'interests':
return 'Interests'
case 'bookmarks':
return 'Bookmarks'
case 'calendar':
return 'Calendar'
default:
return 'Spells'
}
}
export const FAUX_SPELL_ICON: Record<FauxSpellName, LucideIcon> = {
notifications: Bell,
discussions: MessageSquare,
following: Users,
heatMap: Flame,
followPacks: Gift,
media: ImageIcon,
interests: Hash,
bookmarks: Bookmark,
calendar: CalendarDays
}
/** Lucide icon for a follow-set row (indented under Following). */
export const FOLLOW_SET_SPELL_ROW_ICON = Users
/** Follow-set rows + URL segments */
export {
decodeFollowSetSpellId,
encodeFollowSetSpellId,
getFollowSetDTag,
isFollowSetSpellId,
labelFollowSetEvent
}

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

File diff suppressed because it is too large Load Diff

453
src/pages/primary/SpellsPage/useSpellsPageFeed.ts

@ -0,0 +1,453 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { Event } from 'nostr-tools'
import { kinds as nostrKinds } from 'nostr-tools'
import { ExtendedKind, DEFAULT_FEED_SHOW_KINDS } from '@/constants'
import { getPubkeysFromPTags } from '@/lib/tag'
import { normalizeUrl } from '@/lib/url'
import {
augmentSubRequestsWithFavoritesFastReadAndInbox,
getRelayUrlsWithFavoritesFastReadAndInbox,
userReadRelaysWithHttp
} from '@/lib/favorites-feed-relays'
import {
computeKind777SpellFeedSubscriptionKey,
computeSpellSubRequestsIdentityKey
} from '@/lib/spell-feed-request-identity'
import { isUserInEventMentions } from '@/lib/event'
import {
decodeFollowSetSpellId,
getFollowSetDTag,
isFollowSetSpellId,
pubkeysFromFollowSetEvent
} from '@/lib/follow-set-spell'
import client from '@/services/client.service'
import {
buildBookmarksSubRequests,
buildCalendarSpellFilter,
buildDiscussionFilter,
buildInterestsSubRequests,
buildMediaSpellFilter,
buildNotificationsSpellSubRequests,
buildWebBookmarksSpellSubRequests,
NOTIFICATION_SPELL_LOADING_SAFETY_MS,
FAUX_SPELL_EVENT_LIMIT,
MEDIA_SPELL_KINDS,
NOTIFICATION_SPELL_KINDS,
applyFauxSpellCapsToSubRequests
} from './fauxSpellFeeds'
import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service'
import type { TFeedSubRequest } from '@/types'
import { isFollowFeedFauxSpellId } from './fauxSpellConfig'
import storage from '@/services/local-storage.service'
function useNoteListHideReplies() {
const [hideReplies, setHideReplies] = useState(() => storage.getNoteListMode() === 'posts')
useEffect(() => {
const sync = () => setHideReplies(storage.getNoteListMode() === 'posts')
window.addEventListener('noteListModeChanged', sync)
return () => window.removeEventListener('noteListModeChanged', sync)
}, [])
return hideReplies
}
export type UseSpellsPageFeedArgs = {
selectedFauxSpell: string | null
selectedSpell: Event | null
pubkey: string | null | undefined
relayList: { read: string[]; write: string[] } | null | undefined
favoriteRelays: string[]
blockedRelays: string[]
notificationsFeedPubkey: string | null
interestListEvent: Event | null | undefined
bookmarkListEvent: Event | null | undefined
followListEvent: Event | null | undefined
contacts: string[]
contactsSyncKey: string
followSetListEvents: Event[]
followSetCatalogLoading: boolean
kindFilterShowKinds: number[]
}
export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
const {
selectedFauxSpell,
selectedSpell,
pubkey,
relayList,
favoriteRelays,
blockedRelays,
notificationsFeedPubkey,
interestListEvent,
bookmarkListEvent,
followListEvent,
contacts,
contactsSyncKey,
followSetListEvents,
followSetCatalogLoading,
kindFilterShowKinds
} = a
const hideRepliesFollowing = useNoteListHideReplies()
const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([])
const normalizedReadSorted = relayList
? [...relayList.read].map((u) => normalizeUrl(u) || u).filter(Boolean).sort()
: []
const normalizedWriteSorted = relayList
? [...relayList.write].map((u) => normalizeUrl(u) || u).filter(Boolean).sort()
: []
const relayMailboxStableKey =
relayList == null
? ''
: JSON.stringify({ r: normalizedReadSorted, w: normalizedWriteSorted })
const relayListWriteKey = useMemo(() => {
if (!relayList) return '[]'
return JSON.stringify(normalizedWriteSorted)
}, [relayMailboxStableKey])
const sortedFavoriteRelaysKey = useMemo(
() =>
JSON.stringify(
[...favoriteRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((x, y) => x.localeCompare(y))
),
[favoriteRelays]
)
const sortedBlockedRelaysKey = useMemo(
() =>
JSON.stringify(
[...blockedRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((x, y) => x.localeCompare(y))
),
[blockedRelays]
)
const followSetListStableKey = useMemo(
() =>
followSetListEvents
.map((e) => {
const d = getFollowSetDTag(e) ?? ''
return `${d}:${e.id}:${e.created_at}`
})
.sort()
.join('|'),
[followSetListEvents]
)
useEffect(() => {
if (!pubkey || !isFollowFeedFauxSpellId(selectedFauxSpell)) {
setFollowingSubRequests([])
return
}
const followSetD =
selectedFauxSpell && isFollowSetSpellId(selectedFauxSpell)
? decodeFollowSetSpellId(selectedFauxSpell)
: null
if (followSetD && followSetCatalogLoading) {
setFollowingSubRequests([])
return
}
let cancelled = false
void (async () => {
const augment = (raw: TFeedSubRequest[]) =>
augmentSubRequestsWithFavoritesFastReadAndInbox(
raw,
favoriteRelays,
blockedRelays,
userReadRelaysWithHttp(relayList),
{ userWriteRelays: relayList?.write ?? [] }
)
try {
if (selectedFauxSpell === 'following') {
const fromTags = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
const provisionalAuthors = [...new Set([pubkey, ...fromTags])]
let provisionalOk = false
try {
const rawProv = await client.generateSubRequestsForPubkeys(provisionalAuthors, pubkey)
if (!cancelled) {
setFollowingSubRequests(augment(rawProv))
provisionalOk = true
}
} catch {
/* refined wave may still succeed */
}
let followings = fromTags
try {
followings = await client.fetchFollowings(pubkey)
} catch {
followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
}
const fullAuthors = [...new Set([pubkey, ...followings])]
const sameSet =
fullAuthors.length === provisionalAuthors.length &&
fullAuthors.every((p) => provisionalAuthors.includes(p)) &&
provisionalAuthors.every((p) => fullAuthors.includes(p))
if (sameSet) {
if (!provisionalOk && !cancelled) {
try {
const req = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey)
if (!cancelled) setFollowingSubRequests(augment(req))
} catch {
if (!cancelled) setFollowingSubRequests([])
}
}
return
}
const req = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey)
if (!cancelled) setFollowingSubRequests(augment(req))
} else if (followSetD) {
const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === followSetD)
if (!ev) {
if (!cancelled) setFollowingSubRequests([])
return
}
const listed = pubkeysFromFollowSetEvent(ev)
const authorPubkeys = [pubkey, ...listed]
const req = await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey)
if (!cancelled) setFollowingSubRequests(augment(req))
} else {
if (!cancelled) setFollowingSubRequests([])
}
} catch {
if (!cancelled) setFollowingSubRequests([])
}
})()
return () => {
cancelled = true
}
}, [
selectedFauxSpell,
pubkey,
sortedFavoriteRelaysKey,
sortedBlockedRelaysKey,
relayMailboxStableKey,
followSetCatalogLoading,
followSetListStableKey,
followListEvent?.id,
favoriteRelays,
blockedRelays,
relayList,
followListEvent
])
const interestTagsStableKey = interestListEvent
? JSON.stringify(
[...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
)
: ''
const bookmarkTagsStableKey = bookmarkListEvent
? JSON.stringify(
[...bookmarkListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
)
: ''
const fauxFeedRelaysDepsKey = [
sortedFavoriteRelaysKey,
sortedBlockedRelaysKey,
interestListEvent?.id ?? '',
String(interestListEvent?.created_at ?? ''),
interestTagsStableKey,
bookmarkListEvent?.id ?? '',
String(bookmarkListEvent?.created_at ?? ''),
bookmarkTagsStableKey
].join('\0')
const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (
!selectedFauxSpell ||
isFollowFeedFauxSpellId(selectedFauxSpell) ||
selectedFauxSpell === 'heatMap'
)
return []
const fauxSpellSkipSocialKindBlocked =
selectedFauxSpell === 'calendar' ||
selectedFauxSpell === 'followPacks' ||
selectedFauxSpell === 'media' ||
selectedFauxSpell === 'bookmarks' ||
selectedFauxSpell === 'interests'
const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadRelaysWithHttp(relayList),
{
userWriteRelays: relayList?.write ?? [],
applySocialKindBlockedFilter: fauxSpellSkipSocialKindBlocked ? false : undefined
}
)
if (selectedFauxSpell === 'notifications') {
if (!notificationsFeedPubkey || !feedUrls.length) return []
return buildNotificationsSpellSubRequests(feedUrls, notificationsFeedPubkey)
}
if (selectedFauxSpell === 'discussions') {
if (!feedUrls.length) return []
return [{ urls: feedUrls, filter: buildDiscussionFilter() }]
}
if (selectedFauxSpell === 'media') {
if (!feedUrls.length) return []
return [{ urls: feedUrls, filter: buildMediaSpellFilter() }]
}
if (selectedFauxSpell === 'calendar') {
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]!)
return buildInterestsSubRequests(feedUrls, topics, DEFAULT_FEED_SHOW_KINDS)
}
if (selectedFauxSpell === 'bookmarks') {
if (!pubkey) return []
const idReqs = buildBookmarksSubRequests(bookmarkListEvent ?? null, feedUrls)
const webReqs = buildWebBookmarksSpellSubRequests(pubkey, feedUrls)
return [...idReqs, ...webReqs]
}
if (selectedFauxSpell === 'followPacks') {
if (!feedUrls.length) return []
return [
{
urls: feedUrls,
filter: { kinds: [ExtendedKind.FOLLOW_PACK], limit: FAUX_SPELL_EVENT_LIMIT }
}
]
}
return []
}, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey, interestListEvent, bookmarkListEvent, favoriteRelays, blockedRelays, relayList])
const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '')
? followingSubRequests
: syncFauxSubRequests
return applyFauxSpellCapsToSubRequests(base)
}, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests])
const spellSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedSpell) return []
const relayListWrite = relayList?.write ?? []
const ctx = { pubkey: pubkey ?? null, contacts }
const filter = spellEventToFilter(selectedSpell, ctx)
if (!filter) return []
const relays = getRelaysForSpell(selectedSpell, { relayListWrite })
if (!relays.length) return []
return [{ urls: relays, filter }]
}, [selectedSpell, pubkey, contactsSyncKey, relayListWriteKey, contacts, relayList])
const subRequests = useMemo<TFeedSubRequest[]>(() => {
if (selectedFauxSpell) return fauxSubRequests
return spellSubRequests
}, [selectedFauxSpell, fauxSubRequests, spellSubRequests])
const spellFeedSubscriptionKey = useMemo(() => {
if (selectedFauxSpell) return computeSpellSubRequestsIdentityKey(subRequests)
if (selectedSpell) return computeKind777SpellFeedSubscriptionKey(selectedSpell, subRequests)
return ''
}, [selectedFauxSpell, selectedSpell, subRequests])
const spellBrowseRelayUrls = useMemo(() => {
const set = new Set<string>()
for (const req of subRequests) {
for (const u of req.urls) {
const n = normalizeUrl(u) || u
if (n) set.add(n)
}
}
return [...set].sort()
}, [subRequests])
const spellBrowseRelayUrlsKey = spellBrowseRelayUrls.join('|')
const showKindsTagKey = useMemo(() => {
if (!selectedSpell) return ''
return selectedSpell.tags
.filter((tag) => tag[0] === 'k')
.map((tag) => tag[1])
.sort()
.join(',')
}, [selectedSpell?.id])
const followingShowKindsKey =
selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)
? JSON.stringify(kindFilterShowKinds)
: ''
const showKinds = useMemo(() => {
if (selectedFauxSpell === 'notifications') {
return [...NOTIFICATION_SPELL_KINDS]
}
if (selectedFauxSpell === 'discussions') {
return [ExtendedKind.DISCUSSION]
}
if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) {
const k = kindFilterShowKinds
const out = [...k]
if (!out.includes(nostrKinds.Repost)) out.push(nostrKinds.Repost)
if (!out.includes(ExtendedKind.GENERIC_REPOST)) out.push(ExtendedKind.GENERIC_REPOST)
return out.sort((x, y) => x - y)
}
if (selectedFauxSpell === 'followPacks') {
return [ExtendedKind.FOLLOW_PACK]
}
if (selectedFauxSpell === 'media') {
return [...MEDIA_SPELL_KINDS]
}
if (selectedFauxSpell === 'calendar') {
return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME]
}
if (selectedFauxSpell === 'interests') {
return [...DEFAULT_FEED_SHOW_KINDS]
}
if (selectedFauxSpell === 'bookmarks') {
const out = [...DEFAULT_FEED_SHOW_KINDS]
if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK)
return out.sort((a, b) => a - b)
}
if (!selectedSpell) return [1]
const kinds = selectedSpell.tags
.filter((tag) => tag[0] === 'k')
.map((tag) => parseInt(tag[1], 10))
.filter((n) => !Number.isNaN(n))
return kinds.length ? kinds : [1]
}, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey])
const fauxNoteListUseFilterAsIs = useMemo(() => {
if (!selectedFauxSpell) return true
if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) return false
return true
}, [selectedFauxSpell])
const spellFauxMergeTimeline = useMemo(
() => !!selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell),
[selectedFauxSpell]
)
const notificationsMentionExtraHide = useCallback(
(evt: Event) =>
notificationsFeedPubkey ? !isUserInEventMentions(evt, notificationsFeedPubkey) : false,
[notificationsFeedPubkey]
)
return {
relayMailboxStableKey,
sortedFavoriteRelaysKey,
sortedBlockedRelaysKey,
followingSubRequests,
fauxSubRequests,
subRequests,
spellFeedSubscriptionKey,
spellBrowseRelayUrls,
spellBrowseRelayUrlsKey,
showKinds,
fauxNoteListUseFilterAsIs,
spellFauxMergeTimeline,
notificationsMentionExtraHide,
hideRepliesFollowing,
NOTIFICATION_SPELL_LOADING_SAFETY_MS,
NOTIFICATION_SPELL_KINDS
}
}

89
src/providers/FeedProvider.tsx

@ -16,6 +16,14 @@ import { useNostr } from './NostrProvider'
export { useFeed } from './feed-context' export { useFeed } from './feed-context'
export type { TFeedContext } from './feed-context' export type { TFeedContext } from './feed-context'
function relayUrlListIdentity(urls: string[]): string {
return urls
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('\n')
}
export function FeedProvider({ children }: { children: React.ReactNode }) { export function FeedProvider({ children }: { children: React.ReactNode }) {
const { pubkey, isInitialized, cacheRelayListEvent, httpRelayListEvent } = useNostr() const { pubkey, isInitialized, cacheRelayListEvent, httpRelayListEvent } = useNostr()
const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays() const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays()
@ -48,6 +56,13 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
id: DEFAULT_FAVORITE_RELAYS[0] id: DEFAULT_FAVORITE_RELAYS[0]
}) })
const feedInfoRef = useRef<TFeedInfo>(feedInfo) const feedInfoRef = useRef<TFeedInfo>(feedInfo)
/** Same logical list as {@link mergeRelayUrlLayers} result — reuse array ref so NoteList does not re-subscribe. */
const setRelayUrlsIfChanged = useCallback((next: string[]) => {
setRelayUrls((prev) => {
if (relayUrlListIdentity(prev) === relayUrlListIdentity(next)) return prev
return next
})
}, [])
const switchFeed = useCallback(async ( const switchFeed = useCallback(async (
feedType: TFeedType, feedType: TFeedType,
@ -81,7 +96,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
logger.component('FeedProvider', 'Setting relay feed info', newFeedInfo) logger.component('FeedProvider', 'Setting relay feed info', newFeedInfo)
setFeedInfo(newFeedInfo) setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo feedInfoRef.current = newFeedInfo
setRelayUrls([normalizedUrl]) setRelayUrlsIfChanged([normalizedUrl])
logger.component('FeedProvider', 'Set relayUrls', { relayUrls: [normalizedUrl] }) logger.component('FeedProvider', 'Set relayUrls', { relayUrls: [normalizedUrl] })
storage.setFeedInfo(newFeedInfo, pubkey) storage.setFeedInfo(newFeedInfo, pubkey)
// Reset note list mode to 'posts' when switching to relay feed to ensure main content is shown // Reset note list mode to 'posts' when switching to relay feed to ensure main content is shown
@ -114,7 +129,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
const newFeedInfo = { feedType, id: relaySet.id } const newFeedInfo = { feedType, id: relaySet.id }
setFeedInfo(newFeedInfo) setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo feedInfoRef.current = newFeedInfo
setRelayUrls(relaySet.relayUrls) setRelayUrlsIfChanged(relaySet.relayUrls)
storage.setFeedInfo(newFeedInfo, pubkey) storage.setFeedInfo(newFeedInfo, pubkey)
// Reset note list mode to 'posts' when switching to relay set to ensure main content is shown // Reset note list mode to 'posts' when switching to relay set to ensure main content is shown
storage.setNoteListMode('posts') storage.setNoteListMode('posts')
@ -130,7 +145,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
const newFeedInfo = { feedType } const newFeedInfo = { feedType }
setFeedInfo(newFeedInfo) setFeedInfo(newFeedInfo)
feedInfoRef.current = newFeedInfo feedInfoRef.current = newFeedInfo
setRelayUrls(finalRelays) setRelayUrlsIfChanged(finalRelays)
storage.setFeedInfo(newFeedInfo, pubkey) storage.setFeedInfo(newFeedInfo, pubkey)
// Reset note list mode to 'posts' when switching to all-favorites to ensure main content is shown // Reset note list mode to 'posts' when switching to all-favorites to ensure main content is shown
storage.setNoteListMode('posts') storage.setNoteListMode('posts')
@ -138,7 +153,44 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
return return
} }
setIsReady(true) setIsReady(true)
}, [pubkey, favoriteRelays, blockedRelays, relaySets, extraFeedRelayUrls]) }, [pubkey, favoriteRelays, blockedRelays, relaySets, extraFeedRelayUrls, setRelayUrlsIfChanged])
const switchFeedRef = useRef(switchFeed)
switchFeedRef.current = switchFeed
const favoriteRelaysIdentity = useMemo(
() =>
[...favoriteRelays]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('|'),
[favoriteRelays]
)
const blockedRelaysIdentity = useMemo(
() =>
[...blockedRelays]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('|'),
[blockedRelays]
)
const relaySetsIdentity = useMemo(
() =>
relaySets
.map((s) => {
const urls = [...s.relayUrls]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join(',')
return `${s.id}:${urls}`
})
.sort()
.join('\n'),
[relaySets]
)
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@ -185,11 +237,11 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
logger.info('[FeedProvider] Migrated deprecated feed type to all-favorites', { logger.info('[FeedProvider] Migrated deprecated feed type to all-favorites', {
previous: previousMainFeed previous: previousMainFeed
}) })
return await switchFeed('all-favorites') return await switchFeedRef.current('all-favorites')
} }
if (feedInfo.feedType === 'relays') { if (feedInfo.feedType === 'relays') {
return await switchFeed('relays', { activeRelaySetId: feedInfo.id }) return await switchFeedRef.current('relays', { activeRelaySetId: feedInfo.id })
} }
if (feedInfo.feedType === 'relay') { if (feedInfo.feedType === 'relay') {
@ -199,17 +251,17 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
feedInfo.id = favoritesFeedRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0] feedInfo.id = favoritesFeedRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0]
} }
logger.component('FeedProvider', 'Initial relay setup, calling switchFeed', { relayId: feedInfo.id }) logger.component('FeedProvider', 'Initial relay setup, calling switchFeed', { relayId: feedInfo.id })
return await switchFeed('relay', { relay: feedInfo.id }) return await switchFeedRef.current('relay', { relay: feedInfo.id })
} }
if (feedInfo.feedType === 'all-favorites') { if (feedInfo.feedType === 'all-favorites') {
logger.debug('Initializing all-favorites feed') logger.debug('Initializing all-favorites feed')
return await switchFeed('all-favorites') return await switchFeedRef.current('all-favorites')
} }
} }
init() void init()
}, [pubkey, isInitialized, favoriteRelays, blockedRelays, switchFeed]) }, [pubkey, isInitialized, favoriteRelaysIdentity, blockedRelaysIdentity, relaySetsIdentity])
// Update relay URLs when favoriteRelays, blocked, or extra relay lists change while in all-favorites mode // Update relay URLs when favoriteRelays, blocked, or extra relay lists change while in all-favorites mode
useEffect(() => { useEffect(() => {
@ -219,21 +271,8 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
logger.debug('Updating relay URLs for all-favorites:', finalRelays) logger.debug('Updating relay URLs for all-favorites:', finalRelays)
// Same logical list can be merged into a new array each run; keep the previous reference so // Same logical list can be merged into a new array each run; keep the previous reference so
// feed consumers (RelaysFeed → NoteList relay subscription) do not re-enter effects in a tight loop. // feed consumers (RelaysFeed → NoteList relay subscription) do not re-enter effects in a tight loop.
const nextKey = finalRelays setRelayUrlsIfChanged(finalRelays)
.map((u) => normalizeAnyRelayUrl(u) || u) }, [feedInfo.feedType, favoriteRelays, blockedRelays, extraFeedRelayUrls, setRelayUrlsIfChanged])
.filter(Boolean)
.sort()
.join('\n')
setRelayUrls((prev) => {
const prevKey = prev
.map((u) => normalizeAnyRelayUrl(u) || u)
.filter(Boolean)
.sort()
.join('\n')
if (prevKey === nextKey) return prev
return finalRelays
})
}, [feedInfo.feedType, favoriteRelays, blockedRelays, extraFeedRelayUrls])
return ( return (
<FeedContext.Provider <FeedContext.Provider

Loading…
Cancel
Save