Merge branch 'main' of gitlab.com:soapbox-pub/ditto

This commit is contained in:
Alex Gleason
2026-04-03 22:50:34 -05:00
8 changed files with 193 additions and 147 deletions
@@ -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<BlobbiCompanion | null> => {
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. */
+13 -29
View File
@@ -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<string, NostrEvent>();
@@ -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,
+9 -7
View File
@@ -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[] = [];
+89 -81
View File
@@ -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<string>();
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;
@@ -1014,7 +1030,7 @@ export function ComposeBox({
if (!user && compact) return null;
return (
<div className={cn("px-4 py-3 bg-background/85")}>
<div className={cn("px-4 py-3 bg-background/85 rounded-2xl")}>
{/* Preview toggle at top when not controlled and has previewable content */}
{hasPreviewableContent && controlledPreviewMode === undefined && (
<div className="flex items-center justify-end mb-3">
@@ -1062,31 +1078,83 @@ export function ComposeBox({
)}
<div className="flex-1 min-w-0">
{mode === 'poll' ? (
/* ── Inline poll builder ─────────────────────────────── */
<div className="pt-2.5 pb-1 space-y-3">
{!previewMode ? (
/* ── Edit mode — Textarea ────────────────────────────── */
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={expand}
onPaste={handlePaste}
placeholder={mode === 'poll' ? 'Ask a question…' : placeholder}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
<MentionAutocomplete
textareaRef={textareaRef}
content={content}
onInsertMention={handleInsertMention}
/>
<EmojiShortcodeAutocomplete
textareaRef={textareaRef}
content={content}
onInsertEmoji={handleInsertShortcodeEmoji}
/>
</div>
) : (
/* Preview mode - Show how post will look */
mockEvent && (() => {
const imetaMap = parseImetaMap(mockEvent.tags);
const videos = extractVideoUrls(mockEvent.content);
const imetaAudios = Array.from(imetaMap.values())
.filter((e) => e.mime?.startsWith('audio/'))
.map((e) => e.url);
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
const webxdcApps = Array.from(imetaMap.values()).filter(
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
);
return (
<div className="pt-2.5 pb-2 min-h-[100px]">
<div className="text-lg opacity-85">
<NoteContent event={mockEvent} className="text-foreground" />
</div>
<NoteMedia
videos={videos}
audios={audios}
imetaMap={imetaMap}
webxdcApps={webxdcApps}
event={mockEvent}
/>
</div>
);
})()
)}
{/* Poll options + settings — shown below the normal textarea/preview */}
{mode === 'poll' && (
<div className="space-y-3 pt-1">
{/* Back to post link — hidden when poll mode is the only mode */}
{initialMode !== 'poll' && (
<button
type="button"
onClick={() => setMode('post')}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors -mt-0.5"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="size-3.5" />
Back to post
</button>
)}
{/* Question */}
<textarea
value={pollQuestion}
onChange={(e) => setPollQuestion(e.target.value)}
placeholder="Ask a question…"
rows={2}
maxLength={280}
className="w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pb-1 opacity-85 break-words"
/>
{/* Options */}
<div className="space-y-1.5">
{pollOptions.map((opt, idx) => (
@@ -1167,66 +1235,6 @@ export function ComposeBox({
))}
</div>
</div>
) : !previewMode ? (
/* ── Edit mode — Textarea ────────────────────────────── */
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onFocus={expand}
onPaste={handlePaste}
placeholder={placeholder}
className={cn(
'w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-lg pt-2.5 pb-2 opacity-85 break-words overflow-hidden transition-[min-height] duration-200 ease-in-out',
isExpanded ? 'min-h-[100px]' : 'min-h-[44px]',
)}
rows={1}
disabled={!user}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
/>
<MentionAutocomplete
textareaRef={textareaRef}
content={content}
onInsertMention={handleInsertMention}
/>
<EmojiShortcodeAutocomplete
textareaRef={textareaRef}
content={content}
onInsertEmoji={handleInsertShortcodeEmoji}
/>
</div>
) : (
/* Preview mode - Show how post will look */
mockEvent && (() => {
const imetaMap = parseImetaMap(mockEvent.tags);
const videos = extractVideoUrls(mockEvent.content);
const imetaAudios = Array.from(imetaMap.values())
.filter((e) => e.mime?.startsWith('audio/'))
.map((e) => e.url);
const audios = imetaAudios.length > 0 ? imetaAudios : extractAudioUrls(mockEvent.content);
const webxdcApps = Array.from(imetaMap.values()).filter(
(entry) => entry.mime === 'application/x-webxdc' || entry.mime === 'application/vnd.webxdc+zip',
);
return (
<div className="pt-2.5 pb-2 min-h-[100px]">
<div className="text-lg opacity-85">
<NoteContent event={mockEvent} className="text-foreground" />
</div>
<NoteMedia
videos={videos}
audios={audios}
imetaMap={imetaMap}
webxdcApps={webxdcApps}
event={mockEvent}
/>
</div>
);
})()
)}
{/* Content warning input */}
+5 -3
View File
@@ -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({
))}
</div>
{/* 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(
<Lightbox
images={images}
currentIndex={lightboxIndex}
@@ -135,7 +136,8 @@ export function ImageGallery({
onPrev={goPrev}
topBarLeft={lightboxTopBarLeft}
bottomBar={lightboxBottomBar}
/>
/>,
document.body,
)}
</>
);
+14 -9
View File
@@ -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);
+1 -1
View File
@@ -566,7 +566,7 @@ export class NostrBatcher {
req(
filters: NostrFilter[],
opts?: { signal?: AbortSignal },
opts?: { signal?: AbortSignal; relays?: string[]; eoseTimeout?: number },
): AsyncIterable<import('@nostrify/types').NostrRelayEVENT | import('@nostrify/types').NostrRelayEOSE | import('@nostrify/types').NostrRelayCLOSED> {
return this.pool.req(filters, opts);
}
+54 -6
View File
@@ -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<NostrEvent | null> {
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<NostrEvent[]> {
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<string>();
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 });
}