Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
23b504ab7a
  1. 5
      src/components/Note/PublicationCard.tsx
  2. 6
      src/components/Profile/ProfileMediaFeed.tsx
  3. 2
      src/components/Profile/ProfileTimeline.tsx
  4. 29
      src/components/Profile/index.tsx
  5. 43
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  6. 42
      src/constants.ts
  7. 9
      src/hooks/useFetchEvent.tsx
  8. 7
      src/hooks/useNoteStatsById.tsx
  9. 43
      src/hooks/useProfileTimeline.tsx
  10. 1
      src/i18n/locales/cs.ts
  11. 1
      src/i18n/locales/de.ts
  12. 1
      src/i18n/locales/en.ts
  13. 1
      src/i18n/locales/es.ts
  14. 1
      src/i18n/locales/fr.ts
  15. 1
      src/i18n/locales/nl.ts
  16. 1
      src/i18n/locales/pl.ts
  17. 1
      src/i18n/locales/ru.ts
  18. 1
      src/i18n/locales/tr.ts
  19. 1
      src/i18n/locales/zh.ts
  20. 45
      src/pages/primary/CalendarPrimaryPage.tsx
  21. 106
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx
  22. 55
      src/services/client.service.ts
  23. 54
      src/services/indexed-db.service.ts
  24. 44
      src/services/note-stats.service.ts

5
src/components/Note/PublicationCard.tsx

