Browse Source

bug-fixes. combine profile feed with favorites feed

imwald
Silberengel 1 month ago
parent
commit
4264f41776
  1. 17
      src/PageManager.tsx
  2. 2
      src/components/Embedded/EmbeddedNote.tsx
  3. 6
      src/components/NormalFeed/index.tsx
  4. 319
      src/components/Profile/ProfileFeedWithPins.tsx
  5. 139
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  6. 4
      src/hooks/useFetchEvent.tsx
  7. 139
      src/hooks/useProfileAuthorFeedSubRequests.ts
  8. 41
      src/lib/profile-author-subrequests.ts
  9. 73
      src/pages/primary/CalendarPrimaryPage.tsx
  10. 32
      src/providers/FavoriteRelaysActivityProvider.tsx
  11. 22
      src/services/navigation-event-store.ts

17
src/PageManager.tsx

@ -449,17 +449,19 @@ export function useSmartNoteNavigation() { @@ -449,17 +449,19 @@ export function useSmartNoteNavigation() {
return
}
const { noteId } = parsed
// If event is provided, store it in navigation event store to avoid re-fetching
navigationEventStore.clear()
if (event) {
navigationEventStore.clear()
navigationEventStore.setEvent(event)
client.addEventToCache(event)
}
// Pre-cache related events (parent, root, embedded) so NotePage avoids re-fetching
if (relatedEvents?.length) {
for (const ev of relatedEvents) {
if (ev && ev !== event) client.addEventToCache(ev)
if (ev && ev !== event) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
}
}
@ -516,14 +518,17 @@ export function useSmartNoteNavigationOptional() { @@ -516,14 +518,17 @@ export function useSmartNoteNavigationOptional() {
return
}
const { noteId } = parsed
navigationEventStore.clear()
if (event) {
navigationEventStore.clear()
navigationEventStore.setEvent(event)
client.addEventToCache(event)
}
if (relatedEvents?.length) {
for (const ev of relatedEvents) {
if (ev && ev !== event) client.addEventToCache(ev)
if (ev && ev !== event) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
}
}
const contextualUrl = buildNoteUrl(noteId, currentPrimaryPage)

2
src/components/Embedded/EmbeddedNote.tsx

@ -280,7 +280,7 @@ function EmbeddedNoteFetched({ @@ -280,7 +280,7 @@ function EmbeddedNoteFetched({
const resolve = (ev: Event | undefined) => resolveAndSetRef.current(ev)
const tryShortcuts = (): boolean => {
const nav = navigationEventStore.getEvent(noteKey)
const nav = navigationEventStore.peekEvent(noteKey)
if (nav && resolve(nav)) return true
const peek = client.peekSessionCachedEvent(noteKey)
if (peek && resolve(peek)) return true

6
src/components/NormalFeed/index.tsx

@ -124,7 +124,11 @@ const NormalFeed = forwardRef<TNoteListRef, { @@ -124,7 +124,11 @@ const NormalFeed = forwardRef<TNoteListRef, {
}
return 'posts'
}
return storedMode || 'posts'
// Non-main feeds only expose Notes / Replies tabs — ignore stored "media" from the home gallery tab.
if (storedMode === 'posts' || storedMode === 'postsAndReplies') {
return storedMode
}
return 'posts'
})
const internalNoteListRef = useRef<TNoteListRef>(null)
const noteListRef = ref || internalNoteListRef

319
src/components/Profile/ProfileFeedWithPins.tsx

@ -1,25 +1,19 @@ @@ -1,25 +1,19 @@
import NoteList, { type TNoteListRef } from '@/components/NoteList'
import NoteCard from '@/components/NoteCard'
import ProfileSearchBar from '@/components/ui/ProfileSearchBar'
import KindFilter from '@/components/KindFilter'
import { RefreshButton } from '@/components/RefreshButton'
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind, PROFILE_POSTS_TAB_KINDS } from '@/constants'
import { isReplyNoteEvent } from '@/lib/event'
import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { useProfileAuthorFeedSubRequests } from '@/hooks/useProfileAuthorFeedSubRequests'
import { useProfilePins } from '@/hooks/useProfilePins'
import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { useProfileZapPollParticipation } from '@/hooks/useProfileZapPollParticipation'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useZap } from '@/providers/ZapProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import client from '@/services/client.service'
import storage from '@/services/local-storage.service'
import { RefreshCw } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { nip19, kinds } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const INITIAL_SHOW_COUNT = 25
const LOAD_MORE_COUNT = 25
function useHideRepliesLikeMainFeed() {
const [hideReplies, setHideReplies] = useState(() => {
const m = storage.getNoteListMode()
@ -41,9 +35,8 @@ function useHideRepliesLikeMainFeed() { @@ -41,9 +35,8 @@ function useHideRepliesLikeMainFeed() {
const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => {
const { t } = useTranslation()
const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults()
/** Profile timelines always show reposts; global kind filter still applies to other kinds. */
const { showKinds, showKind1OPs, showKind1Replies, showKind1111, feedKindFilterBypass } =
useKindFilterOrDefaults()
const profileTimelineShowKinds = useMemo(() => {
if (showKinds.includes(kinds.Repost) && showKinds.includes(ExtendedKind.GENERIC_REPOST)) {
return showKinds
@ -54,188 +47,53 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string @@ -54,188 +47,53 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
return next.sort((a, b) => a - b)
}, [showKinds])
const hideReplies = useHideRepliesLikeMainFeed()
const [searchQuery, setSearchQuery] = useState('')
const [isRefreshing, setIsRefreshing] = useState(false)
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(null)
const noteListRef = useRef<TNoteListRef>(null)
const { pinEvents, loadingPins, refreshPins } = useProfilePins(pubkey)
const filterPredicate = useCallback(
(event: Event) => {
if (event.kind === ExtendedKind.ZAP_RECEIPT) {
return shouldIncludeZapReceiptAtReplyThreshold(event, zapReplyThreshold)
}
return true
},
[zapReplyThreshold]
)
/** Bump when posts-tab `kinds` change so in-memory timeline cache is not reused across incompatible filters. */
const cacheKey = useMemo(
() => `${pubkey}-profile-posts-tab-v2-${zapReplyThreshold}`,
[pubkey, zapReplyThreshold]
)
const postsTabKinds = useMemo(() => [...PROFILE_POSTS_TAB_KINDS], [])
const { events: timelineEvents, isLoading: loadingTimeline, refresh: refreshTimeline } = useProfileTimeline({
pubkey,
cacheKey,
kinds: postsTabKinds,
limit: 200,
filterPredicate
})
const { rows: zapPollVoteRows, loading: loadingZapPollVotes, reload: reloadZapPollVotes } =
useProfileZapPollParticipation(pubkey)
const pinIds = useMemo(() => new Set(pinEvents.map((e) => e.id)), [pinEvents])
const passesMainFeedTimelineRules = useCallback(
(event: Event) => {
if (!profileTimelineShowKinds.includes(event.kind)) return false
if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event)
if (hideReplies && isReply) return false
if (isReply && !showKind1Replies) return false
if (!isReply && !showKind1OPs) return false
}
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false
if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false
return true
},
[profileTimelineShowKinds, showKind1OPs, showKind1Replies, showKind1111, hideReplies]
)
const restTimeline = useMemo(
() => timelineEvents.filter((e) => !pinIds.has(e.id)).filter(passesMainFeedTimelineRules),
[timelineEvents, pinIds, passesMainFeedTimelineRules]
)
type ProfileMergedRow = {
key: string
event: Event
sortAt: number
zapPollVoteHighlight?: number
}
const mergedRestRows = useMemo((): ProfileMergedRow[] => {
const showZapPollVotes = profileTimelineShowKinds.includes(ExtendedKind.ZAP_POLL)
const timelinePollIds = new Set(
restTimeline.filter((e) => e.kind === ExtendedKind.ZAP_POLL).map((e) => e.id)
)
const noteRows: ProfileMergedRow[] = restTimeline.map((e) => ({
key: e.id,
event: e,
sortAt: e.created_at
}))
const voteRows: ProfileMergedRow[] = showZapPollVotes
? zapPollVoteRows
.filter((r) => !timelinePollIds.has(r.poll.id))
.map((r) => ({
key: `zap-poll-vote:${r.voteReceipt.id}`,
event: r.poll,
sortAt: r.voteReceipt.created_at,
zapPollVoteHighlight: r.optionIndex
}))
: []
return [...noteRows, ...voteRows].sort((a, b) => b.sortAt - a.sortAt)
}, [restTimeline, zapPollVoteRows, profileTimelineShowKinds])
const rowMatchesSearch = useCallback(
(event: Event) => {
const q = searchQuery.trim().toLowerCase()
if (!q) return true
if (event.content.toLowerCase().includes(q)) return true
return event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(q))
},
[searchQuery]
)
const applySearch = useCallback(
(events: Event[]) => {
const q = searchQuery.trim().toLowerCase()
if (!q) return events
return events.filter((event) => rowMatchesSearch(event))
},
[rowMatchesSearch]
)
const { subRequests, followingFeedDeltaSubRequests, feedSubscriptionKey, refresh: refreshAuthorRelayLayers } =
useProfileAuthorFeedSubRequests({
pubkey,
kinds: postsTabKinds,
limit: 200
})
const filteredPins = useMemo(
() => applySearch(pinEvents).filter((e) => !isEventDeleted(e)),
[pinEvents, applySearch, isEventDeleted]
)
const filteredRest = useMemo(
const pinnedEventIds = useMemo(
() =>
mergedRestRows.filter((row) => rowMatchesSearch(row.event) && !isEventDeleted(row.event)),
[mergedRestRows, rowMatchesSearch, isEventDeleted]
)
const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest])
/** Pins always occupy the top of the profile; `showCount` caps total visible rows (pins + posts). */
const displayedPins = useMemo(() => {
if (filteredPins.length <= showCount) return filteredPins
return filteredPins.slice(0, showCount)
}, [filteredPins, showCount])
const displayedFeed = useMemo(
() => filteredRest.slice(0, Math.max(0, showCount - displayedPins.length)),
[filteredRest, showCount, displayedPins.length]
pinEvents.map((e) =>
nip19.neventEncode({ id: e.id, author: e.pubkey, kind: e.kind })
),
[pinEvents]
)
const totalVisible = displayedPins.length + displayedFeed.length
useEffect(() => {
setShowCount(INITIAL_SHOW_COUNT)
}, [searchQuery, pubkey])
useEffect(() => {
if (!loadingPins && !loadingTimeline && !loadingZapPollVotes) {
setIsRefreshing(false)
}
}, [loadingPins, loadingTimeline, loadingZapPollVotes])
const refreshAll = useCallback(() => {
setIsRefreshing(true)
refreshPins()
refreshTimeline()
reloadZapPollVotes()
refreshAuthorRelayLayers()
noteListRef.current?.refresh()
void client.fetchDeletionEventsForPubkey(pubkey)
}, [refreshPins, refreshTimeline, reloadZapPollVotes, pubkey])
}, [refreshPins, refreshAuthorRelayLayers, pubkey])
useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll])
useEffect(() => {
if (!bottomRef.current || totalVisible >= mergedDisplay.length) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && totalVisible < mergedDisplay.length) {
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, mergedDisplay.length))
}
},
{ threshold: 0.1 }
)
observer.observe(bottomRef.current)
return () => observer.disconnect()
}, [totalVisible, mergedDisplay.length])
if (!isRefreshing) return
const id = window.setTimeout(() => setIsRefreshing(false), 600)
return () => clearTimeout(id)
}, [isRefreshing])
const handleShowKindsChange = useCallback(() => {
noteListRef.current?.scrollToTop()
}, [])
// Pins and zap-poll votes can take longer than the timeline; do not block the whole tab on them.
// Show posts as soon as the timeline has delivered anything (or finished empty).
const showFullSkeleton =
mergedDisplay.length === 0 && loadingTimeline && timelineEvents.length === 0
const showPinsOnlySkeleton = pinEvents.length === 0 && loadingPins && subRequests.length === 0
if (showFullSkeleton) {
if (showPinsOnlySkeleton) {
return (
<div className="mt-4 space-y-2 px-1">
<div className="flex flex-wrap items-center gap-2 px-2">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={t('Search posts...')}
className="w-64 max-w-full"
/>
</div>
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
@ -245,94 +103,65 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string @@ -245,94 +103,65 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
)
}
if (!mergedDisplay.length && !loadingPins && !loadingTimeline && !loadingZapPollVotes) {
if (!subRequests.length) {
return (
<div className="mt-4 px-2">
<div className="flex flex-wrap items-center gap-2 mb-4">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={t('Search posts...')}
className="w-64 max-w-full"
/>
</div>
<div className="flex justify-center py-8 text-sm text-muted-foreground">
{searchQuery.trim() ? t('No posts match your search') : t('No posts found')}
</div>
<p className="py-8 text-center text-sm text-muted-foreground">{t('Nothing to load for this feed.')}</p>
</div>
)
}
return (
<div className="mt-4">
<div className="flex flex-wrap items-center gap-2 px-2 mb-2">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={t('Search posts...')}
className="w-64 max-w-full"
/>
</div>
<div className="mt-4 min-w-0">
{isRefreshing && (
<div
className="flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500"
className="mb-2 flex items-center justify-center gap-2 px-4 py-2 text-center text-sm text-green-500"
role="status"
aria-live="polite"
>
<RefreshCw className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
{t('Refreshing posts...')}
</div>
)}
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
{t('Showing {{filtered}} of {{total}} items', {
filtered: totalVisible,
total: mergedDisplay.length
})}
</div>
)}
<div className="space-y-2">
{displayedPins.length > 0 && (
<div className="space-y-2" aria-label={t('Pinned posts')}>
{displayedPins.map((event) => (
<NoteCard
key={event.id}
className="w-full"
event={event}
filterMutedNotes={false}
pinned
/>
))}
</div>
)}
{mergedDisplay.length === 0 && (loadingPins || loadingZapPollVotes) && (
<div className="flex justify-center py-6 text-sm text-muted-foreground" role="status" aria-live="polite">
{t('Loading…')}
</div>
)}
{displayedPins.length > 0 && displayedFeed.length > 0 && (
<div className="text-xs text-muted-foreground px-2 py-1 border-t border-border/60 mt-2 pt-2">
{t('Feed')}
</div>
)}
{displayedFeed.length > 0 && (
<div className="space-y-2" aria-label={t('Posts')}>
{displayedFeed.map((row) => (
<NoteCard
key={row.key}
className="w-full"
event={row.event}
filterMutedNotes={false}
pinned={false}
zapPollVoteHighlightOption={row.zapPollVoteHighlight}
/>
))}
</div>
)}
<div className="mb-2 flex flex-wrap items-center justify-end gap-2 px-2">
<RefreshButton onClick={refreshAll} />
<KindFilter showKinds={showKinds} onShowKindsChange={handleShowKindsChange} />
</div>
{totalVisible < mergedDisplay.length && (
<div ref={bottomRef} className="flex h-10 items-center justify-center">
<div className="text-sm text-muted-foreground">{t('Loading more...')}</div>
{pinEvents.filter((e) => !isEventDeleted(e)).length > 0 && (
<div className="mb-3 space-y-2 px-1" aria-label={t('Pinned posts')}>
{pinEvents
.filter((e) => !isEventDeleted(e))
.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} pinned />
))}
<div className="border-t border-border/60 px-2 py-1 text-xs text-muted-foreground">{t('Feed')}</div>
</div>
)}
<div className="min-h-[min(40vh,320px)] min-w-0">
<NoteList
ref={noteListRef}
subRequests={subRequests}
followingFeedDeltaSubRequests={followingFeedDeltaSubRequests}
feedSubscriptionKey={feedSubscriptionKey}
hostPrimaryPageName="profile"
showKinds={profileTimelineShowKinds}
seeAllFeedEvents={feedKindFilterBypass}
withKindFilter
useFilterAsIs
clientSideKindFilter
preserveTimelineOnSubRequestsChange
mergeTimelineWhenSubRequestFiltersMatch
pinnedEventIds={pinnedEventIds}
hideReplies={hideReplies}
hideUntrustedNotes={false}
filterMutedNotes={false}
showKind1OPs={showKind1OPs}
showKind1Replies={showKind1Replies}
showKind1111={showKind1111}
showFeedClientFilter
timelinePublicReadFallback={false}
revealBatchSize={48}
/>
</div>
</div>
)
})

