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 @@
import { FAST_READ_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -13,6 +14,8 @@ import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const LIMIT = 100 const LIMIT = 100
const SHOW_COUNT = 10 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({ export default function QuoteList({
event, event,
@ -26,6 +29,7 @@ export default function QuoteList({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { relayList: userRelayList } = useNostr() const { relayList: userRelayList } = useNostr()
const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
@ -33,19 +37,35 @@ export default function QuoteList({
const [hasMore, setHasMore] = useState<boolean>(true) const [hasMore, setHasMore] = useState<boolean>(true)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)
const receivedAnyQuotesRef = useRef(false)
useEffect(() => { useEffect(() => {
let cancelled = false
let loadTimeoutId: ReturnType<typeof setTimeout> | undefined
async function init() { async function init() {
setLoading(true) setLoading(true)
setEvents([]) setEvents([])
setHasMore(true) 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 userRelays = userRelayList?.read || []
const finalRelayUrls = Array.from(new Set([ const fromFeed = browsingRelayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean)
...userRelays.map(url => normalizeUrl(url) || url), const finalRelayUrls = Array.from(
...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url) 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 eventId = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id
const eventCoordinate = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : `${event.kind}:${event.pubkey}:${event.id}` const eventCoordinate = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : `${event.kind}:${event.pubkey}:${event.id}`
@ -86,21 +106,34 @@ export default function QuoteList({
} }
], ],
{ {
onEvents: (events, eosed) => { onEvents: (batch, eosed) => {
if (events.length > 0) { if (cancelled) return
setEvents(events) if (batch.length > 0) {
receivedAnyQuotesRef.current = true
setEvents(batch)
} }
if (eosed) { if (batch.length > 0 || eosed) {
setLoading(false) setLoading(false)
// CRITICAL FIX: Always assume there might be more events if (loadTimeoutId) {
// Even if we got fewer events than the limit, there might be more due to filtering clearTimeout(loadTimeoutId)
// The loadMore logic will handle stopping when we've truly reached the end loadTimeoutId = undefined
setHasMore(true) }
}
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) => 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({
useCache: false // NO CACHING - stream raw from relays useCache: false // NO CACHING - stream raw from relays
} }
) )
if (cancelled) {
closer()
return undefined
}
setTimelineKey(timelineKey) setTimelineKey(timelineKey)
return closer return closer
} }
const promise = init() const promise = init()
return () => { return () => {
promise.then((closer) => closer()) cancelled = true
if (loadTimeoutId) clearTimeout(loadTimeoutId)
promise.then((closer) => closer?.())
} }
}, [event]) }, [event, browsingRelayUrls, userRelayList?.read])
useEffect(() => { useEffect(() => {
const options = { const options = {

15
src/components/ReplyNoteList/index.tsx

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

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

@ -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 * Build relay list for reading replies/comments
* READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes * READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes
@ -264,11 +276,13 @@ export async function buildExploreProfileAndUserRelayList(
export async function buildReplyReadRelayList( export async function buildReplyReadRelayList(
opAuthorPubkey: string | undefined, opAuthorPubkey: string | undefined,
userPubkey: string | undefined, userPubkey: string | undefined,
blockedRelays: string[] = [] blockedRelays: string[] = [],
threadRelayHints: string[] = []
): Promise<string[]> { ): Promise<string[]> {
return buildComprehensiveRelayList({ return buildComprehensiveRelayList({
authorPubkey: opAuthorPubkey, authorPubkey: opAuthorPubkey,
userPubkey, userPubkey,
relayHints: threadRelayHints,
includeFastReadRelays: true, includeFastReadRelays: true,
includeLocalRelays: true, includeLocalRelays: true,
blockedRelays blockedRelays

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

@ -42,14 +42,38 @@ export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: s
return dedupe(base.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]) 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( export function notificationRelayUrls(
relayList: TRelayList | null | undefined, relayList: TRelayList | null | undefined,
favoriteRelays: string[] favoriteRelays: string[],
blockedRelays: string[] = []
): string[] { ): string[] {
const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b))
const read = relayList?.read ?? [] const read = relayList?.read ?? []
if (read.length > 0) return dedupe(read.slice(0, 5)) if (read.length > 0) {
if (favoriteRelays.length > 0) return dedupe(favoriteRelays.slice(0, 5)) const fromRead = relayUrlsUpToUnblocked(read, blocked, NOTIFICATION_FEED_MAX_RELAYS)
return dedupe(FAST_READ_RELAY_URLS.slice(0, 5)) 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[] { function dedupe(urls: string[]): string[] {

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

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

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

@ -2,37 +2,75 @@
* Navigation Event Store * Navigation Event Store
* Temporarily stores events when navigating to avoid re-fetching * 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 { class NavigationEventStore {
private eventMap = new Map<string, Event>() 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 { setEvent(event: Event): void {
this.eventMap.set(event.id, event) this.eventMap.set(event.id, event)
// Also store by bech32 ID if available (for naddr/nevent) try {
// This will be handled by the navigation system 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) * Get an event by ID (removes it after retrieval to prevent memory leaks)
*/ */
getEvent(eventId: string): Event | undefined { getEvent(eventId: string): Event | undefined {
const event = this.eventMap.get(eventId) for (const key of candidateKeysForNoteUrlId(eventId)) {
if (event) { const event = this.eventMap.get(key)
// Remove after retrieval to prevent memory leaks if (event) {
this.eventMap.delete(eventId) this.removeEventFromAllKeys(event)
return event
}
} }
return event return undefined
} }
/** /**
* Check if an event exists without removing it * Check if an event exists without removing it
*/ */
hasEvent(eventId: string): boolean { hasEvent(eventId: string): boolean {
return this.eventMap.has(eventId) return candidateKeysForNoteUrlId(eventId).some((k) => this.eventMap.has(k))
} }
/** /**

Loading…
Cancel
Save