@ -2,7 +2,7 @@ import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb' @@ -2,7 +2,7 @@ import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPageOptional } from '@/PageManager'
import { useSecondaryPageOptional, useSmartNoteNavigationOptional } from '@/PageManager'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
@ -20,6 +20,7 @@ export default function PublicationCard({ @@ -20,6 +20,7 @@ export default function PublicationCard({
}) {
const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false
const { navigateToNote } = useSmartNoteNavigationOptional()
const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
const contentPolicy = useContentPolicyOptional()
@ -32,7 +33,7 @@ export default function PublicationCard({ @@ -32,7 +33,7 @@ export default function PublicationCard({
const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation()
push(toNote(event))
navigateToNote(toNote(event), event)
}
const titleComponent = metadata.title ? <div className="text-xl font-semibold break-words min-w-0 sm:line-clamp-2">{metadata.title}</div> : null

6
src/components/Profile/ProfileMediaFeed.tsx

@ -77,7 +77,11 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey @@ -77,7 +77,11 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey
}
}, [pubkey, blockedKey, blockedRelays, includeAuthorLocalRelays])
const authorRelayUrls = refinedAuthorRelayUrls ?? provisionalAuthorRelayUrls
/** Empty NIP-65 stack is not “unknown” — fall back to provisional tier so augmented read relays still apply. */
const authorRelayUrls =
refinedAuthorRelayUrls != null && refinedAuthorRelayUrls.length > 0
? refinedAuthorRelayUrls
: provisionalAuthorRelayUrls
const subRequests = useMemo(() => {
const pk = pubkey?.trim()

2
src/components/Profile/ProfileTimeline.tsx

@ -140,7 +140,7 @@ const ProfileTimeline = forwardRef< @@ -140,7 +140,7 @@ const ProfileTimeline = forwardRef<
return () => {
observer.disconnect()
}
}, [displayedEvents.length, filteredEvents.length])
}, [displayedEvents.length, filteredEvents.length, isLoading])
if (!pubkey) {
return (

29
src/components/Profile/index.tsx

@ -200,6 +200,7 @@ export default function Profile({ @@ -200,6 +200,7 @@ export default function Profile({
const postsFeedRef = useRef<{ refresh: () => void }>(null)
const mediaFeedRef = useRef<TNoteListRef>(null)
const publicationsFeedRef = useRef<{ refresh: () => void }>(null)
const [profileFeedTab, setProfileFeedTab] = useState<'posts' | 'media' | 'publications'>('posts')
const { profile, isFetching } = useFetchProfile(id)
const { pubkey: accountPubkey, publish, checkLogin } = useNostr()
@ -388,6 +389,24 @@ export default function Profile({ @@ -388,6 +389,24 @@ export default function Profile({
forceUpdateCache()
}, [profile?.pubkey])
useEffect(() => {
if (!profile?.pubkey) return
setProfileFeedTab('posts')
}, [profile?.pubkey])
/**
* Radix {@link TabsContent} unmounts inactive panels, so media / publications feeds can miss the same
* warm-up window as Posts or show a frozen first paint. Re-run their refresh path when the tab becomes active
* (after refs attach {@link useLayoutEffect}).
*/
useLayoutEffect(() => {
if (profileFeedTab === 'media') {
mediaFeedRef.current?.refresh()
} else if (profileFeedTab === 'publications') {
publicationsFeedRef.current?.refresh()
}
}, [profileFeedTab])
if (!profile && isFetching) {
return (
<>
@ -695,7 +714,15 @@ export default function Profile({ @@ -695,7 +714,15 @@ export default function Profile({
</div>
</div>
</div>
<Tabs defaultValue="posts" className="min-w-0 pt-4">
<Tabs
value={profileFeedTab}
onValueChange={(v) => {
if (v === 'posts' || v === 'media' || v === 'publications') {
setProfileFeedTab(v)
}
}}
className="min-w-0 pt-4"
>
<TabsList className="mb-2 ml-1 w-auto justify-start md:ml-4">
<TabsTrigger value="posts">{t('Posts')}</TabsTrigger>
<TabsTrigger value="media">{t('Media')}</TabsTrigger>

43
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -111,7 +111,11 @@ export default function SidebarCalendarWeekWidget() { @@ -111,7 +111,11 @@ export default function SidebarCalendarWeekWidget() {
void (async () => {
try {
const { weekStartMs, weekEndExclusiveMs } = getLocalMondayWeekBounds(weekOffset)
const fromIdb = await indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs)
const [fromIdb, fromArchive] = await Promise.all([
indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs),
indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400)
])
const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive])
if (!relayUrls.length) {
if (cancelled) return
@ -120,7 +124,7 @@ export default function SidebarCalendarWeekWidget() { @@ -120,7 +124,7 @@ export default function SidebarCalendarWeekWidget() {
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...fromIdb, ...fromSession]))
setRawEvents(dedupeCalendarEvents([...localBaseline, ...fromSession]))
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
@ -129,7 +133,7 @@ export default function SidebarCalendarWeekWidget() { @@ -129,7 +133,7 @@ export default function SidebarCalendarWeekWidget() {
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...fromIdb]))
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...localBaseline]))
}, 2500)
return
}
@ -190,7 +194,7 @@ export default function SidebarCalendarWeekWidget() { @@ -190,7 +194,7 @@ export default function SidebarCalendarWeekWidget() {
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...fromIdb]))
setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...localBaseline]))
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
@ -199,10 +203,27 @@ export default function SidebarCalendarWeekWidget() { @@ -199,10 +203,27 @@ export default function SidebarCalendarWeekWidget() {
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later]))
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...localBaseline]))
}, 2500)
} catch {
if (!cancelled) setRawEvents([])
if (!cancelled) {
try {
const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset)
const [idb, arc] = await Promise.all([
indexedDb.getCalendarEventsForOccurrenceWindow(ws, we),
indexedDb.getArchivedCalendarEventsOverlappingWindow(ws, we, 25_000, 400)
])
const salvage = dedupeCalendarEvents([...idb, ...arc])
const fromSession = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...salvage, ...fromSession]))
} catch {
setRawEvents([])
}
}
} finally {
if (!cancelled) setLoading(false)
}
@ -269,11 +290,7 @@ export default function SidebarCalendarWeekWidget() { @@ -269,11 +290,7 @@ export default function SidebarCalendarWeekWidget() {
<Loader2 className="size-4 animate-spin" aria-hidden />
<span className="text-[11px]">{t('sidebarCalendarLoading')}</span>
</div>
) : !relayUrls.length ? (
<p className="px-1 py-2 text-center text-[11px] text-muted-foreground">{t('sidebarCalendarNoRelays')}</p>
) : sortedForWeek.length === 0 ? (
<p className="px-1 py-2 text-center text-[11px] text-muted-foreground">{t('sidebarCalendarEmptyWeek')}</p>
) : (
) : 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)
@ -308,6 +325,10 @@ export default function SidebarCalendarWeekWidget() { @@ -308,6 +325,10 @@ export default function SidebarCalendarWeekWidget() {
)
})}
</ul>
) : !relayUrls.length ? (
<p className="px-1 py-2 text-center text-[11px] text-muted-foreground">{t('sidebarCalendarNoRelays')}</p>
) : (
<p className="px-1 py-2 text-center text-[11px] text-muted-foreground">{t('sidebarCalendarEmptyWeek')}</p>
)}
</div>
)

42
src/constants.ts

