From ed083bfdad5ab71249a5a73d1c4db13e505748cd Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Fri, 3 Apr 2026 21:15:51 -0500 Subject: [PATCH 1/4] Use relaxed eoseTimeout (1000ms) for Blobbi queries to ensure freshest data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default pool eoseTimeout (300ms) races and resolves shortly after the fastest relay. Blobbi pet state and profile data are accuracy-sensitive — stale data from a single fast relay can cause data loss when mutations overwrite newer versions on other relays. - Add eoseTimeout option to fetchFreshEvent and new fetchFreshEvents variant - Update useBlobbisCollection, useBlobbonautProfile, and useBlobbiSleepToggle to use fetchFreshEvents/fetchFreshEvent with eoseTimeout: 1000 - Widen NostrBatcher.req() type to pass through eoseTimeout to NPool - Gate unconditional console.log in parseBlobbiEvent behind import.meta.env.DEV - Remove unconditional console.logs from useBlobbisCollection --- .../interaction/useBlobbiSleepToggle.ts | 19 +++--- src/blobbi/core/hooks/useBlobbisCollection.ts | 42 ++++--------- src/blobbi/core/lib/blobbi.ts | 16 ++--- src/hooks/useBlobbonautProfile.ts | 23 ++++--- src/lib/NostrBatcher.ts | 2 +- src/lib/fetchFreshEvent.ts | 60 +++++++++++++++++-- 6 files changed, 99 insertions(+), 63 deletions(-) diff --git a/src/blobbi/companion/interaction/useBlobbiSleepToggle.ts b/src/blobbi/companion/interaction/useBlobbiSleepToggle.ts index e58c8a25..b6ac84d5 100644 --- a/src/blobbi/companion/interaction/useBlobbiSleepToggle.ts +++ b/src/blobbi/companion/interaction/useBlobbiSleepToggle.ts @@ -17,6 +17,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { toast } from '@/hooks/useToast'; +import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; import type { BlobbiCompanion } from '@/blobbi/core/lib/blobbi'; import { @@ -52,18 +53,14 @@ export function useBlobbiSleepToggle(): UseBlobbiSleepToggleResult { pubkey: string, dTag: string, ): Promise => { - const events = await nostr.query([{ - kinds: [KIND_BLOBBI_STATE], - authors: [pubkey], - '#d': [dTag], - }]); + const event = await fetchFreshEvent( + nostr, + { kinds: [KIND_BLOBBI_STATE], authors: [pubkey], '#d': [dTag] }, + { eoseTimeout: 1000 }, + ); - const validEvents = events - .filter(isValidBlobbiEvent) - .sort((a, b) => b.created_at - a.created_at); - - if (validEvents.length === 0) return null; - return parseBlobbiEvent(validEvents[0]) ?? null; + if (!event || !isValidBlobbiEvent(event)) return null; + return parseBlobbiEvent(event) ?? null; }, [nostr]); /** Optimistically update the TanStack cache so the companion reacts immediately. */ diff --git a/src/blobbi/core/hooks/useBlobbisCollection.ts b/src/blobbi/core/hooks/useBlobbisCollection.ts index 35b795e0..e7e77362 100644 --- a/src/blobbi/core/hooks/useBlobbisCollection.ts +++ b/src/blobbi/core/hooks/useBlobbisCollection.ts @@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { NostrEvent } from '@nostrify/nostrify'; import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { fetchFreshEvents } from '@/lib/fetchFreshEvent'; import { KIND_BLOBBI_STATE, isValidBlobbiEvent, @@ -51,46 +52,34 @@ export function useBlobbisCollection(dList: string[] | undefined) { // Main query to fetch all companions from relays const query = useQuery({ queryKey: ['blobbi-collection', user?.pubkey, queryKeyDTags], - queryFn: async ({ signal }) => { + queryFn: async () => { if (!user?.pubkey || !sortedDList || sortedDList.length === 0) { - console.log('[useBlobbisCollection] No pubkey or empty dList, returning empty'); return { companionsByD: {}, companions: [] }; } - // Log the dList we're about to query - console.log('[Blobbi] dList:', sortedDList); - // Chunk the d-list for relay compatibility const chunks = chunkArray(sortedDList, CHUNK_SIZE); - console.log('[useBlobbisCollection] Splitting into', chunks.length, 'chunk(s)'); - // Query all chunks in parallel + // Fetch all chunks, using a relaxed eoseTimeout (1000ms) so slower + // relays have time to respond and we get the freshest events. const allEvents: NostrEvent[] = []; for (const chunk of chunks) { - const filter = { - kinds: [KIND_BLOBBI_STATE], - authors: [user.pubkey], - '#d': chunk, - // IMPORTANT: No limit - fetch ALL pets matching the d-tags - }; - - // Log the filter immediately before query - console.log('[Blobbi] 31124 query filter:', JSON.stringify(filter, null, 2)); - - const events = await nostr.query([filter], { signal }); + const events = await fetchFreshEvents( + nostr, + [{ + kinds: [KIND_BLOBBI_STATE], + authors: [user.pubkey], + '#d': chunk, + }], + { eoseTimeout: 1000 }, + ); allEvents.push(...events); - - console.log('[useBlobbisCollection] Chunk returned', events.length, 'events'); } - console.log('[useBlobbisCollection] Total events received:', allEvents.length); - // Filter to valid events const validEvents = allEvents.filter(isValidBlobbiEvent); - console.log('[useBlobbisCollection] Valid events:', validEvents.length); - // Group events by d-tag and keep only the newest per d const eventsByD = new Map(); @@ -116,11 +105,6 @@ export function useBlobbisCollection(dList: string[] | undefined) { } } - console.log('[useBlobbisCollection] Parsed companions:', { - count: companions.length, - dTags: Object.keys(companionsByD), - }); - return { companionsByD, companions }; }, enabled: !!user?.pubkey && !!sortedDList && sortedDList.length > 0, diff --git a/src/blobbi/core/lib/blobbi.ts b/src/blobbi/core/lib/blobbi.ts index a7a9af99..3c39a96b 100644 --- a/src/blobbi/core/lib/blobbi.ts +++ b/src/blobbi/core/lib/blobbi.ts @@ -892,13 +892,15 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined const isLegacy = isLegacyBlobbiEvent(event); // Concise, structured debug log - console.log('[Blobbi]', { - d: d.length > 30 ? `${d.slice(0, 20)}...` : d, - name, - isLegacy, - hasSeed: !!seed, - traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`, - }); + if (import.meta.env.DEV) { + console.log('[Blobbi]', { + d: d.length > 30 ? `${d.slice(0, 20)}...` : d, + name, + isLegacy, + hasSeed: !!seed, + traits: `${visualTraits.baseColor} ${visualTraits.pattern} ${visualTraits.size}`, + }); + } // Parse task progress tags: ["task", "name:value"] const tasks: BlobbiTaskProgress[] = []; diff --git a/src/hooks/useBlobbonautProfile.ts b/src/hooks/useBlobbonautProfile.ts index fdb52e7b..bbcee0fd 100644 --- a/src/hooks/useBlobbonautProfile.ts +++ b/src/hooks/useBlobbonautProfile.ts @@ -5,6 +5,7 @@ import type { NostrEvent } from '@nostrify/nostrify'; import { useCurrentUser } from './useCurrentUser'; import { useLocalStorage } from './useLocalStorage'; +import { fetchFreshEvents } from '@/lib/fetchFreshEvent'; import { KIND_BLOBBONAUT_PROFILE, BLOBBONAUT_PROFILE_KINDS, @@ -76,7 +77,7 @@ export function useBlobbonautProfile() { // Main query to fetch the profile from relays const query = useQuery({ queryKey: ['blobbonaut-profile', user?.pubkey], - queryFn: async ({ signal }) => { + queryFn: async () => { if (!user?.pubkey) { return null; } @@ -84,14 +85,18 @@ export function useBlobbonautProfile() { // Query with all possible d-tag values (canonical + legacy) const dValues = getBlobbonautQueryDValues(user.pubkey); - // Query BOTH current (11125) and legacy (31125) kinds for migration support - const filter = { - kinds: [...BLOBBONAUT_PROFILE_KINDS], - authors: [user.pubkey], - '#d': dValues, - }; - - const events = await nostr.query([filter], { signal }); + // Query BOTH current (11125) and legacy (31125) kinds for migration support. + // Use a relaxed eoseTimeout (1000ms) so slower relays have time to respond + // and we get the freshest profile across all relays. + const events = await fetchFreshEvents( + nostr, + [{ + kinds: [...BLOBBONAUT_PROFILE_KINDS], + authors: [user.pubkey], + '#d': dValues, + }], + { eoseTimeout: 1000 }, + ); // Filter to valid events const validEvents = events.filter(isValidBlobbonautEvent); diff --git a/src/lib/NostrBatcher.ts b/src/lib/NostrBatcher.ts index f584615d..55d80e33 100644 --- a/src/lib/NostrBatcher.ts +++ b/src/lib/NostrBatcher.ts @@ -566,7 +566,7 @@ export class NostrBatcher { req( filters: NostrFilter[], - opts?: { signal?: AbortSignal }, + opts?: { signal?: AbortSignal; relays?: string[]; eoseTimeout?: number }, ): AsyncIterable { return this.pool.req(filters, opts); } diff --git a/src/lib/fetchFreshEvent.ts b/src/lib/fetchFreshEvent.ts index 5ef92e56..ab29fd36 100644 --- a/src/lib/fetchFreshEvent.ts +++ b/src/lib/fetchFreshEvent.ts @@ -1,5 +1,18 @@ import type { NostrEvent, NostrFilter, NPool } from '@nostrify/nostrify'; +interface FetchFreshEventOpts { + /** + * Override the pool-level eoseTimeout for this query. When set, uses + * `nostr.req()` directly with this value instead of `nostr.query()`, + * giving slower relays more time to respond. + * + * The default pool eoseTimeout is 300ms (resolves quickly after the + * fastest relay). Set to eg. 1000 for accuracy-sensitive queries where + * you need the absolute freshest event across all relays. + */ + eoseTimeout?: number; +} + /** * Fetches the freshest version of a replaceable/addressable event directly from * relays, bypassing any local cache. @@ -24,13 +37,9 @@ import type { NostrEvent, NostrFilter, NPool } from '@nostrify/nostrify'; export async function fetchFreshEvent( nostr: NPool, filter: NostrFilter, + opts?: FetchFreshEventOpts, ): Promise { - const signal = AbortSignal.timeout(10_000); - - const events = await nostr.query( - [{ ...filter, limit: 1 }], - { signal }, - ); + const events = await fetchFreshEvents(nostr, [{ ...filter, limit: 1 }], opts); if (events.length === 0) return null; @@ -39,3 +48,42 @@ export async function fetchFreshEvent( current.created_at > latest.created_at ? current : latest, ); } + +/** + * Fetches events from relays, bypassing any local cache. Like + * {@link fetchFreshEvent} but accepts multiple filters and returns all + * matching events (not just one). + * + * When `opts.eoseTimeout` is set, uses `nostr.req()` directly with that + * timeout, overriding the pool-level eoseTimeout. Otherwise falls back to + * the standard `nostr.query()` path. + */ +export async function fetchFreshEvents( + nostr: NPool, + filters: NostrFilter[], + opts?: FetchFreshEventOpts, +): Promise { + const signal = AbortSignal.timeout(10_000); + + if (opts?.eoseTimeout !== undefined) { + // Use req() directly so we can pass a custom eoseTimeout, + // overriding the pool-level value (typically 300ms). + const events: NostrEvent[] = []; + const seen = new Set(); + + for await (const msg of nostr.req(filters, { signal, eoseTimeout: opts.eoseTimeout })) { + if (msg[0] === 'EOSE' || msg[0] === 'CLOSED') break; + if (msg[0] === 'EVENT') { + const event = msg[2]; + if (!seen.has(event.id)) { + seen.add(event.id); + events.push(event); + } + } + } + + return events; + } + + return nostr.query(filters, { signal }); +} From db502b462c24a638d0824e531163a1bc02f09723 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Fri, 3 Apr 2026 21:52:05 -0500 Subject: [PATCH 2/4] Fix lightbox appearing behind right sidebar by portaling to document.body --- src/components/ImageGallery.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/ImageGallery.tsx b/src/components/ImageGallery.tsx index e5ba94ad..ee341831 100644 --- a/src/components/ImageGallery.tsx +++ b/src/components/ImageGallery.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react'; import { Blurhash } from 'react-blurhash'; import { cn } from '@/lib/utils'; @@ -125,8 +126,8 @@ export function ImageGallery({ ))} - {/* Lightbox */} - {lightboxIndex !== null && lightboxIndex !== undefined && ( + {/* Lightbox — portaled to document.body to escape stacking contexts (e.g. the center column z-0) */} + {lightboxIndex !== null && lightboxIndex !== undefined && createPortal( + />, + document.body, )} ); From 45134ef9cc31376dea95f64b74cca9973dbe2fde Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Fri, 3 Apr 2026 22:29:32 -0500 Subject: [PATCH 3/4] Allow file uploads in poll composer Remove the separate pollQuestion state and poll builder branch. Poll mode now reuses the normal textarea/preview ternary (with edit/preview toggle, file uploads, paste handling, imeta tags) and renders poll options and settings below it. --- src/components/ComposeBox.tsx | 168 ++++++++++++++++++---------------- 1 file changed, 88 insertions(+), 80 deletions(-) diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx index 9867baeb..6ccd4591 100644 --- a/src/components/ComposeBox.tsx +++ b/src/components/ComposeBox.tsx @@ -201,7 +201,6 @@ export function ComposeBox({ // Poll mode state const [mode, setMode] = useState<'post' | 'poll'>(initialMode); - const [pollQuestion, setPollQuestion] = useState(''); const [pollOptions, setPollOptions] = useState([ { id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }, @@ -233,7 +232,6 @@ export function ComposeBox({ setTrayOpen(false); setInternalPreviewMode(false); setMode(initialMode); - setPollQuestion(''); setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]); setPollType('singlechoice'); setPollDuration(7); @@ -982,7 +980,8 @@ export function ComposeBox({ const handlePollSubmit = async () => { const filledOptions = pollOptions.filter((o) => o.label.trim()); - if (!pollQuestion.trim() || filledOptions.length < 2 || !user || isPollPending) return; + const finalContent = content.trim(); + if (!finalContent || filledOptions.length < 2 || !user || isPollPending) return; const tags: string[][] = []; for (const opt of filledOptions) { @@ -992,10 +991,27 @@ export function ComposeBox({ if (pollDuration > 0) { tags.push(['endsAt', String(Math.floor(Date.now() / 1000) + pollDuration * 86_400)]); } - tags.push(['alt', `Poll: ${pollQuestion.trim()}`]); + + // NIP-92: Add imeta tags for media URLs in content + const mediaUrlMatches = finalContent.matchAll(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi')); + const processedUrls = new Set(); + for (const match of mediaUrlMatches) { + const url = match[0]; + if (processedUrls.has(url)) continue; + processedUrls.add(url); + const fileTags = uploadedFileGroups.get(url); + if (fileTags) { + tags.push(['imeta', ...fileTags.map(tag => `${tag[0]} ${tag[1]}`)]); + } else { + const ext = match[1].toLowerCase(); + tags.push(['imeta', `url ${url}`, `m ${mimeFromExt(ext)}`]); + } + } + + tags.push(['alt', `Poll: ${finalContent}`]); try { - await createEvent({ kind: 1068, content: pollQuestion.trim(), tags }); + await createEvent({ kind: 1068, content: finalContent, tags }); resetComposeState(); queryClient.invalidateQueries({ queryKey: ['feed'] }); toast({ title: 'Poll published!' }); @@ -1006,7 +1022,7 @@ export function ComposeBox({ }; const pollFilledCount = pollOptions.filter((o) => o.label.trim()).length; - const isPollValid = pollQuestion.trim().length > 0 && pollFilledCount >= 2; + const isPollValid = content.trim().length > 0 && pollFilledCount >= 2; const isExpanded = forceExpanded || expanded || content.length > 0 || !compact; @@ -1062,31 +1078,83 @@ export function ComposeBox({ )}
- {mode === 'poll' ? ( - /* ── Inline poll builder ─────────────────────────────── */ -
+ {!previewMode ? ( + /* ── Edit mode — Textarea ────────────────────────────── */ +
+