Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
c4288cd039
  1. 43
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  2. 31
      src/lib/calendar-event.ts
  3. 53
      src/pages/primary/CalendarPrimaryPage.tsx
  4. 131
      src/pages/primary/SpellsPage/useSpellsPageFeed.ts
  5. 72
      src/providers/FavoriteRelaysActivityProvider.tsx
  6. 92
      src/services/client-replaceable-events.service.ts
  7. 6
      src/services/client.service.ts
  8. 12
      vite.config.ts

43
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import {
calendarOccurrenceOverlapsRange,
dedupeCalendarEventsPreferringOccurrenceRange,
formatCalendarSidebarRow,
formatSidebarWeekLabel,
getCalendarEventMeta,
@ -38,16 +39,6 @@ const SIDEBAR_CALENDAR_MAX_RELAYS = 24 @@ -38,16 +39,6 @@ const SIDEBAR_CALENDAR_MAX_RELAYS = 24
/** Merge session cache so events already loaded in feeds (but missed by this REQ) still appear. */
const SESSION_CALENDAR_MERGE_CAP = 5000
function dedupeCalendarEvents(events: Event[]): Event[] {
const map = new Map<string, Event>()
for (const e of events) {
const k = replaceableEventDedupeKey(e)
const prev = map.get(k)
if (!prev || e.created_at > prev.created_at) map.set(k, e)
}
return [...map.values()]
}
export default function SidebarCalendarWeekWidget() {
const { t } = useTranslation()
const { relayList, pubkey } = useNostr()
@ -117,13 +108,21 @@ export default function SidebarCalendarWeekWidget() { @@ -117,13 +108,21 @@ export default function SidebarCalendarWeekWidget() {
indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400)
])
const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive])
const localBaseline = dedupeCalendarEventsPreferringOccurrenceRange(
[...fromIdb, ...fromArchive],
weekStartMs,
weekEndExclusiveMs
)
const sessionSnap = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
const mergedLocal = dedupeCalendarEvents([...localBaseline, ...sessionSnap])
const mergedLocal = dedupeCalendarEventsPreferringOccurrenceRange(
[...localBaseline, ...sessionSnap],
weekStartMs,
weekEndExclusiveMs
)
/** Always paint IDB + session first; a superseded effect must not skip this (relayKey churn would leave the list blank). */
if (!cancelled) {
setRawEvents(mergedLocal)
@ -136,12 +135,15 @@ export default function SidebarCalendarWeekWidget() { @@ -136,12 +135,15 @@ export default function SidebarCalendarWeekWidget() {
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) => dedupeCalendarEvents([...prev, ...later, ...localBaseline]))
setRawEvents((prev) =>
dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...localBaseline], ws, we)
)
}, 2500)
return
}
@ -207,18 +209,25 @@ export default function SidebarCalendarWeekWidget() { @@ -207,18 +209,25 @@ export default function SidebarCalendarWeekWidget() {
)
if (!cancelled) {
setRawEvents(
dedupeCalendarEvents([...localBaseline, ...fromSessionAfterNet, ...batch, ...fromFollowing])
dedupeCalendarEventsPreferringOccurrenceRange(
[...localBaseline, ...fromSessionAfterNet, ...batch, ...fromFollowing],
weekStartMs,
weekEndExclusiveMs
)
)
}
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) => dedupeCalendarEvents([...prev, ...later, ...localBaseline]))
setRawEvents((prev) =>
dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...localBaseline], ws, we)
)
}, 2500)
} catch {
if (!cancelled) {
@ -228,13 +237,13 @@ export default function SidebarCalendarWeekWidget() { @@ -228,13 +237,13 @@ export default function SidebarCalendarWeekWidget() {
indexedDb.getCalendarEventsForOccurrenceWindow(ws, we),
indexedDb.getArchivedCalendarEventsOverlappingWindow(ws, we, 25_000, 400)
])
const salvage = dedupeCalendarEvents([...idb, ...arc])
const salvage = dedupeCalendarEventsPreferringOccurrenceRange([...idb, ...arc], ws, we)
const fromSession = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...salvage, ...fromSession]))
setRawEvents(dedupeCalendarEventsPreferringOccurrenceRange([...salvage, ...fromSession], ws, we))
} catch {
setRawEvents([])
}

