Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
66776360cf
  1. 73
      src/components/QuoteList/index.tsx
  2. 15
      src/components/ReplyNoteList/index.tsx
  3. 2
      src/components/WebPreview/index.tsx
  4. 16
      src/lib/relay-list-builder.ts
  5. 32
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  6. 434
      src/pages/primary/SpellsPage/index.tsx
  7. 58
      src/services/navigation-event-store.ts

73
src/components/QuoteList/index.tsx

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { FAST_READ_RELAY_URLS } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
@ -13,6 +14,8 @@ import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' @@ -13,6 +14,8 @@ import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const LIMIT = 100
const SHOW_COUNT = 10
/** Multi-filter quote subs only set `eosed` after every sub EOSEs; one stuck relay would otherwise leave the UI loading forever. */
const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000
export default function QuoteList({
event,
@ -26,6 +29,7 @@ export default function QuoteList({ @@ -26,6 +29,7 @@ export default function QuoteList({
}) {
const { t } = useTranslation()
const { relayList: userRelayList } = useNostr()
const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [events, setEvents] = useState<Event[]>([])
@ -33,19 +37,35 @@ export default function QuoteList({ @@ -33,19 +37,35 @@ export default function QuoteList({
const [hasMore, setHasMore] = useState<boolean>(true)
const [loading, setLoading] = useState(true)
const bottomRef = useRef<HTMLDivElement | null>(null)
const receivedAnyQuotesRef = useRef(false)
useEffect(() => {
let cancelled = false
let loadTimeoutId: ReturnType<typeof setTimeout> | undefined
async function init() {
setLoading(true)
setEvents([])
setHasMore(true)
receivedAnyQuotesRef.current = false
loadTimeoutId = setTimeout(() => {
if (cancelled) return
setLoading(false)
if (!receivedAnyQuotesRef.current) {
setHasMore(false)
}
}, INITIAL_QUOTE_LOAD_TIMEOUT_MS)
// Privacy: Only use user's own relays + defaults, never connect to other users' relays
const userRelays = userRelayList?.read || []
const finalRelayUrls = Array.from(new Set([
...userRelays.map(url => normalizeUrl(url) || url),
...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url)
]))
const fromFeed = browsingRelayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean)
const finalRelayUrls = Array.from(
new Set([
...fromFeed,
...userRelays.map((url) => normalizeUrl(url) || url),
...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url)
])
)
const eventId = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id
const eventCoordinate = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : `${event.kind}:${event.pubkey}:${event.id}`
@ -86,21 +106,34 @@ export default function QuoteList({ @@ -86,21 +106,34 @@ export default function QuoteList({
}
],
{
onEvents: (events, eosed) => {
if (events.length > 0) {
setEvents(events)
onEvents: (batch, eosed) => {
if (cancelled) return
if (batch.length > 0) {
receivedAnyQuotesRef.current = true
setEvents(batch)
}
if (eosed) {
if (batch.length > 0 || eosed) {
setLoading(false)
// CRITICAL FIX: Always assume there might be more events
// Even if we got fewer events than the limit, there might be more due to filtering
// The loadMore logic will handle stopping when we've truly reached the end
setHasMore(true)
if (loadTimeoutId) {
clearTimeout(loadTimeoutId)
loadTimeoutId = undefined
}
}
if (eosed) {
setHasMore(batch.length > 0)
}
},
onNew: (event) => {
onNew: (newEvt) => {
if (cancelled) return
receivedAnyQuotesRef.current = true
setLoading(false)
if (loadTimeoutId) {
clearTimeout(loadTimeoutId)
loadTimeoutId = undefined
}
setHasMore(true)
setEvents((oldEvents) =>
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
[newEvt, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
)
}
},
@ -108,15 +141,21 @@ export default function QuoteList({ @@ -108,15 +141,21 @@ export default function QuoteList({
useCache: false // NO CACHING - stream raw from relays
}
)
if (cancelled) {
closer()
return undefined
}
setTimelineKey(timelineKey)
return closer
}
const promise = init()
return () => {
promise.then((closer) => closer())
cancelled = true
if (loadTimeoutId) clearTimeout(loadTimeoutId)
promise.then((closer) => closer?.())
}
}, [event])
}, [event, browsingRelayUrls, userRelayList?.read])
useEffect(() => {
const options = {

15
src/components/ReplyNoteList/index.tsx

@ -11,6 +11,7 @@ import { @@ -11,6 +11,7 @@ import {
} from '@/lib/event'
import { shouldHideInteractions } from '@/lib/event-filtering'
import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import { toNote } from '@/lib/link'
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager'
@ -19,12 +20,13 @@ import { useMuteList } from '@/providers/MuteListProvider' @@ -19,12 +20,13 @@ import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service'
import { eventService, queryService } from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service'
import discussionFeedCache from '@/services/discussion-feed-cache.service'
import { buildReplyReadRelayList } from '@/lib/relay-list-builder'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -63,6 +65,7 @@ function ReplyNoteList({ @@ -63,6 +65,7 @@ function ReplyNoteList({
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { relayList: userRelayList, pubkey: userPubkey } = useNostr()
const { blockedRelays } = useFavoriteRelays()
const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply()
@ -324,10 +327,16 @@ function ReplyNoteList({ @@ -324,10 +327,16 @@ function ReplyNoteList({
try {
// READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes
const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined
const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeUrl(u) || u).filter(Boolean)
const fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean)
const threadRelayHints = [
...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed])
]
const finalRelayUrls = await buildReplyReadRelayList(
opAuthorPubkey,
userPubkey || undefined,
blockedRelays || []
blockedRelays || [],
threadRelayHints
)
const filters: Filter[] = []
@ -410,7 +419,7 @@ function ReplyNoteList({ @@ -410,7 +419,7 @@ function ReplyNoteList({
}
init()
}, [rootInfo, currentIndex, index, userRelayList, event.kind, addReplies])
}, [rootInfo, currentIndex, index, userRelayList, event, blockedRelays, browsingRelayUrls, addReplies])
useEffect(() => {
if (replies.length === 0 && !loading && timelineKey) {

2
src/components/WebPreview/index.tsx

@ -134,7 +134,7 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -134,7 +134,7 @@ export default function WebPreview({ url, className }: { url: string; className?
const { isSmallScreen } = useScreenSize()
const cleanedUrl = useMemo(() => cleanUrl(url), [url])
const { title, description, image } = useFetchWebMetadata(cleanedUrl)
const { title, description, image, ogLoading } = useFetchWebMetadata(cleanedUrl)
const hostname = useMemo(() => {
try {

16
src/lib/relay-list-builder.ts

@ -257,6 +257,18 @@ export async function buildExploreProfileAndUserRelayList( @@ -257,6 +257,18 @@ export async function buildExploreProfileAndUserRelayList(
}
}
/** NIP-10 relay hints from `e` / `E` tags (third value) on the focused event or thread. */
export function relayHintsFromEventTags(event: { tags: string[][] }): string[] {
const out = new Set<string>()
for (const tag of event.tags) {
if ((tag[0] === 'e' || tag[0] === 'E') && tag[2]) {
const n = normalizeUrl(tag[2]) || tag[2]
if (n) out.add(n)
}
}
return [...out]
}
/**
* Build relay list for reading replies/comments
* READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes
@ -264,11 +276,13 @@ export async function buildExploreProfileAndUserRelayList( @@ -264,11 +276,13 @@ export async function buildExploreProfileAndUserRelayList(
export async function buildReplyReadRelayList(
opAuthorPubkey: string | undefined,
userPubkey: string | undefined,
blockedRelays: string[] = []
blockedRelays: string[] = [],
threadRelayHints: string[] = []
): Promise<string[]> {
return buildComprehensiveRelayList({
authorPubkey: opAuthorPubkey,
userPubkey,
relayHints: threadRelayHints,
includeFastReadRelays: true,
includeLocalRelays: true,
blockedRelays

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

@ -42,14 +42,38 @@ export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: s @@ -42,14 +42,38 @@ export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: s
return dedupe(base.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[])
}
/** Same cap/priority as the main Notification list: read/inbox relays first, then favorites, then defaults (few relays → faster EOSE, fewer dead sockets). */
const NOTIFICATION_FEED_MAX_RELAYS = 5
function relayUrlsUpToUnblocked(urls: string[], blocked: Set<string>, max: number): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const u of urls) {
const k = normalizeUrl(u) || u
if (!k || blocked.has(k) || seen.has(k)) continue
seen.add(k)
out.push(k)
if (out.length >= max) break
}
return out
}
export function notificationRelayUrls(
relayList: TRelayList | null | undefined,
favoriteRelays: string[]
favoriteRelays: string[],
blockedRelays: string[] = []
): string[] {
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b))
const read = relayList?.read ?? []
if (read.length > 0) return dedupe(read.slice(0, 5))
if (favoriteRelays.length > 0) return dedupe(favoriteRelays.slice(0, 5))
return dedupe(FAST_READ_RELAY_URLS.slice(0, 5))
if (read.length > 0) {
const fromRead = relayUrlsUpToUnblocked(read, blocked, NOTIFICATION_FEED_MAX_RELAYS)
if (fromRead.length > 0) return fromRead
}
if (favoriteRelays.length > 0) {
const fromFav = relayUrlsUpToUnblocked(favoriteRelays, blocked, NOTIFICATION_FEED_MAX_RELAYS)
if (fromFav.length > 0) return fromFav
}
return relayUrlsUpToUnblocked(FAST_READ_RELAY_URLS, blocked, NOTIFICATION_FEED_MAX_RELAYS)
}
function dedupe(urls: string[]): string[] {

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

@ -15,12 +15,7 @@ import { @@ -15,12 +15,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
@ -28,9 +23,11 @@ import { usePrimaryPage } from '@/PageManager' @@ -28,9 +23,11 @@ import { usePrimaryPage } from '@/PageManager'
import logger from '@/lib/logger'
import { showPublishingError } from '@/lib/publishing-feedback'
import { cn } from '@/lib/utils'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
@ -38,6 +35,7 @@ import storage from '@/services/local-storage.service' @@ -38,6 +35,7 @@ import storage from '@/services/local-storage.service'
import { ExtendedKind, FAUX_SPELL_ORDER, PROFILE_FEED_KINDS } from '@/constants'
import { isUserInEventMentions } from '@/lib/event'
import { formatPubkey } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url'
import {
buildSpellCatalogAuthors,
getRelaysForSpell,
@ -242,6 +240,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -242,6 +240,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const { navigate: navigatePrimary } = usePrimaryPage()
const { pubkey, relayList, attemptDelete, bookmarkListEvent, interestListEvent } = useNostr()
const { hideUntrustedNotifications } = useUserTrust()
const { isSmallScreen } = useScreenSize()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const {
showKinds: kindFilterShowKinds,
@ -320,6 +319,18 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -320,6 +319,18 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
[relayList]
)
/** Content key only — `relayList` often gets a new object ref from NostrProvider; recomputing spell filters would re-run `resolveRelativeTime` (Date.now) and churn NoteList subscriptions. */
const relayListWriteKey = useMemo(
() =>
JSON.stringify(
[...(relayList?.write ?? [])]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.sort()
),
[relayList]
)
useEffect(() => {
loadSpells()
}, [loadSpells])
@ -414,7 +425,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -414,7 +425,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'notifications') {
if (!pubkey) return []
const urls = fauxFavoriteRelayUrls(favoriteRelays, blockedRelays)
const urls = notificationRelayUrls(relayList, favoriteRelays, blockedRelays)
if (!urls.length) return []
return [{ urls, filter: buildMentionsSpellFilter(pubkey) }]
}
@ -441,17 +452,18 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -441,17 +452,18 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
}
if (selectedFauxSpell === 'bookmarks') {
if (!pubkey) return []
const urls = notificationRelayUrls(relayList, favoriteRelays)
const urls = notificationRelayUrls(relayList, favoriteRelays, blockedRelays)
return buildBookmarksSubRequests(bookmarkListEvent, urls)
}
if (selectedFauxSpell === 'followPacks') {
return buildFollowPacksSubRequests()
}
return []
// spellCatalogRelayKey: stable mailbox fingerprint (not relayList ref) so faux feeds don’t rebuild every NostrProvider tick
}, [
selectedFauxSpell,
pubkey,
relayList,
spellCatalogRelayKey,
favoriteRelays,
blockedRelays,
interestListEvent,
@ -472,13 +484,32 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -472,13 +484,32 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const relays = getRelaysForSpell(selectedSpell, { relayListWrite })
if (!relays.length) return []
return [{ urls: relays, filter }]
}, [selectedSpell, pubkey, contacts, relayList?.write])
// relayListWriteKey + contactsSyncKey: avoid recomputing when relayList/contacts are new refs with same contents (spell filters use Date.now via resolveRelativeTime)
}, [selectedSpell, pubkey, contactsSyncKey, relayListWriteKey])
const subRequests = useMemo<TFeedSubRequest[]>(() => {
if (selectedFauxSpell) return fauxSubRequests
return spellSubRequests
}, [selectedFauxSpell, fauxSubRequests, spellSubRequests])
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]
}, [subRequests])
const { addRelayUrls, removeRelayUrls } = useCurrentRelays()
useEffect(() => {
if (!spellBrowseRelayUrls.length) return
addRelayUrls(spellBrowseRelayUrls)
return () => removeRelayUrls(spellBrowseRelayUrls)
}, [spellBrowseRelayUrls, addRelayUrls, removeRelayUrls])
const toggleFavorite = useCallback(async (spellId: string) => {
const ids = await indexedDb.getSpellFavoriteIds()
const set = new Set(ids)
@ -645,6 +676,162 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -645,6 +676,162 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return t('Nothing to load for this feed.')
}, [selectedFauxSpell, fauxSubRequests.length, t])
const spellPickerList = (
<>
{FAUX_SPELL_ORDER.map((name) => {
if (
(name === 'notifications' ||
name === 'following' ||
name === 'bookmarks' ||
name === 'interests') &&
!pubkey
) {
return null
}
const Icon = FAUX_SPELL_ICON[name]
const selected = selectedFauxSpell === name
return (
<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(selected ? null : 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>
)
})}
<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}
/>
))}
</>
) : 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}
groupedUnderAuthor
/>
))}
</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}
groupedUnderAuthor
/>
))}
</div>
</div>
))}
</>
) : null}
</>
)
const spellPickerTriggerButton = (
<Button
type="button"
variant="outline"
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={
selectedFauxSpell
? t(fauxSpellLabelKey(selectedFauxSpell))
: selectedSpell
? spellMenuLabel(selectedSpell)
: undefined
}
aria-expanded={spellPickerOpen}
>
<span className="truncate">
{selectedFauxSpell
? t(fauxSpellLabelKey(selectedFauxSpell))
: selectedSpell
? spellMenuLabel(selectedSpell)
: t('Select a spell…')}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
</Button>
)
return (
<PrimaryPageLayout
ref={ref}
@ -672,174 +859,67 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -672,174 +859,67 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
{/* Spell picker + actions above the feed */}
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<>
<Button
type="button"
variant="outline"
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={
selectedFauxSpell
? t(fauxSpellLabelKey(selectedFauxSpell))
: selectedSpell
? spellMenuLabel(selectedSpell)
: undefined
}
aria-haspopup="dialog"
aria-expanded={spellPickerOpen}
onClick={() => setSpellPickerOpen(true)}
>
<span className="truncate">
{selectedFauxSpell
? t(fauxSpellLabelKey(selectedFauxSpell))
: selectedSpell
? spellMenuLabel(selectedSpell)
: t('Select a spell…')}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
</Button>
<Sheet open={spellPickerOpen} onOpenChange={setSpellPickerOpen}>
<SheetContent
side="bottom"
className="flex max-h-[min(92dvh,40rem)] flex-col gap-0 rounded-t-2xl p-0 sm:max-h-[75vh]"
>
<SheetHeader className="shrink-0 space-y-0 border-b px-4 py-3 text-left">
<SheetTitle className="text-base">{t('Select a spell…')}</SheetTitle>
</SheetHeader>
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-2"
role="listbox"
aria-label={t('Select a spell…')}
{isSmallScreen ? (
<>
<Button
type="button"
variant="outline"
className="min-w-0 flex-1 justify-between font-normal sm:max-w-md"
title={
selectedFauxSpell
? t(fauxSpellLabelKey(selectedFauxSpell))
: selectedSpell
? spellMenuLabel(selectedSpell)
: undefined
}
aria-haspopup="dialog"
aria-expanded={spellPickerOpen}
onClick={() => setSpellPickerOpen(true)}
>
{FAUX_SPELL_ORDER.map((name) => {
if (
(name === 'notifications' ||
name === 'following' ||
name === 'bookmarks' ||
name === 'interests') &&
!pubkey
) {
return null
}
const Icon = FAUX_SPELL_ICON[name]
const selected = selectedFauxSpell === name
return (
<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(selected ? null : 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>
)
})}
<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}
/>
))}
</>
) : 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}
groupedUnderAuthor
/>
))}
</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}
groupedUnderAuthor
/>
))}
</div>
</div>
))}
</>
) : null}
</div>
</SheetContent>
</Sheet>
<span className="truncate">
{selectedFauxSpell
? t(fauxSpellLabelKey(selectedFauxSpell))
: selectedSpell
? spellMenuLabel(selectedSpell)
: t('Select a spell…')}
</span>
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" aria-hidden />
</Button>
<Drawer open={spellPickerOpen} onOpenChange={setSpellPickerOpen}>
<DrawerContent className="flex max-h-[min(92dvh,40rem)] flex-col gap-0 p-0 sm:max-h-[75vh]">
<DrawerHeader className="shrink-0 space-y-0 border-b px-4 py-3 text-left">
<DrawerTitle className="text-base">{t('Select a spell…')}</DrawerTitle>
</DrawerHeader>
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-2"
role="listbox"
aria-label={t('Select a spell…')}
>
{spellPickerList}
</div>
</DrawerContent>
</Drawer>
</>
) : (
<DropdownMenu open={spellPickerOpen} onOpenChange={setSpellPickerOpen}>
<DropdownMenuTrigger asChild aria-haspopup="menu">
{spellPickerTriggerButton}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
showScrollButtons
className="max-h-[min(75vh,40rem)] w-[var(--radix-dropdown-menu-trigger-width)] max-w-md p-0"
>
<div className="sticky top-0 z-10 border-b bg-popover px-3 py-2 text-left text-sm font-semibold">
{t('Select a spell…')}
</div>
<div className="px-1 py-2" role="listbox" aria-label={t('Select a spell…')}>
{spellPickerList}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
<div className="flex shrink-0 flex-wrap items-center gap-2">

58
src/services/navigation-event-store.ts

@ -2,37 +2,75 @@ @@ -2,37 +2,75 @@
* Navigation Event Store
* Temporarily stores events when navigating to avoid re-fetching
*/
import { Event } from 'nostr-tools'
import { getNoteBech32Id } from '@/lib/event'
import { Event, nip19 } from 'nostr-tools'
/** URL paths use bech32 (nevent1…, naddr1…); lookups must match the `id` passed to `useFetchEvent`. */
function candidateKeysForNoteUrlId(eventId: string): string[] {
const keys = [eventId]
if (/^[a-f0-9]{64}$/i.test(eventId)) return keys
try {
const decoded = nip19.decode(eventId)
if (decoded.type === 'nevent') {
keys.push(decoded.data.id)
} else if (decoded.type === 'note') {
keys.push(decoded.data)
}
} catch {
/* not bech32 */
}
return keys
}
class NavigationEventStore {
private eventMap = new Map<string, Event>()
private removeEventFromAllKeys(event: Event): void {
this.eventMap.delete(event.id)
try {
const urlId = getNoteBech32Id(event)
if (urlId !== event.id) {
this.eventMap.delete(urlId)
}
} catch {
/* ignore */
}
}
/**
* Store an event for navigation (keyed by event ID)
* Store an event for navigation (hex id + same bech32 form as {@link toNote} / the URL).
*/
setEvent(event: Event): void {
this.eventMap.set(event.id, event)
// Also store by bech32 ID if available (for naddr/nevent)
// This will be handled by the navigation system
try {
const urlId = getNoteBech32Id(event)
if (urlId !== event.id) {
this.eventMap.set(urlId, event)
}
} catch {
/* ignore */
}
}
/**
* Get an event by ID (removes it after retrieval to prevent memory leaks)
*/
getEvent(eventId: string): Event | undefined {
const event = this.eventMap.get(eventId)
if (event) {
// Remove after retrieval to prevent memory leaks
this.eventMap.delete(eventId)
for (const key of candidateKeysForNoteUrlId(eventId)) {
const event = this.eventMap.get(key)
if (event) {
this.removeEventFromAllKeys(event)
return event
}
}
return event
return undefined
}
/**
* Check if an event exists without removing it
*/
hasEvent(eventId: string): boolean {
return this.eventMap.has(eventId)
return candidateKeysForNoteUrlId(eventId).some((k) => this.eventMap.has(k))
}
/**

Loading…
Cancel
Save