139
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -20,7 +20,7 @@ import client from '@/services/client.service' @@ -20,7 +20,7 @@ import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds'
import { CalendarDays, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react'
import { type Event } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -49,8 +49,6 @@ export default function SidebarCalendarWeekWidget() { @@ -49,8 +49,6 @@ export default function SidebarCalendarWeekWidget() {
const [weekOffset, setWeekOffset] = useState(0)
const [rawEvents, setRawEvents] = useState<Event[]>([])
/** True only until the first IndexedDB (+ session) snapshot for this week is applied — never while relay REQ runs. */
const [loading, setLoading] = useState(true)
const relayUrls = useMemo(() => {
const base = getRelayUrlsWithFavoritesFastReadAndInbox(
@ -99,52 +97,70 @@ export default function SidebarCalendarWeekWidget() { @@ -99,52 +97,70 @@ export default function SidebarCalendarWeekWidget() {
useEffect(() => {
let cancelled = false
let lateMergeTimer: number | null = null
setLoading(true)
void (async () => {
try {
const { weekStartMs, weekEndExclusiveMs } = getLocalMondayWeekBounds(weekOffset)
const [fromIdb, fromArchive] = await Promise.all([
indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs),
indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400)
])
const { weekStartMs, weekEndExclusiveMs } = getLocalMondayWeekBounds(weekOffset)
const localBaseline = dedupeCalendarEventsPreferringOccurrenceRange(
[...fromIdb, ...fromArchive],
weekStartMs,
weekEndExclusiveMs
)
const sessionSnap = client.getSessionEventsMatchingSearch(
const fromSessionSync = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
const sessionOnly = dedupeCalendarEventsPreferringOccurrenceRange(
fromSessionSync,
weekStartMs,
weekEndExclusiveMs
)
setRawEvents(sessionOnly)
const scheduleLateSessionMerge = (mergeWithIdb: Event[]) => {
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset)
const later = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
const mergedLocal = dedupeCalendarEventsPreferringOccurrenceRange(
[...localBaseline, ...sessionSnap],
weekStartMs,
weekEndExclusiveMs
setRawEvents((prev) =>
dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...mergeWithIdb], ws, we)
)
/** Always paint IDB + session first; a superseded effect must not skip this (relayKey churn would leave the list blank). */
if (!cancelled) {
setRawEvents(mergedLocal)
setLoading(false)
}
}, 2500)
}
void (async () => {
try {
const idbP = Promise.all([
indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs, 8000),
indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400)
])
.then(([fromIdb, fromArchive]) =>
dedupeCalendarEventsPreferringOccurrenceRange(
[...fromIdb, ...fromArchive],
weekStartMs,
weekEndExclusiveMs
)
)
.catch((): Event[] => [])
void idbP.then((localBaseline) => {
if (cancelled) return
const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset)
const s2 = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(
dedupeCalendarEventsPreferringOccurrenceRange([...localBaseline, ...s2], ws, we)
)
})
if (cancelled) return
if (!relayUrls.length) {
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset)
const later = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) =>
dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...localBaseline], ws, we)
)
}, 2500)
void idbP.then((lb) => {
if (!cancelled) scheduleLateSessionMerge(lb)
})
return
}
@ -186,22 +202,21 @@ export default function SidebarCalendarWeekWidget() { @@ -186,22 +202,21 @@ export default function SidebarCalendarWeekWidget() {
)
)
let batch: Event[] = []
const fromFollowing: Event[] = []
try {
const merged = await Promise.all([mainReq, ...chunkReqs])
batch = merged[0] ?? []
for (let i = 1; i < merged.length; i++) {
fromFollowing.push(...(merged[i] ?? []))
}
} catch {
/** Relay REQ failed or timed out — keep the snapshot we already painted (re-apply in case of races). */
if (!cancelled) {
setRawEvents(mergedLocal)
}
}
const relayMergedP = Promise.all([mainReq, ...chunkReqs])
.then((merged) => {
const batch = merged[0] ?? []
const fromFollowing: Event[] = []
for (let i = 1; i < merged.length; i++) {
fromFollowing.push(...(merged[i] ?? []))
}
return { batch, fromFollowing }
})
.catch(() => ({ batch: [] as Event[], fromFollowing: [] as Event[] }))
const [{ batch, fromFollowing }, localBaseline] = await Promise.all([relayMergedP, idbP])
if (cancelled) return
const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset)
const fromSessionAfterNet = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
@ -211,22 +226,22 @@ export default function SidebarCalendarWeekWidget() { @@ -211,22 +226,22 @@ export default function SidebarCalendarWeekWidget() {
setRawEvents(
dedupeCalendarEventsPreferringOccurrenceRange(
[...localBaseline, ...fromSessionAfterNet, ...batch, ...fromFollowing],
weekStartMs,
weekEndExclusiveMs
ws,
we
)
)
}
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset)
const { weekStartMs: w2, weekEndExclusiveMs: w2e } = getLocalMondayWeekBounds(weekOffset)
const later = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) =>
dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...localBaseline], ws, we)
dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...localBaseline], w2, w2e)
)
}, 2500)
} catch {
@ -247,10 +262,7 @@ export default function SidebarCalendarWeekWidget() { @@ -247,10 +262,7 @@ export default function SidebarCalendarWeekWidget() {
} catch {
setRawEvents([])
}
setLoading(false)
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
@ -310,12 +322,7 @@ export default function SidebarCalendarWeekWidget() { @@ -310,12 +322,7 @@ export default function SidebarCalendarWeekWidget() {
<p className="mb-1.5 text-center text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{t('sidebarCalendarHeading')}
</p>
{loading && sortedForWeek.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-4 text-muted-foreground">
<Loader2 className="size-4 animate-spin" aria-hidden />
<span className="text-[11px]">{t('sidebarCalendarLoading')}</span>
</div>
) : sortedForWeek.length > 0 ? (
{sortedForWeek.length > 0 ? (
<ul className="min-w-0 space-y-1 overflow-y-auto pr-0.5" style={{ maxHeight: LIST_MAX_HEIGHT_PX }}>
{sortedForWeek.map((ev) => {
const meta = getCalendarEventMeta(ev)

4
src/hooks/useFetchEvent.tsx

@ -62,9 +62,9 @@ export function useFetchEvent( @@ -62,9 +62,9 @@ export function useFetchEvent(
}
}
// Check navigation event store first (events passed through navigation)
// Check navigation event store first (events passed through navigation) — peek so remounts still see it.
if (!skipShortcuts) {
const navigationEvent = navigationEventStore.getEvent(eventId)
const navigationEvent = navigationEventStore.peekEvent(eventId)
if (navigationEvent && !isEventDeleted(navigationEvent)) {
setEvent(navigationEvent)
addReplies([navigationEvent])

139
src/hooks/useProfileAuthorFeedSubRequests.ts

@ -0,0 +1,139 @@ @@ -0,0 +1,139 @@
import { buildProfileAuthorSubRequestsFromUrlGroups } from '@/lib/profile-author-subrequests'
import { buildProfilePageReadRelayUrls } from '@/lib/favorites-feed-relays'
import { computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity'
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl, subtractNormalizedRelayUrls } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostrOptional } from '@/providers/nostr-context'
import client from '@/services/client.service'
import type { TFeedSubRequest } from '@/types'
import { isSocialKindBlockedKind } from '@/constants'
import { useCallback, useEffect, useMemo, useState } from 'react'
function relayListsContentKey(favoriteRelays: string[], blockedRelays: string[]): string {
const fav = [...favoriteRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001')
const blk = [...blockedRelays].map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean).sort().join('\u0001')
return `${fav}\u0000${blk}`
}
const emptyAuthor = {
read: [] as string[],
write: [] as string[],
httpRead: [] as string[],
httpWrite: [] as string[]
}
export type UseProfileAuthorFeedSubRequestsOptions = {
pubkey: string
/** REQ kinds (e.g. {@link PROFILE_POSTS_TAB_KINDS}) — stable for the Posts tab. */
kinds: readonly number[]
limit?: number
}
export function useProfileAuthorFeedSubRequests({
pubkey,
kinds,
limit = 200
}: UseProfileAuthorFeedSubRequestsOptions): {
subRequests: TFeedSubRequest[]
followingFeedDeltaSubRequests: TFeedSubRequest[]
feedSubscriptionKey: string
refresh: () => void
} {
const nostr = useNostrOptional()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const includeAuthorLocalRelays = useMemo(() => {
const me = nostr?.pubkey?.trim()
if (!me) return false
try {
return hexPubkeysEqual(normalizeHexPubkey(me), normalizeHexPubkey(pubkey))
} catch {
return false
}
}, [nostr?.pubkey, pubkey])
const relayListsKey = useMemo(
() => relayListsContentKey(favoriteRelays, blockedRelays),
[favoriteRelays, blockedRelays]
)
const kindsKey = useMemo(() => [...kinds].join(','), [kinds])
const authorHex = useMemo(() => {
try {
return normalizeHexPubkey(pubkey)
} catch {
return pubkey.trim()
}
}, [pubkey])
const [refreshToken, setRefreshToken] = useState(0)
const [provisionalUrls, setProvisionalUrls] = useState<string[]>([])
const [fullUrls, setFullUrls] = useState<string[] | null>(null)
useEffect(() => {
let cancelled = false
const socialKinds = kinds.some(isSocialKindBlockedKind)
const provisional = buildProfilePageReadRelayUrls(
favoriteRelays,
blockedRelays,
emptyAuthor,
socialKinds,
includeAuthorLocalRelays,
kinds
)
if (!cancelled) {
setProvisionalUrls(provisional)
setFullUrls(null)
}
void client
.fetchRelayList(pubkey)
.catch(() => emptyAuthor)
.then((authorRl) => {
if (cancelled) return
const full = buildProfilePageReadRelayUrls(
favoriteRelays,
blockedRelays,
authorRl,
socialKinds,
includeAuthorLocalRelays,
kinds
)
setFullUrls(full)
})
return () => {
cancelled = true
}
}, [pubkey, relayListsKey, kindsKey, kinds, refreshToken, favoriteRelays, blockedRelays, includeAuthorLocalRelays])
const subRequests = useMemo(() => {
if (!provisionalUrls.length) return [] as TFeedSubRequest[]
return buildProfileAuthorSubRequestsFromUrlGroups([provisionalUrls], authorHex, [...kinds], limit)
}, [provisionalUrls, authorHex, kinds, limit])
const followingFeedDeltaSubRequests = useMemo(() => {
if (!fullUrls?.length || !provisionalUrls.length) return [] as TFeedSubRequest[]
const delta = subtractNormalizedRelayUrls(fullUrls, provisionalUrls)
if (!delta.length) return [] as TFeedSubRequest[]
return buildProfileAuthorSubRequestsFromUrlGroups([delta], authorHex, [...kinds], limit)
}, [fullUrls, provisionalUrls, authorHex, kinds, limit])
const feedSubscriptionKey = useMemo(() => {
const base = computeSpellSubRequestsIdentityKey(subRequests)
return `profile-posts-${authorHex}-${relayListsKey}-${base}`
}, [authorHex, relayListsKey, subRequests])
const refresh = useCallback(() => {
setRefreshToken((n) => n + 1)
}, [])
return {
subRequests,
followingFeedDeltaSubRequests,
feedSubscriptionKey,
refresh
}
}

41
src/lib/profile-author-subrequests.ts

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import type { TFeedSubRequest } from '@/types'
/**
* REQ shards for a profile posts timeline: per-relay URL group, `authors` + `kinds`, plus optional
* calendar-invite filters (#p) when {@link kindsArg} includes NIP-52 calendar kinds.
* Same shape as {@link useProfileTimeline}s internal {@code buildSubRequests}, for {@link NoteList} / {@link NormalFeed}.
*/
export function buildProfileAuthorSubRequestsFromUrlGroups(
groups: string[][],
authorPubkeyHex: string,
kindsArg: number[],
limit: number
): TFeedSubRequest[] {
const hasCalendarKinds = kindsArg.some((k) =>
(CALENDAR_EVENT_KINDS as readonly number[]).includes(k)
)
const authorRequests: TFeedSubRequest[] = groups
.map((urls) => ({
urls,
filter: {
authors: [authorPubkeyHex],
kinds: kindsArg,
limit
}
}))
.filter((request) => request.urls.length > 0)
const calendarInviteRequests: TFeedSubRequest[] = hasCalendarKinds
? groups
.map((urls) => ({
urls,
filter: {
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME],
'#p': [authorPubkeyHex],
limit: 100
}
}))
.filter((request) => request.urls.length > 0)
: []
return [...authorRequests, ...calendarInviteRequests]
}

73
src/pages/primary/CalendarPrimaryPage.tsx

@ -153,30 +153,43 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct @@ -153,30 +153,43 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
useEffect(() => {
let cancelled = false
let lateMergeTimer: number | null = null
setLoading(true)
void (async () => {
const scheduleLateSessionMerge = (mergeWithIdb: NostrEvent[]) => {
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
const later = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) =>
dedupeCalendarEventsPreferringOccurrenceRange(
[...prev, ...later, ...mergeWithIdb],
paddedMonthRange.rangeStartMs,
paddedMonthRange.rangeEndExclusiveMs
)
const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange
/** Same-tick paint from in-memory session (no await) — IDB + relays merge in the async block below. */
const fromSessionSync = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
const sessionOnly = dedupeCalendarEventsPreferringOccurrenceRange(
fromSessionSync,
rangeStartMs,
rangeEndExclusiveMs
)
setRawEvents(sessionOnly)
setLoading(false)
const scheduleLateSessionMerge = (mergeWithIdb: NostrEvent[]) => {
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
const later = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) =>
dedupeCalendarEventsPreferringOccurrenceRange(
[...prev, ...later, ...mergeWithIdb],
rangeStartMs,
rangeEndExclusiveMs
)
}, 2500)
}
)
}, 2500)
}
void (async () => {
try {
const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange
const idbP = Promise.all([
indexedDb.getCalendarEventsForOccurrenceWindow(
rangeStartMs,
@ -199,21 +212,6 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct @@ -199,21 +212,6 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
)
.catch((): NostrEvent[] => [])
const fromSessionNow = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
const sessionOnly = dedupeCalendarEventsPreferringOccurrenceRange(
fromSessionNow,
rangeStartMs,
rangeEndExclusiveMs
)
if (!cancelled) {
setRawEvents(sessionOnly)
setLoading(false)
}
void idbP.then((localBaseline) => {
if (cancelled) return
const s2 = client.getSessionEventsMatchingSearch(
@ -323,7 +321,8 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct @@ -323,7 +321,8 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
} catch {
if (!cancelled) {
try {
const { rangeStartMs: rs, rangeEndExclusiveMs: re } = paddedMonthRange
const rs = rangeStartMs
const re = rangeEndExclusiveMs
const [idb, arc] = await Promise.all([
indexedDb.getCalendarEventsForOccurrenceWindow(rs, re, MONTH_IDB_MAX_SCAN),
indexedDb.getArchivedCalendarEventsOverlappingWindow(rs, re, 55_000, 2500)

32
src/providers/FavoriteRelaysActivityProvider.tsx

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import logger from '@/lib/logger'
import { ExtendedKind, NIP71_VIDEO_KINDS } from '@/constants'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { buildLiveActivitiesRelayUrls } from '@/lib/live-activities'
import {
readRelayPulseActiveNpubsCache,
writeRelayPulseActiveNpubsCache
@ -138,7 +139,7 @@ function partitionByFollows(orderedPubkeys: string[], followings: string[]) { @@ -138,7 +139,7 @@ function partitionByFollows(orderedPubkeys: string[], followings: string[]) {
export function FavoriteRelaysActivityProvider({ children }: { children: React.ReactNode }) {
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { pubkey: viewerPubkey, followListEvent } = useNostr()
const { pubkey: viewerPubkey, followListEvent, relayList } = useNostr()
const followings = useMemo(
() => (followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []),
[followListEvent]
@ -158,17 +159,32 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R @@ -158,17 +159,32 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R
orderedPubkeysRef.current = orderedPubkeys
/** After restoring from disk, ignore the first empty network result (timeouts / slow relays), then behave normally. */
const skipFirstEmptyNetworkOverwriteRef = useRef(false)
const relayKey = useMemo(
() => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays).join('\n'),
[favoriteRelays, blockedRelays]
const pulseQueryUrls = useMemo(
() =>
buildLiveActivitiesRelayUrls({
loggedIn: !!viewerPubkey,
favoriteRelays,
blockedRelays,
relayListRead: userReadRelaysWithHttp(relayList),
relayListWrite: relayList?.write ?? []
}),
[viewerPubkey, favoriteRelays, blockedRelays, relayList]
)
const relayKey = useMemo(() => pulseQueryUrls.join('\n'), [pulseQueryUrls])
const fetchActive = useCallback(
async (useDefaultRelays = false) => {
const cacheViewer = viewerPubkey ?? storage.getCurrentAccount()?.pubkey ?? null
const urls = useDefaultRelays
? getFavoritesFeedRelayUrls([], blockedRelays)
: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
? buildLiveActivitiesRelayUrls({
loggedIn: false,
favoriteRelays: [],
blockedRelays,
relayListRead: [],
relayListWrite: []
})
: pulseQueryUrls
if (urls.length === 0) {
setLoading(false)
setRelayActivityReady(true)
@ -220,7 +236,7 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R @@ -220,7 +236,7 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R
setRelayActivityReady(true)
}
},
[favoriteRelays, blockedRelays, relayKey, viewerPubkey]
[favoriteRelays, blockedRelays, relayKey, viewerPubkey, pulseQueryUrls]
)
const fetchRef = useRef(fetchActive)

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

@ -25,18 +25,6 @@ function candidateKeysForNoteUrlId(eventId: string): string[] { @@ -25,18 +25,6 @@ function candidateKeysForNoteUrlId(eventId: string): string[] {
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 (hex id + same bech32 form as {@link toNote} / the URL).
*/
@ -53,15 +41,13 @@ class NavigationEventStore { @@ -53,15 +41,13 @@ class NavigationEventStore {
}
/**
* Get an event by ID (removes it after retrieval to prevent memory leaks)
* Read an event by ID without removing it (safe for React Strict Mode / effect re-runs).
* Cleared on the next {@link clear} (e.g. when navigating to another note).
*/
getEvent(eventId: string): Event | undefined {
peekEvent(eventId: string): Event | undefined {
for (const key of candidateKeysForNoteUrlId(eventId)) {
const event = this.eventMap.get(key)
if (event) {
this.removeEventFromAllKeys(event)
return event
}
if (event) return event
}
return undefined
}

Loading…
Cancel
Save