31
src/lib/calendar-event.ts

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { ExtendedKind, isNip52CalendarCardKind } from '@/constants'
import { replaceableEventDedupeKey } from '@/lib/event'
import { generateBech32IdFromATag, tagNameEquals } from '@/lib/tag'
import { Event } from 'nostr-tools'
@ -443,6 +444,36 @@ export function calendarOccurrenceOverlapsRange( @@ -443,6 +444,36 @@ export function calendarOccurrenceOverlapsRange(
return w.startMs < rangeEndExclusiveMs && w.endExclusiveMs > rangeStartMs
}
/**
* Deduplicate by replaceable coordinate; when several revisions exist, prefer one whose occurrence **overlaps**
* `[rangeStartMs, rangeEndExclusiveMs)` with a parseable window, then newest `created_at`. Avoids global calendar
* REQs replacing a good local row with a newer revision that does not apply to the visible range.
*/
export function dedupeCalendarEventsPreferringOccurrenceRange(
events: Event[],
rangeStartMs: number,
rangeEndExclusiveMs: number
): Event[] {
const byKey = new Map<string, Event[]>()
for (const e of events) {
const k = replaceableEventDedupeKey(e)
const list = byKey.get(k)
if (list) list.push(e)
else byKey.set(k, [e])
}
const out: Event[] = []
for (const variants of byKey.values()) {
const inRange = variants.filter(
(e) =>
getCalendarOccurrenceWindowMs(e) != null &&
calendarOccurrenceOverlapsRange(e, rangeStartMs, rangeEndExclusiveMs)
)
const pool = inRange.length > 0 ? inRange : variants
out.push(pool.reduce((best, e) => (e.created_at > best.created_at ? e : best)))
}
return out
}
/** Monday 00:00 local through the following Monday 00:00 (exclusive), shifted by `weekOffset` weeks from the anchor week. */
/** Local midnight on the 1st through midnight on the 1st of the following month (exclusive). */
export function getLocalMonthRangeMs(

53
src/pages/primary/CalendarPrimaryPage.tsx

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import {
calendarOccurrenceOverlapsRange,
dedupeCalendarEventsPreferringOccurrenceRange,
getCalendarEventMeta,
getLocalMondayWeekBounds,
getLocalMonthRangeMs
@ -52,16 +53,6 @@ export type CalendarPrimaryPageProps = { @@ -52,16 +53,6 @@ export type CalendarPrimaryPageProps = {
weekOffset?: number
}
function dedupeCalendarEvents(events: NostrEvent[]): NostrEvent[] {
const map = new Map<string, NostrEvent>()
for (const e of events) {
const k = replaceableEventDedupeKey(e)
const prev = map.get(k)
if (!prev || e.created_at > prev.created_at) map.set(k, e)
}
return [...map.values()]
}
function mondayFirstOffsetFromMonthStart(year: number, monthIndex: number): number {
const first = new Date(year, monthIndex, 1, 0, 0, 0, 0)
const dow = first.getDay()
@ -173,7 +164,13 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct @@ -173,7 +164,13 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...mergeWithIdb]))
setRawEvents((prev) =>
dedupeCalendarEventsPreferringOccurrenceRange(
[...prev, ...later, ...mergeWithIdb],
paddedMonthRange.rangeStartMs,
paddedMonthRange.rangeEndExclusiveMs
)
)
}, 2500)
}
@ -194,14 +191,24 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct @@ -194,14 +191,24 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
])
if (cancelled) return
const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive])
const localBaseline = dedupeCalendarEventsPreferringOccurrenceRange(
[...fromIdb, ...fromArchive],
rangeStartMs,
rangeEndExclusiveMs
)
const fromSessionNow = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...localBaseline, ...fromSessionNow]))
setRawEvents(
dedupeCalendarEventsPreferringOccurrenceRange(
[...localBaseline, ...fromSessionNow],
rangeStartMs,
rangeEndExclusiveMs
)
)
setLoading(false)
if (!relayUrls.length) {
@ -268,7 +275,13 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct @@ -268,7 +275,13 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...localBaseline]))
setRawEvents(
dedupeCalendarEventsPreferringOccurrenceRange(
[...batch, ...fromFollowing, ...fromSession, ...localBaseline],
rangeStartMs,
rangeEndExclusiveMs
)
)
lateMergeTimer = window.setTimeout(() => {
lateMergeTimer = null
if (cancelled) return
@ -277,7 +290,13 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct @@ -277,7 +290,13 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...localBaseline]))
setRawEvents((prev) =>
dedupeCalendarEventsPreferringOccurrenceRange(
[...prev, ...later, ...localBaseline],
rangeStartMs,
rangeEndExclusiveMs
)
)
}, 2500)
} catch {
if (!cancelled) {
@ -287,13 +306,13 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct @@ -287,13 +306,13 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
indexedDb.getCalendarEventsForOccurrenceWindow(rs, re, MONTH_IDB_MAX_SCAN),
indexedDb.getArchivedCalendarEventsOverlappingWindow(rs, re, 55_000, 2500)
])
const salvage = dedupeCalendarEvents([...idb, ...arc])
const salvage = dedupeCalendarEventsPreferringOccurrenceRange([...idb, ...arc], rs, re)
const fromSession = client.getSessionEventsMatchingSearch(
'',
SESSION_CALENDAR_MERGE_CAP,
[...CALENDAR_EVENT_KINDS]
)
setRawEvents(dedupeCalendarEvents([...salvage, ...fromSession]))
setRawEvents(dedupeCalendarEventsPreferringOccurrenceRange([...salvage, ...fromSession], rs, re))
} catch {
setRawEvents([])
}

