11 changed files with 1112 additions and 893 deletions
@ -1,17 +0,0 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -1,24 +0,0 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,389 @@
@@ -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} |
||||
</> |
||||
) |
||||
} |
||||
@ -0,0 +1,86 @@
@@ -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 |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,453 @@
@@ -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 |
||||
} |
||||
} |
||||
Loading…
Reference in new issue