@ -106,9 +106,9 @@ export const MAX_CONCURRENT_SUBS_PER_RELAY = 7 @@ -106,9 +106,9 @@ export const MAX_CONCURRENT_SUBS_PER_RELAY = 7
* How many timeline shards may open relay subscriptions at once. Each shard sends one REQ per relay
* in its list; with 6 shards in parallel a popular relay can see 6+ SUBs from this app alone, and a
* second feed wave (remount / strict mode) pushes past strict relay caps (e.g. nostr.sovbit.host 10).
* 3 is a modest bump for faster multi-shard home loads; lower to 2 if a relay complains about SUB count.
* 5 balances faster multi-shard home loads against per-relay SUB caps (see {@link MAX_CONCURRENT_SUBS_PER_RELAY}).
*/
export const TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY = 3
export const TIMELINE_SHARD_SUBSCRIBE_CONCURRENCY = 5
/** Max relays to publish each event to (outboxes first, then targets' inboxes, then extras). */
export const MAX_PUBLISH_RELAYS = 20
@ -666,8 +666,10 @@ export function isSocialKindBlockedKind(kind: number): boolean { @@ -666,8 +666,10 @@ export function isSocialKindBlockedKind(kind: number): boolean {
/**
* True when a filter should avoid relays that do not carry social-note surface.
*
* Important: kindless lookup filters (e.g. `ids`, `authors + #d`) are often used for
* publication / replaceable resolution and must keep relays like thecitadel in scope.
* Important: kindless lookup filters (e.g. `ids`, `authors + #d`, **`#p` mentions**, `#e` threads)
* are scoped and must keep aggregators / read mirrors in scope. The notifications faux spell uses
* `#p` only (kinds applied client-side); misclassifying it as a broad social firehose stripped every
* relay and skipped real REQ batches (`groupedRequests.length === 0`).
*/
export function relayFilterIncludesSocialKindBlockedKind(filter: Filter): boolean {
const k = filter.kinds
@ -676,8 +678,38 @@ export function relayFilterIncludesSocialKindBlockedKind(filter: Filter): boolea @@ -676,8 +678,38 @@ export function relayFilterIncludesSocialKindBlockedKind(filter: Filter): boolea
const dTags = Array.isArray((filter as Record<string, unknown>)['#d'])
? ((filter as Record<string, unknown>)['#d'] as unknown[]).length
: 0
const pTags = Array.isArray((filter as Record<string, unknown>)['#p'])
? ((filter as Record<string, unknown>)['#p'] as unknown[]).length
: 0
const eTags = Array.isArray((filter as Record<string, unknown>)['#e'])
? ((filter as Record<string, unknown>)['#e'] as unknown[]).length
: 0
const eUpperTags = Array.isArray((filter as Record<string, unknown>)['#E'])
? ((filter as Record<string, unknown>)['#E'] as unknown[]).length
: 0
const aTags = Array.isArray((filter as Record<string, unknown>)['#a'])
? ((filter as Record<string, unknown>)['#a'] as unknown[]).length
: 0
const tTags = Array.isArray((filter as Record<string, unknown>)['#t'])
? ((filter as Record<string, unknown>)['#t'] as unknown[]).length
: 0
const authors = Array.isArray(filter.authors) ? filter.authors.length : 0
const search = filter.search
const hasSearch = typeof search === 'string' && search.trim().length > 0
// Scoped lookups are not "broad social feed" queries.
if (ids > 0 || dTags > 0) return false
if (
ids > 0 ||
dTags > 0 ||
pTags > 0 ||
eTags > 0 ||
eUpperTags > 0 ||
aTags > 0 ||
tTags > 0 ||
authors > 0 ||
hasSearch
) {
return false
}
return true
}
const arr = Array.isArray(k) ? k : [k]

9
src/hooks/useFetchEvent.tsx

@ -44,7 +44,6 @@ export function useFetchEvent( @@ -44,7 +44,6 @@ export function useFetchEvent(
const initialMatches =
initialEvent &&
(initialEvent.id === eventId ||
eventId.includes(initialEvent.id) ||
(() => {
try {
return getNoteBech32Id(initialEvent) === eventId
@ -76,6 +75,11 @@ export function useFetchEvent( @@ -76,6 +75,11 @@ export function useFetchEvent(
}
}
// New target without a synchronous hit: drop the previous note immediately so the panel does not
// keep showing the last-opened article (or fail to show a skeleton) while the new fetch runs or
// after it returns empty.
setEvent(undefined)
setError(null)
setIsFetching(true)
const fetchEvent = async () => {
@ -90,10 +94,13 @@ export function useFetchEvent( @@ -90,10 +94,13 @@ export function useFetchEvent(
if (fetchedEvent && !isEventDeleted(fetchedEvent)) {
setEvent(fetchedEvent)
addReplies([fetchedEvent])
} else {
setEvent(undefined)
}
} catch (error) {
if (!cancelled) {
setError(error as Error)
setEvent(undefined)
}
} finally {
if (!cancelled) {

7
src/hooks/useNoteStatsById.tsx

@ -3,7 +3,8 @@ import { useSyncExternalStore } from 'react' @@ -3,7 +3,8 @@ import { useSyncExternalStore } from 'react'
export function useNoteStatsById(noteId: string) {
return useSyncExternalStore(
(cb) => noteStats.subscribeNoteStats(noteId, cb),
() => noteStats.getNoteStats(noteId)
)
(onStoreChange) => noteStats.subscribeNoteStats(noteId, onStoreChange),
() => noteStats.getNoteStatsExternalSnapshot(noteId),
() => noteStats.getNoteStatsExternalSnapshot(noteId)
).stats
}

43
src/hooks/useProfileTimeline.tsx

@ -155,6 +155,9 @@ export function useProfileTimeline({ @@ -155,6 +155,9 @@ export function useProfileTimeline({
const cachedEntry = useMemo(() => memoryTimelineByKey.get(cacheKey), [cacheKey])
const [events, setEvents] = useState<Event[]>(cachedEntry?.events ?? [])
const [isLoading, setIsLoading] = useState(!cachedEntry)
/** Last painted rows — re-seed merge pool after `refresh()` clears memory so relay hiccups do not wipe the list. */
const latestEventsRef = useRef<Event[]>(events)
latestEventsRef.current = events
const [refreshToken, setRefreshToken] = useState(0)
const subscriptionRef = useRef<() => void>(() => {})
@ -212,7 +215,25 @@ export function useProfileTimeline({ @@ -212,7 +215,25 @@ export function useProfileTimeline({
setIsLoading(false)
mem.events.forEach((e) => pool.set(e.id, e))
} else {
setIsLoading(!mem)
/**
* Stale memory: keep showing last rows while revalidating (SWR). Previously we set `isLoading` false
* whenever `mem` existed (`!mem` is false), which hid the refresh banner and skipped priming the pool
* relay failures then left the UI frozen on an empty pool with no new merge.
*/
if (mem?.events?.length) {
mem.events.forEach((e) => pool.set(e.id, e))
setEvents(mem.events)
} else {
try {
const pk = normalizeHexPubkey(pubkey)
for (const e of latestEventsRef.current) {
if (normalizeHexPubkey(e.pubkey) === pk) pool.set(e.id, e)
}
} catch {
/* ignore malformed pubkeys */
}
}
setIsLoading(true)
}
const hasCalendarKinds = kinds.some((k) => CALENDAR_EVENT_KINDS.includes(k))
@ -231,16 +252,22 @@ export function useProfileTimeline({ @@ -231,16 +252,22 @@ export function useProfileTimeline({
if (idbDocKinds.length > 0) {
try {
const pkNorm = normalizeHexPubkey(pubkey)
const fromIdb = await indexedDb.getCachedPublicationStoreEventsForProfileAuthor(
pkNorm,
idbDocKinds,
limit
)
const [fromPubStore, fromArchive] = await Promise.all([
indexedDb.getCachedPublicationStoreEventsForProfileAuthor(pkNorm, idbDocKinds, limit),
indexedDb.scanEventArchiveByAuthorPubkey(pkNorm, {
kinds: idbDocKinds,
maxRowsScanned: 18_000,
maxMatches: limit
})
])
if (!cancelled) {
for (const e of fromIdb) {
for (const e of fromPubStore) {
pool.set(e.id, e)
}
for (const e of fromArchive) {
pool.set(e.id, e)
}
if (fromIdb.length) flushPool()
if (fromPubStore.length || fromArchive.length) flushPool()
}
} catch {
/* IDB optional */

1
src/i18n/locales/cs.ts

@ -774,6 +774,7 @@ export default { @@ -774,6 +774,7 @@ export default {
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",

1
src/i18n/locales/de.ts

@ -794,6 +794,7 @@ export default { @@ -794,6 +794,7 @@ export default {
heatMapRescan: "Erneut scannen",
heatMapOpenThread: "Thread öffnen",
heatMapBubbleStats: "{{posts}} Notes · {{people}} Personen · {{follows}} Folge-Accounts im Thread",
heatMapConnectorHint: "Verknüpfte Threads — «{{left}}» ↔ «{{right}}»",
"Please login to view thread heat map": "Bitte anmelden, um die Thread-Heatmap zu öffnen.",
Calendar: "Kalender",
"No subscribed interests yet.": "Noch keine Interessen abonniert. Themen in den Einstellungen hinzufügen, um sie hier zu sehen.",

1
src/i18n/locales/en.ts

@ -798,6 +798,7 @@ export default { @@ -798,6 +798,7 @@ export default {
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} people · {{follows}} follows in thread",
heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",

1
src/i18n/locales/es.ts

@ -774,6 +774,7 @@ export default { @@ -774,6 +774,7 @@ export default {
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",

1
src/i18n/locales/fr.ts

@ -774,6 +774,7 @@ export default { @@ -774,6 +774,7 @@ export default {
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",

1
src/i18n/locales/nl.ts

@ -774,6 +774,7 @@ export default { @@ -774,6 +774,7 @@ export default {
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",

1
src/i18n/locales/pl.ts

@ -774,6 +774,7 @@ export default { @@ -774,6 +774,7 @@ export default {
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",

1
src/i18n/locales/ru.ts

@ -774,6 +774,7 @@ export default { @@ -774,6 +774,7 @@ export default {
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",

1
src/i18n/locales/tr.ts

@ -774,6 +774,7 @@ export default { @@ -774,6 +774,7 @@ export default {
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",

1
src/i18n/locales/zh.ts

@ -774,6 +774,7 @@ export default { @@ -774,6 +774,7 @@ export default {
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",

45
src/pages/primary/CalendarPrimaryPage.tsx

@ -179,23 +179,33 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct @@ -179,23 +179,33 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
try {
const { rangeStartMs, rangeEndExclusiveMs } = paddedMonthRange
const fromIdb = await indexedDb.getCalendarEventsForOccurrenceWindow(
rangeStartMs,
rangeEndExclusiveMs,
MONTH_IDB_MAX_SCAN
)
const [fromIdb, fromArchive] = await Promise.all([
indexedDb.getCalendarEventsForOccurrenceWindow(
rangeStartMs,
rangeEndExclusiveMs,
MONTH_IDB_MAX_SCAN
),
indexedDb.getArchivedCalendarEventsOverlappingWindow(
rangeStartMs,
rangeEndExclusiveMs,
55_000,
2500
)
])
if (cancelled) return
const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive])
const fromSessionNow = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...fromIdb, ...fromSessionNow]))
setRawEvents(dedupeCalendarEvents([...localBaseline, ...fromSessionNow]))
setLoading(false)
if (!relayUrls.length) {
scheduleLateSessionMerge(fromIdb)
scheduleLateSessionMerge(localBaseline)
return
}
@ -258,7 +268,7 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct @@ -258,7 +268,7 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...fromIdb]))
setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...localBaseline]))
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
@ -267,11 +277,26 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct @@ -267,11 +277,26 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later]))
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...localBaseline]))
}, 2500)
} catch {
if (!cancelled) {
setRawEvents([])
try {
const { rangeStartMs: rs, rangeEndExclusiveMs: re } = paddedMonthRange
const [idb, arc] = await Promise.all([
indexedDb.getCalendarEventsForOccurrenceWindow(rs, re, MONTH_IDB_MAX_SCAN),
indexedDb.getArchivedCalendarEventsOverlappingWindow(rs, re, 55_000, 2500)
])
const salvage = dedupeCalendarEvents([...idb, ...arc])
const fromSession = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...salvage, ...fromSession]))
} catch {
setRawEvents([])
}
setLoading(false)
}
}

106
src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx

@ -333,15 +333,37 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) @@ -333,15 +333,37 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
const graphAreaRef = useRef<HTMLDivElement>(null)
const bubbleRefs = useRef<Map<string, HTMLButtonElement>>(new Map())
const [lineSegs, setLineSegs] = useState<
Array<{ x1: number; y1: number; x2: number; y2: number }>
>([])
type ConnectorSeg = {
x1: number
y1: number
x2: number
y2: number
threadA: string
threadB: string
}
const [lineSegs, setLineSegs] = useState<ConnectorSeg[]>([])
const [hoveredConnector, setHoveredConnector] = useState<{ a: string; b: string } | null>(null)
const rowByRoot = useMemo(() => new Map(layoutRows.map((r) => [r.rootId, r])), [layoutRows])
const bindBubbleRef = useCallback((rootId: string) => (el: HTMLButtonElement | null) => {
if (el) bubbleRefs.current.set(rootId, el)
else bubbleRefs.current.delete(rootId)
}, [])
const connectorTitle = useCallback(
(threadA: string, threadB: string) => {
const clip = (s: string, max: number) => {
const x = s.replace(/\s+/g, ' ').trim()
return x.length <= max ? x : `${x.slice(0, max - 1)}`
}
const left = clip(rowByRoot.get(threadA)?.snippet ?? threadA, 96)
const right = clip(rowByRoot.get(threadB)?.snippet ?? threadB, 96)
return t('heatMapConnectorHint', { left, right })
},
[rowByRoot, t]
)
const recomputeConnectorLines = useCallback(() => {
const host = graphAreaRef.current
if (!host || layoutRows.length === 0) {
@ -355,11 +377,13 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) @@ -355,11 +377,13 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
const r = el.getBoundingClientRect()
return { x: r.left - br.left + r.width / 2, y: r.top - br.top + r.height / 2 }
}
const segs: Array<{ x1: number; y1: number; x2: number; y2: number }> = []
const segs: ConnectorSeg[] = []
for (const { a, b } of edges) {
const ca = centerOf(a)
const cb = centerOf(b)
if (ca && cb) segs.push({ x1: ca.x, y1: ca.y, x2: cb.x, y2: cb.y })
if (ca && cb) {
segs.push({ x1: ca.x, y1: ca.y, x2: cb.x, y2: cb.y, threadA: a, threadB: b })
}
}
setLineSegs(segs)
}, [layoutRows, edges])
@ -439,24 +463,56 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) @@ -439,24 +463,56 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-4">
<div ref={graphAreaRef} className="relative w-full min-h-[min(40vh,420px)] pt-2">
<svg
className="pointer-events-none absolute inset-0 z-0 h-full w-full overflow-visible text-primary"
aria-hidden
className="absolute inset-0 z-[1] h-full w-full overflow-visible text-primary"
role="presentation"
>
{lineSegs.map((s, i) => (
<line
key={`${s.x1}-${s.y1}-${s.x2}-${s.y2}-${i}`}
x1={s.x1}
y1={s.y1}
x2={s.x2}
y2={s.y2}
stroke="currentColor"
strokeOpacity={0.38}
strokeWidth={1.75}
vectorEffect="non-scaling-stroke"
/>
))}
<g className="pointer-events-none">
{lineSegs.map((s, i) => {
const hi =
hoveredConnector != null &&
((hoveredConnector.a === s.threadA && hoveredConnector.b === s.threadB) ||
(hoveredConnector.a === s.threadB && hoveredConnector.b === s.threadA))
return (
<line
key={`vis-${s.threadA}-${s.threadB}-${i}`}
x1={s.x1}
y1={s.y1}
x2={s.x2}
y2={s.y2}
stroke="currentColor"
strokeOpacity={hi ? 0.82 : 0.38}
strokeWidth={hi ? 2.35 : 1.75}
vectorEffect="non-scaling-stroke"
/>
)
})}
</g>
<g>
{lineSegs.map((s, i) => {
const title = connectorTitle(s.threadA, s.threadB)
return (
<g key={`hit-${s.threadA}-${s.threadB}-${i}`}>
<title>{title}</title>
<line
x1={s.x1}
y1={s.y1}
x2={s.x2}
y2={s.y2}
stroke="transparent"
strokeWidth={14}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
className="cursor-help"
style={{ pointerEvents: 'stroke' }}
onMouseEnter={() => setHoveredConnector({ a: s.threadA, b: s.threadB })}
onMouseLeave={() => setHoveredConnector(null)}
/>
</g>
)
})}
</g>
</svg>
<div className="relative z-10 flex flex-wrap content-start items-start justify-center gap-4">
<div className="pointer-events-none relative z-10 flex flex-wrap content-start items-start justify-center gap-4">
{layoutRows.map((row) => {
const intensity = Math.min(1, row.heat / maxHeat)
const size = Math.min(200, Math.max(76, 52 + Math.sqrt(row.heat) * 9))
@ -466,6 +522,9 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) @@ -466,6 +522,9 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
follows: row.followAuthorsInThread
})
const ariaLabel = [row.snippet, statsLine, t('heatMapOpenThread')].filter(Boolean).join('. ')
const connectorHit =
hoveredConnector != null &&
(row.rootId === hoveredConnector.a || row.rootId === hoveredConnector.b)
return (
<HoverCard key={row.rootId} openDelay={180} closeDelay={80}>
<HoverCardTrigger asChild>
@ -473,10 +532,11 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) @@ -473,10 +532,11 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
ref={bindBubbleRef(row.rootId)}
type="button"
className={cn(
'group relative shrink-0 rounded-full border shadow-sm transition-transform',
'pointer-events-auto group relative shrink-0 rounded-full border shadow-sm transition-transform',
'flex items-center justify-center',
'hover:z-10 hover:scale-[1.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'border-border/70 bg-card/90 backdrop-blur-sm'
'border-border/70 bg-card/90 backdrop-blur-sm',
connectorHit && 'z-[9] scale-[1.03] ring-2 ring-primary/70 ring-offset-2 ring-offset-background'
)}
style={{
width: size,

55
src/services/client.service.ts

@ -180,6 +180,8 @@ export const JUMBLE_SESSION_RELAY_STRIKES_CHANGED = 'jumble:session-relay-strike @@ -180,6 +180,8 @@ export const JUMBLE_SESSION_RELAY_STRIKES_CHANGED = 'jumble:session-relay-strike
/** Live timeline REQ: EOSE caps “connected but silent” relays. */
const SUBSCRIBE_RELAY_EOSE_TIMEOUT_MS = 4800
/** Coalesce pre-EOSE timeline snapshots; `setTimeout` so updates still run when rAF is throttled (background tab). */
const TIMELINE_STREAMING_COALESCE_MS = 24
/**
* After initial timeline EOSE (incl. grace), events with `created_at` older than this many seconds
@ -1542,6 +1544,17 @@ class ClientService extends EventTarget { @@ -1542,6 +1544,17 @@ class ClientService extends EventTarget {
})
let hasResolved = false
let earlyGraceTimer: ReturnType<typeof setTimeout> | null = null
/**
* Live timelines listen for {@link emitNewEvent} on the first relay ACK not after N/3 successes.
* Waiting for a third of many relays meant the profile/home feed stayed stale until a subscription
* picked the note up from the network (minutes later if few relays accepted the publish).
*/
let newEventLiveFanoutEmitted = false
const maybeEmitNewEventForLiveFeeds = () => {
if (newEventLiveFanoutEmitted || successCount < 1) return
newEventLiveFanoutEmitted = true
client.emitNewEvent(event)
}
const globalTimeout = setTimeout(() => {
if (hasResolved) {
@ -1574,6 +1587,7 @@ class ClientService extends EventTarget { @@ -1574,6 +1587,7 @@ class ClientService extends EventTarget {
earlyGraceTimer = null
}
hasResolved = true
maybeEmitNewEventForLiveFeeds()
logger.debug('[PublishEvent] Resolving due to timeout', {
success: successCount >= uniqueRelayUrls.length / 3,
successCount,
@ -1782,11 +1796,7 @@ class ClientService extends EventTarget { @@ -1782,11 +1796,7 @@ class ClientService extends EventTarget {
successCount
})
// If one third of the relays have accepted the event, consider it a success
const isSuccess = successCount >= uniqueRelayUrls.length / 3
if (isSuccess) {
this.emitNewEvent(event)
}
maybeEmitNewEventForLiveFeeds()
if (currentFinished >= uniqueRelayUrls.length && !hasResolved) {
if (earlyGraceTimer != null) {
clearTimeout(earlyGraceTimer)
@ -2674,13 +2684,20 @@ class ClientService extends EventTarget { @@ -2674,13 +2684,20 @@ class ClientService extends EventTarget {
/**
* Stream matching events to the UI immediately. Initial completion is either aggregate `oneose` from all
* relays, or {@link firstRelayResultGraceMs} after the first event (whichever comes first).
* While still before EOSE, coalesce bursts onto one rAF so we do not sort the full buffer on every microtask.
* While still before EOSE, coalesce bursts with a short timeout (not rAF) so feeds still advance when the
* tab is in the background browsers throttle rAF heavily there, which looked like a frozen timeline.
*/
let streamFlushRafId: number | null = null
let streamFlushDelayId: ReturnType<typeof setTimeout> | null = null
const clearStreamFlushDelay = () => {
if (streamFlushDelayId != null) {
clearTimeout(streamFlushDelayId)
streamFlushDelayId = null
}
}
const flushStreamingSnapshot = () => {
if (eosedAt) return
const emit = () => {
streamFlushRafId = null
streamFlushDelayId = null
if (eosedAt) return
if (needSort) {
const sorted = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
@ -2690,15 +2707,12 @@ class ClientService extends EventTarget { @@ -2690,15 +2707,12 @@ class ClientService extends EventTarget {
}
}
if (events.length <= 1) {
if (streamFlushRafId != null) {
cancelAnimationFrame(streamFlushRafId)
streamFlushRafId = null
}
clearStreamFlushDelay()
emit()
return
}
if (streamFlushRafId == null) {
streamFlushRafId = requestAnimationFrame(emit)
if (streamFlushDelayId == null) {
streamFlushDelayId = setTimeout(emit, TIMELINE_STREAMING_COALESCE_MS)
}
}
@ -2786,8 +2800,7 @@ class ClientService extends EventTarget { @@ -2786,8 +2800,7 @@ class ClientService extends EventTarget {
}
idx++
}
if (idx >= timeline.refs.length) return
// idx === refs.length → strictly older than tail; splice appends (previous early-return dropped these).
timeline.refs.splice(idx, 0, [evt.id, evt.created_at])
that.scheduleTimelinePersist(key)
}
@ -2834,10 +2847,7 @@ class ClientService extends EventTarget { @@ -2834,10 +2847,7 @@ class ClientService extends EventTarget {
if (eosedAt != null) return
clearFirstResultGraceTimer()
if (streamFlushRafId != null) {
cancelAnimationFrame(streamFlushRafId)
streamFlushRafId = null
}
clearStreamFlushDelay()
eosedAt = dayjs().unix()
@ -2924,10 +2934,7 @@ class ClientService extends EventTarget { @@ -2924,10 +2934,7 @@ class ClientService extends EventTarget {
timelineKey: key,
closer: () => {
clearFirstResultGraceTimer()
if (streamFlushRafId != null) {
cancelAnimationFrame(streamFlushRafId)
streamFlushRafId = null
}
clearStreamFlushDelay()
clearHttpTimelinePoll()
onEvents = () => {}
onNew = () => {}

54
src/services/indexed-db.service.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { ExtendedKind } from '@/constants'
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import {
publicationCoordinateLookupKeys,
splitPublicationCoordinate
@ -21,6 +21,7 @@ import { @@ -21,6 +21,7 @@ import {
} from '@/lib/event'
import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import logger from '@/lib/logger'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
/** Hot archive row in {@link StoreNames.EVENT_ARCHIVE}. */
export type TArchivedEventRow = {
@ -3037,6 +3038,57 @@ class IndexedDbService { @@ -3037,6 +3038,57 @@ class IndexedDbService {
})
}
/**
* Hot {@link StoreNames.EVENT_ARCHIVE} rows for NIP-52 calendar notes whose occurrence overlaps the range.
* Calendar kinds are no longer archived on ingest, but older builds could still have 31922/31923 in the archive.
*/
async getArchivedCalendarEventsOverlappingWindow(
rangeStartMs: number,
rangeEndExclusiveMs: number,
maxRowsScanned = 30_000,
maxMatches = 800
): Promise<Event[]> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return []
const kindSet = new Set<number>(CALENDAR_EVENT_KINDS as readonly number[])
const maxRows = Math.min(Math.max(maxRowsScanned, 1), 50_000)
const maxOut = Math.min(Math.max(maxMatches, 1), 3000)
return new Promise((resolve, reject) => {
const out: Event[] = []
let scanned = 0
const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly')
const store = tx.objectStore(StoreNames.EVENT_ARCHIVE)
const req = store.openCursor()
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor || scanned >= maxRows || out.length >= maxOut) {
tx.commit()
resolve(out)
return
}
scanned += 1
const row = cursor.value as TArchivedEventRow
const ev = row?.value
if (
ev &&
isLikelyCachedNostrEvent(ev) &&
kindSet.has(ev.kind) &&
!shouldDropEventOnIngest(ev) &&
calendarOccurrenceOverlapsRange(ev, rangeStartMs, rangeEndExclusiveMs)
) {
out.push(ev)
}
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
}
async deleteArchivedEvent(eventId: string): Promise<void> {
const id = eventId.toLowerCase()
await this.initPromise

44
src/services/note-stats.service.ts

@ -56,6 +56,13 @@ export type TNoteStats = { @@ -56,6 +56,13 @@ export type TNoteStats = {
class NoteStatsService {
static instance: NoteStatsService
private noteStatsMap: Map<string, Partial<TNoteStats>> = new Map()
/** Bumped whenever {@link notifyNoteStats} runs so {@link useNoteStatsById} can rely on `Object.is` (map entries alone are not always a new reference). */
private noteStatsUiEpochByKey = new Map<string, number>()
/** Last `{ stats, epoch }` object per note for {@link getNoteStatsExternalSnapshot} — must be stable across renders. */
private noteStatsExternalSnapCache = new Map<
string,
{ stats: Partial<TNoteStats> | undefined; epoch: number; out: { stats: Partial<TNoteStats> | undefined; epoch: number } }
>()
private noteStatsSubscribers = new Map<string, Set<() => void>>()
/**
* Batched, microtask-deferred subscriber wakes. Without this, {@link updateNoteStatsByEvents} called from
@ -276,7 +283,7 @@ class NoteStatsService { @@ -276,7 +283,7 @@ class NoteStatsService {
return
}
logger.info('[NoteStats] processBatch: running', {
logger.debug('[NoteStats] processBatch: running', {
pendingForeground: this.pendingForeground.size,
pendingBackground: this.pendingEvents.size
})
@ -288,7 +295,7 @@ class NoteStatsService { @@ -288,7 +295,7 @@ class NoteStatsService {
try {
const eventsToProcess = this.takeNextStatsSlice()
logger.info('[NoteStats] processBatch slice', {
logger.debug('[NoteStats] processBatch slice', {
count: eventsToProcess.length,
ids: eventsToProcess.map((id) => `${id.slice(0, 12)}`),
remainingForeground: this.pendingForeground.size,
@ -330,7 +337,7 @@ class NoteStatsService { @@ -330,7 +337,7 @@ class NoteStatsService {
updatedAt: dayjs().unix()
})
const subscriberCount = this.noteStatsSubscribers.get(statsKey)?.size ?? 0
logger.info('[NoteStats] processSingleEvent: snapshot published', {
logger.debug('[NoteStats] processSingleEvent: snapshot published', {
statsKey: `${statsKey.slice(0, 12)}`,
reason,
subscriberCount
@ -637,6 +644,16 @@ class NoteStatsService { @@ -637,6 +644,16 @@ class NoteStatsService {
this.noteStatsSubscribers.set(key, set)
}
set.add(callback)
// Stats may have been merged while this note was off-screen (subscriberCount was 0 on publish).
// One microtask ping lets `useSyncExternalStore` re-read after mount so counts are not stuck blank.
queueMicrotask(() => {
if (!set?.has(callback)) return
try {
callback()
} catch (e) {
logger.warn('[NoteStatsService] subscribeNoteStats ping failed', { err: e })
}
})
return () => {
set?.delete(callback)
if (set?.size === 0) this.noteStatsSubscribers.delete(key)
@ -662,6 +679,7 @@ class NoteStatsService { @@ -662,6 +679,7 @@ class NoteStatsService {
private notifyNoteStats(noteId: string) {
const key = this.statsKey(noteId)
this.noteStatsUiEpochByKey.set(key, (this.noteStatsUiEpochByKey.get(key) ?? 0) + 1)
this.subscriberNotifyKeys.add(key)
if (this.subscriberNotifyMicrotaskQueued) return
this.subscriberNotifyMicrotaskQueued = true
@ -674,6 +692,26 @@ class NoteStatsService { @@ -674,6 +692,26 @@ class NoteStatsService {
return this.noteStatsMap.get(this.statsKey(id))
}
/**
* Snapshot for {@link useNoteStatsById} / `useSyncExternalStore`: `epoch` changes on every stats notify so React
* always re-renders when counts update (avoids stale UI when the map entry reference is reused or updates race mount).
*/
getNoteStatsExternalSnapshot(noteId: string): {
stats: Partial<TNoteStats> | undefined
epoch: number
} {
const key = this.statsKey(noteId)
const stats = this.noteStatsMap.get(key)
const epoch = this.noteStatsUiEpochByKey.get(key) ?? 0
const prev = this.noteStatsExternalSnapCache.get(key)
if (prev && prev.stats === stats && prev.epoch === epoch) {
return prev.out
}
const out = { stats, epoch }
this.noteStatsExternalSnapCache.set(key, { stats, epoch, out })
return out
}
addZap(
pubkey: string,
eventId: string,

Loading…
Cancel
Save