131
src/pages/primary/SpellsPage/useSpellsPageFeed.ts

@ -40,6 +40,55 @@ import type { TFeedSubRequest } from '@/types' @@ -40,6 +40,55 @@ import type { TFeedSubRequest } from '@/types'
import { isFollowFeedFauxSpellId } from './fauxSpellConfig'
import storage from '@/services/local-storage.service'
/** `fetchReplaceableEvent(kind 3)` / relay-list hydration can hang; never block the Following spell on it. */
const FOLLOWING_FETCH_FOLLOWINGS_TIMEOUT_MS = 10_000
/** Per-shard relay-list batch has a UI budget; still cap so a wedged promise cannot blank the feed forever. */
const FOLLOWING_GENERATE_SUBREQ_TIMEOUT_MS = 16_000
const FOLLOWING_INBOX_SHARD_AUTHOR_CAP = 512
function racePromiseWithTimeout<T>(promise: Promise<T>, ms: number, onTimeout: () => T): Promise<T> {
return new Promise((resolve) => {
const t = window.setTimeout(() => resolve(onTimeout()), ms)
promise
.then((v) => {
window.clearTimeout(t)
resolve(v)
})
.catch(() => {
window.clearTimeout(t)
resolve(onTimeout())
})
})
}
function buildInboxShardFollowingSubRequests(args: {
authors: string[]
favoriteRelays: string[]
blockedRelays: string[]
relayList: { read: string[]; write: string[] } | null | undefined
augment: (raw: TFeedSubRequest[]) => TFeedSubRequest[]
}): TFeedSubRequest[] {
const { authors, favoriteRelays, blockedRelays, relayList, augment } = args
const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadRelaysWithHttp(relayList),
{ userWriteRelays: relayList?.write ?? [] }
)
if (!feedUrls.length) return []
const capped = authors.slice(0, FOLLOWING_INBOX_SHARD_AUTHOR_CAP)
return augment([
{
urls: feedUrls,
filter: {
authors: capped,
kinds: [...DEFAULT_FEED_SHOW_KINDS],
limit: FAUX_SPELL_EVENT_LIMIT
}
}
])
}
function useNoteListHideReplies() {
const [hideReplies, setHideReplies] = useState(() => storage.getNoteListMode() === 'posts')
@ -166,41 +215,55 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { @@ -166,41 +215,55 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
if (selectedFauxSpell === 'following') {
const fromTags = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
const provisionalAuthors = [...new Set([pubkey, ...fromTags])]
let provisionalOk = false
try {
const rawProv = await client.generateSubRequestsForPubkeys(provisionalAuthors, pubkey)
if (!cancelled) {
setFollowingSubRequests(augment(rawProv))
provisionalOk = true
}
} catch {
/* refined wave may still succeed */
}
let followings = fromTags
try {
followings = await client.fetchFollowings(pubkey)
} catch {
followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
const inboxFallbackArgs = {
favoriteRelays,
blockedRelays,
relayList,
augment
}
const [rawProv, followings] = await Promise.all([
racePromiseWithTimeout<TFeedSubRequest[]>(
client.generateSubRequestsForPubkeys(provisionalAuthors, pubkey) as Promise<TFeedSubRequest[]>,
FOLLOWING_GENERATE_SUBREQ_TIMEOUT_MS,
() => []
),
racePromiseWithTimeout(
client.fetchFollowings(pubkey).catch(() => fromTags),
FOLLOWING_FETCH_FOLLOWINGS_TIMEOUT_MS,
() => fromTags
)
])
const provisionalNext =
rawProv.length > 0
? augment(rawProv)
: buildInboxShardFollowingSubRequests({
authors: provisionalAuthors,
...inboxFallbackArgs
})
if (!cancelled) setFollowingSubRequests(provisionalNext)
const fullAuthors = [...new Set([pubkey, ...followings])]
const sameSet =
fullAuthors.length === provisionalAuthors.length &&
fullAuthors.every((p) => provisionalAuthors.includes(p)) &&
provisionalAuthors.every((p) => fullAuthors.includes(p))
if (sameSet) {
if (!provisionalOk && !cancelled) {
try {
const req = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey)
if (!cancelled) setFollowingSubRequests(augment(req))
} catch {
if (!cancelled) setFollowingSubRequests([])
}
}
return
}
const req = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey)
if (!cancelled) setFollowingSubRequests(augment(req))
const rawFull = await racePromiseWithTimeout<TFeedSubRequest[]>(
client.generateSubRequestsForPubkeys(fullAuthors, pubkey) as Promise<TFeedSubRequest[]>,
FOLLOWING_GENERATE_SUBREQ_TIMEOUT_MS,
() => []
)
const fullNext =
rawFull.length > 0
? augment(rawFull)
: buildInboxShardFollowingSubRequests({ authors: fullAuthors, ...inboxFallbackArgs })
if (!cancelled) setFollowingSubRequests(fullNext)
} else if (followSetD) {
const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === followSetD)
if (!ev) {
@ -209,8 +272,22 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { @@ -209,8 +272,22 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) {
}
const listed = pubkeysFromFollowSetEvent(ev)
const authorPubkeys = [pubkey, ...listed]
const req = await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey)
if (!cancelled) setFollowingSubRequests(augment(req))
const rawFs = await racePromiseWithTimeout<TFeedSubRequest[]>(
client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) as Promise<TFeedSubRequest[]>,
FOLLOWING_GENERATE_SUBREQ_TIMEOUT_MS,
() => []
)
const req =
rawFs.length > 0
? augment(rawFs)
: buildInboxShardFollowingSubRequests({
authors: authorPubkeys,
favoriteRelays,
blockedRelays,
relayList,
augment
})
if (!cancelled) setFollowingSubRequests(req)
} else {
if (!cancelled) setFollowingSubRequests([])
}

72
src/providers/FavoriteRelaysActivityProvider.tsx

@ -21,11 +21,14 @@ import { @@ -21,11 +21,14 @@ import {
} from './favorite-relays-activity-context'
const ACTIVE_WINDOW_SEC = 3600
/** Recent slice (seconds): newest notes dominate global REQ limits; a shorter window improves author diversity. */
const PULSE_RECENT_TAIL_SEC = 1200
/** Per-REQ event cap; two time slices run in parallel and merge (see {@link fetchRelayPulseNoteEvents}). */
const PULSE_REQ_LIMIT_RECENT = 900
const PULSE_REQ_LIMIT_EARLIER = 1400
const FETCH_RETRY_DELAY_MS = 2500
/** Wall-clock cadence while the tab is visible */
const POLL_INTERVAL_MS = 60 * 60 * 1000
/** Event cap for relay pulse query. This is event-count (not author-count): keep high enough for >120 active npubs. */
const REQ_LIMIT = 500
/** Keep relay pulse focused on note-like activity to avoid expensive all-kind signature verification bursts. */
const ACTIVE_PULSE_KINDS = [
kinds.ShortTextNote,
@ -39,6 +42,59 @@ const ACTIVE_PULSE_KINDS = [ @@ -39,6 +42,59 @@ const ACTIVE_PULSE_KINDS = [
ExtendedKind.GENERIC_REPOST
] as number[]
const PULSE_QUERY_OPTS = {
firstRelayResultGraceMs: false as const,
eoseTimeout: 1800,
globalTimeout: 14_000
}
function mergeRelayPulseEventsById(events: { id: string; pubkey: string; created_at: number }[]) {
const byId = new Map<string, (typeof events)[0]>()
for (const e of events) {
const id = e.id?.trim().toLowerCase()
if (!id || !/^[0-9a-f]{64}$/i.test(id)) continue
const prev = byId.get(id)
if (!prev || e.created_at > prev.created_at) byId.set(id, e)
}
return [...byId.values()]
}
/**
* One REQ with a high `limit` over a full hour mostly returns the newest notes, so a few threads can
* exhaust the cap and hide many active npubs. Two slices (recent tail + earlier in the same hour)
* merge by id, then we dedupe by pubkey for the widget.
*/
async function fetchRelayPulseNoteEvents(
urls: string[],
anchorSec: number
): Promise<{ pubkey: string; created_at: number; id: string }[]> {
const sinceFull = anchorSec - ACTIVE_WINDOW_SEC
const recentSince = anchorSec - PULSE_RECENT_TAIL_SEC
const kinds = [...ACTIVE_PULSE_KINDS]
const settled = await Promise.allSettled([
queryService.fetchEvents(
urls,
{ since: recentSince, limit: PULSE_REQ_LIMIT_RECENT, kinds },
PULSE_QUERY_OPTS
),
queryService.fetchEvents(
urls,
{
since: sinceFull,
until: recentSince,
limit: PULSE_REQ_LIMIT_EARLIER,
kinds
},
PULSE_QUERY_OPTS
)
])
const merged: { id: string; pubkey: string; created_at: number }[] = []
for (const r of settled) {
if (r.status === 'fulfilled') merged.push(...r.value)
}
return mergeRelayPulseEventsById(merged)
}
function aggregatePubkeysByRecency(events: { pubkey: string; created_at: number }[]): string[] {
const lastByPk = new Map<string, number>()
for (const e of events) {
@ -129,17 +185,9 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R @@ -129,17 +185,9 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R
return
}
setLoading(true)
const since = Math.floor(Date.now() / 1000) - ACTIVE_WINDOW_SEC
const anchorSec = Math.floor(Date.now() / 1000)
try {
const events = await queryService.fetchEvents(
urls,
{ since, limit: REQ_LIMIT, kinds: [...ACTIVE_PULSE_KINDS] },
{
firstRelayResultGraceMs: false,
eoseTimeout: 1800,
globalTimeout: 14_000
}
)
const events = await fetchRelayPulseNoteEvents(urls, anchorSec)
const now = Date.now()
const nextPubkeys = aggregatePubkeysByRecency(events)
const prev = orderedPubkeysRef.current

92
src/services/client-replaceable-events.service.ts

@ -13,8 +13,8 @@ import { @@ -13,8 +13,8 @@ import {
import { kinds, nip19 } from 'nostr-tools'
import type { Event as NEvent, Filter } from 'nostr-tools'
import DataLoader from 'dataloader'
import { normalizeHttpUrl, normalizeUrl } from '@/lib/url'
import { getProfileFromEvent } from '@/lib/event-metadata'
import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpUrl, normalizeUrl } from '@/lib/url'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { getPubkeysFromPTags, getServersFromServerTags } from '@/lib/tag'
import { TProfile } from '@/types'
@ -1167,6 +1167,9 @@ export class ReplaceableEventService { @@ -1167,6 +1167,9 @@ export class ReplaceableEventService {
* When relayUrls are provided (e.g. user write + search relays), queries those directly.
* Otherwise uses the default relay set (FAST_WRITE + PROFILE_FETCH + FAST_READ).
*/
/** Hard cap: {@link fetchReplaceableEvent} can otherwise wedge the DataLoader chain when relays never answer. */
private static readonly FETCH_FOLLOW_LIST_REPLACEABLE_TIMEOUT_MS = 14_000
async fetchFollowListEvent(pubkey: string, relayUrls?: string[]): Promise<NEvent | undefined> {
if (relayUrls && relayUrls.length > 0) {
const normalized = Array.from(
@ -1181,7 +1184,19 @@ export class ReplaceableEventService { @@ -1181,7 +1184,19 @@ export class ReplaceableEventService {
const latest = events.sort((a, b) => b.created_at - a.created_at)[0]
return latest
}
return await this.fetchReplaceableEvent(pubkey, kinds.Contacts)
const fromNetwork = await Promise.race([
this.fetchReplaceableEvent(pubkey, kinds.Contacts),
new Promise<undefined>((resolve) =>
setTimeout(() => resolve(undefined), ReplaceableEventService.FETCH_FOLLOW_LIST_REPLACEABLE_TIMEOUT_MS)
)
])
if (fromNetwork) return fromNetwork
try {
const fromIdb = await indexedDb.getReplaceableEvent(pubkey, kinds.Contacts)
return fromIdb ?? undefined
} catch {
return undefined
}
}
/**
@ -1278,10 +1293,16 @@ export class ReplaceableEventService { @@ -1278,10 +1293,16 @@ export class ReplaceableEventService {
if (!skipCache) {
const cached = this.followingFavoriteRelaysCache.get(pubkey)
if (cached) {
return cached
return cached.catch((err: unknown) => {
this.followingFavoriteRelaysCache.delete(pubkey)
throw err
})
}
}
const promise = this._fetchFollowingFavoriteRelays(pubkey)
const promise = this._fetchFollowingFavoriteRelays(pubkey).catch((err: unknown) => {
this.followingFavoriteRelaysCache.delete(pubkey)
throw err
})
this.followingFavoriteRelaysCache.set(pubkey, promise)
return promise
}
@ -1289,46 +1310,63 @@ export class ReplaceableEventService { @@ -1289,46 +1310,63 @@ export class ReplaceableEventService {
private async _fetchFollowingFavoriteRelays(pubkey: string): Promise<[string, string[]][]> {
const followings = await this.fetchFollowings(pubkey)
const followingsToProcess = followings.slice(0, 100)
const favoriteRelaysEvents = await this.fetchReplaceableEventsFromProfileFetchRelays(
followingsToProcess,
ExtendedKind.FAVORITE_RELAYS
)
const [favoriteRelaysEvents, relayListEvents] = await Promise.all([
this.fetchReplaceableEventsFromProfileFetchRelays(
followingsToProcess,
ExtendedKind.FAVORITE_RELAYS
),
this.fetchReplaceableEventsFromProfileFetchRelays(followingsToProcess, kinds.RelayList)
])
// Group by relay URL: Map<relayUrl, Set<pubkey>>
const relayToUsers = new Map<string, Set<string>>()
// favoriteRelaysEvents[i] corresponds to followingsToProcess[i]
for (let i = 0; i < followingsToProcess.length && i < favoriteRelaysEvents.length; i++) {
const event = favoriteRelaysEvents[i]
const addFollowingRelay = (followingPk: string, rawUrl: string) => {
const normalizedUrl =
(normalizeUrl(rawUrl) || normalizeAnyRelayUrl(rawUrl) || '').trim() || null
if (!normalizedUrl || !isWebsocketUrl(normalizedUrl)) return
if (!relayToUsers.has(normalizedUrl)) relayToUsers.set(normalizedUrl, new Set())
relayToUsers.get(normalizedUrl)!.add(followingPk)
}
for (let i = 0; i < followingsToProcess.length; i++) {
const followingPubkey = followingsToProcess[i]
if (event && followingPubkey) {
event.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'relay' && tagValue) {
const normalizedUrl = normalizeUrl(tagValue)
if (normalizedUrl) {
if (!relayToUsers.has(normalizedUrl)) {
relayToUsers.set(normalizedUrl, new Set())
}
relayToUsers.get(normalizedUrl)!.add(followingPubkey)
}
if (!followingPubkey) continue
const favEv = favoriteRelaysEvents[i]
if (favEv) {
favEv.tags.forEach(([tagName, tagValue]) => {
if (!tagValue) return
if (tagName === 'relay' || tagName === 'r') {
addFollowingRelay(followingPubkey, tagValue)
}
})
}
/** NIP-65 kind 10002 — most clients only publish this, not kind 10012 “favorite relays”. */
const nip65 = relayListEvents[i]
if (nip65) {
const rl = getRelayListFromEvent(nip65)
for (const { url } of rl.originalRelays) {
addFollowingRelay(followingPubkey, url)
}
}
}
// Convert to array format: [relayUrl, pubkeys[]]
const result: [string, string[]][] = []
for (const [relayUrl, pubkeys] of relayToUsers.entries()) {
result.push([relayUrl, Array.from(pubkeys)])
}
logger.debug('[ReplaceableEventService] fetchFollowingFavoriteRelays completed', {
followingsCount: followings.length,
processedCount: followingsToProcess.length,
eventsFound: favoriteRelaysEvents.filter(e => e !== undefined).length,
favoriteRelaysEventsFound: favoriteRelaysEvents.filter((e) => e !== undefined).length,
relayListEventsFound: relayListEvents.filter((e) => e !== undefined).length,
uniqueRelays: result.length,
totalUsers: result.reduce((sum, [, users]) => sum + users.length, 0)
})
return result
}
}

6
src/services/client.service.ts

@ -3102,7 +3102,11 @@ class ClientService extends EventTarget { @@ -3102,7 +3102,11 @@ class ClientService extends EventTarget {
relays = relaysAfterSocialKindBlockedStrip(wsOriginal, stripped)
}
relays = this.relayUrlsAfterStrikesOrRecover(relays)
const queryRelays = dedupeNormalizeRelayUrlsOrdered([...relays, ...httpRelayBases])
let queryRelays = dedupeNormalizeRelayUrlsOrdered([...relays, ...httpRelayBases])
/** If every candidate was session-striked / filtered away, still hit public read mirrors so REQ does not no-op. */
if (queryRelays.length === 0) {
queryRelays = dedupeNormalizeRelayUrlsOrdered([...FAST_READ_RELAY_URLS])
}
const events = await this.queryService.query(queryRelays, filter, onevent, {
eoseTimeout,
globalTimeout,

12
vite.config.ts

@ -152,7 +152,17 @@ export default defineConfig(({ mode }) => { @@ -152,7 +152,17 @@ export default defineConfig(({ mode }) => {
},
'/sites': {
target: 'http://127.0.0.1:8090',
changeOrigin: true
changeOrigin: true,
/** Without OG proxy on :8090, Node was returning 500 HTML; return JSON so callers fail softly in dev. */
configure(proxy) {
proxy.on('error', (_err, _req, res) => {
const r = res as { writeHead?: (c: number, h: Record<string, string>) => void; end?: (b: string) => void }
if (typeof r?.writeHead === 'function' && typeof r?.end === 'function') {
r.writeHead(502, { 'Content-Type': 'application/json' })
r.end(JSON.stringify({ ok: false, error: 'og_proxy_unreachable', hint: 'Start OG scraper on :8090 (see PROXY_SETUP.md)' }))
}
})
}
},
// Loopback HTTP index relay: `import.meta.env.DEV` rewrites kind 10243 URLs through this path.
'/dev-index-relay': {

Loading…
Cancel
Save