Skip to content

Commit 058b630

Browse files
committed
some fixes
1 parent a6b7126 commit 058b630

5 files changed

Lines changed: 189 additions & 5 deletions

File tree

src/lib/nostr/client.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ type SearchFilter = Filter & { search?: string };
2727
const bunkerSigners = new Map<string, nip46.BunkerSigner>();
2828
const connectedBunkerSignerKeys = new Set<string>();
2929
const bunkerConnectionPromises = new Map<string, Promise<void>>();
30+
const nip07DetectionTimeoutMs = 3000;
31+
const nip07DetectionIntervalMs = 100;
3032
type SubCloser = { close: (reason?: string) => void };
3133
type PomegranateProfile = { name: string; handler_pubkey: string; email?: string };
3234
type PomegranateAccount = { pubkey: string; email?: string };
@@ -164,12 +166,24 @@ export function createGuestSession(): Session {
164166
}
165167

166168
export async function loginWithNip07(): Promise<Session> {
167-
if (!window.nostr) throw new Error('No NIP-07 extension was found.');
168-
const pubkey = await withTimeout(window.nostr.getPublicKey(), 30_000, 'The NIP-07 extension did not respond.');
169+
const signer = await waitForNip07();
170+
if (!signer) throw new Error('No NIP-07 extension was found. In Chrome, check the extension is enabled for nostr.com, then reload the page.');
171+
const pubkey = await withTimeout(signer.getPublicKey(), 30_000, 'The NIP-07 extension did not respond.');
169172
if (!/^[0-9a-f]{64}$/i.test(pubkey)) throw new Error('The NIP-07 extension returned an invalid public key.');
170173
return { pubkey: pubkey.toLowerCase(), mode: 'nip07' };
171174
}
172175

176+
async function waitForNip07() {
177+
if (typeof window === 'undefined') return undefined;
178+
if (window.nostr) return window.nostr;
179+
const startedAt = Date.now();
180+
while (Date.now() - startedAt < nip07DetectionTimeoutMs) {
181+
await sleep(nip07DetectionIntervalMs);
182+
if (window.nostr) return window.nostr;
183+
}
184+
return undefined;
185+
}
186+
173187
export function loginWithPrivateKey(secret: string): Session {
174188
const normalized = secret.trim();
175189
const bytes = normalized.startsWith('nsec1') ? decodeNsec(normalized) : hexToBytes(normalized);
@@ -190,6 +204,10 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, message: string)
190204
}
191205
}
192206

207+
function sleep(ms: number) {
208+
return new Promise<void>((resolve) => setTimeout(resolve, ms));
209+
}
210+
193211
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, mapper: (item: T) => Promise<R>) {
194212
const results: R[] = [];
195213
let index = 0;

src/lib/stores/app.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { browser } from '$app/environment';
22
import { goto } from '$app/navigation';
33
import { writable } from 'svelte/store';
44
import {
5+
cacheEvents,
56
cacheDirectMessages,
67
cacheNotifications,
78
clearEventCache,
@@ -1160,6 +1161,7 @@ export async function postNote(content: string, parent?: NostrEvent, extraTags:
11601161
const event = await publishNote(currentSession, content, currentRelays, tags);
11611162
seenLiveStatEvents.add(event.id);
11621163
events.update((existing) => mergeEvents([event], existing));
1164+
void cacheEvents([event]);
11631165
if (parent) mergeLocalReplyStats(event);
11641166
replyTarget.set(null);
11651167
quoteTarget.set(null);
@@ -1242,6 +1244,7 @@ export async function repostNote(target: NostrEvent) {
12421244
try {
12431245
const event = await publishRepost(currentSession, target, currentRelays);
12441246
events.update((existing) => mergeEvents([event], existing));
1247+
void cacheEvents([event]);
12451248
} catch (err) {
12461249
repostedEvents.update((existing) => {
12471250
const next = new Set(existing);
@@ -1625,7 +1628,7 @@ async function activateSession(
16251628
notifications.set([]);
16261629
loadingFeed.set(true);
16271630
if (!hasExplicitFeedModeSelection && previousFeedMode === 'global') feedMode.set('global');
1628-
void finishSignedInBootstrap(next);
1631+
await finishSignedInBootstrap(next);
16291632
}
16301633

16311634
function restoreSession(next: Session) {
@@ -1901,10 +1904,10 @@ async function hydrateDefaultFeedContext() {
19011904

19021905
async function finishSignedInBootstrap(next: Session) {
19031906
try {
1904-
await hydrateSignedInFeedContext(next);
1907+
await hydrateSignedInFeedContext(next).catch(() => undefined);
19051908
if (!isCurrentSession(next)) return;
19061909
selectPreferredSignedInFeed(true);
1907-
await refreshFeed(currentMode);
1910+
await refreshSignedInFeedWithFallback();
19081911
void warmSignedInSession(next, { refreshFeed: false });
19091912
restartInboxSubscriptions();
19101913
if (pendingGoogleOnboardingPubkey === next.pubkey && shouldOfferGoogleOnboarding(next, 'google')) onboardingDialogOpen.set(true);
@@ -1913,6 +1916,17 @@ async function finishSignedInBootstrap(next: Session) {
19131916
}
19141917
}
19151918

1919+
async function refreshSignedInFeedWithFallback() {
1920+
await refreshFeed(currentMode).catch(() => undefined);
1921+
if (getStoreSnapshot(events).length || currentMode === 'global') return;
1922+
activeHashtag.set('');
1923+
cachedOlderEvents = [];
1924+
olderFeedEmptyAttempts = 0;
1925+
hasMoreFeed.set(true);
1926+
feedMode.set('global');
1927+
await refreshFeed('global').catch(() => undefined);
1928+
}
1929+
19161930
async function warmSignedInSession(currentSession: Session, options: { refreshFeed?: boolean } = {}) {
19171931
if (!isCurrentSession(currentSession)) return;
19181932
await Promise.all([

src/routes/+page.svelte

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { onMount, tick } from 'svelte';
3+
import { browser } from '$app/environment';
34
import { page } from '$app/stores';
45
import { beforeNavigate, goto } from '$app/navigation';
56
import { ArrowUp, Plus } from '@lucide/svelte';
@@ -46,15 +47,20 @@
4647
let hideNewerBubbleTimeout: ReturnType<typeof setTimeout> | undefined;
4748
let restoredFeedPosition = false;
4849
let clickedFeedNoteSaved = false;
50+
let autoFillRunning = false;
51+
let autoFillQueued = false;
52+
let autoFillTimer: ReturnType<typeof setTimeout> | undefined;
4953
const pullThreshold = 78;
5054
const newerBubbleCooldownMs = 5000;
5155
const feedScrollStateMaxAgeMs = 30 * 60 * 1000;
56+
const autoFillMaxLoads = 5;
5257
$: hasReadRelays = $relays.some((relay) => relay.enabled && relay.read);
5358
$: feedEvents = hashtagFilteredEvents(displayEventsForFeedMode($feedMode, topLevelFeedEvents($events), $follows, $customFeedSettings), $activeHashtag);
5459
$: if (observer && loadMoreSentinel && feedEvents.length) {
5560
observer.unobserve(loadMoreSentinel);
5661
observer.observe(loadMoreSentinel);
5762
}
63+
$: if (browser) scheduleFeedAutoFill();
5864
$: pullProgress = Math.min(1, pullDistance / pullThreshold);
5965
$: showNewerBubble = canLoadNewerForFeed() && $pendingNewerEvents.length && !pullingNewer && !hideNewerBubble && !pullStartedAtTop && pullDistance <= 0;
6066
$: emptyMessage = !hasReadRelays
@@ -114,6 +120,7 @@
114120
observer?.disconnect();
115121
clearTimeout(hideNewerBubbleTimeout);
116122
clearTimeout(wheelPullTimeout);
123+
clearTimeout(autoFillTimer);
117124
removeEventListener('scroll', onScroll);
118125
removeEventListener('touchstart', startPullForNewer);
119126
removeEventListener('touchmove', updatePullForNewer);
@@ -217,6 +224,39 @@
217224
}
218225
}
219226
227+
function scheduleFeedAutoFill() {
228+
if (!feedEvents.length || $loadingFeed || $loadingMoreFeed || !$hasMoreFeed || autoFillQueued || autoFillRunning) return;
229+
autoFillQueued = true;
230+
clearTimeout(autoFillTimer);
231+
autoFillTimer = setTimeout(() => {
232+
autoFillQueued = false;
233+
void autoFillFeedViewport();
234+
}, 80);
235+
}
236+
237+
async function autoFillFeedViewport() {
238+
if (autoFillRunning) return;
239+
autoFillRunning = true;
240+
try {
241+
for (let attempt = 0; attempt < autoFillMaxLoads; attempt += 1) {
242+
await tick();
243+
if (!shouldAutoLoadOlderFeed()) return;
244+
const beforeCount = feedEvents.length;
245+
await requestOlderFeed();
246+
await tick();
247+
if (feedEvents.length <= beforeCount || !shouldAutoLoadOlderFeed()) return;
248+
}
249+
} finally {
250+
autoFillRunning = false;
251+
}
252+
}
253+
254+
function shouldAutoLoadOlderFeed() {
255+
if (!feedEvents.length || $loadingFeed || $loadingMoreFeed || !$hasMoreFeed || requestingOlder) return false;
256+
const page = document.documentElement;
257+
return page.scrollHeight <= window.innerHeight + 180 || isNearPageBottom();
258+
}
259+
220260
function saveCurrentFeedScrollPosition() {
221261
const anchor = firstVisibleFeedNote();
222262
saveFeedScrollPositionForAnchor(anchor);

src/routes/profile/[pubkey]/+page.svelte

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
profileEvents = profileEvents.filter((event) => !$deletedEventIds.has(event.id));
101101
profileSummaryEvents = profileSummaryEvents.filter((event) => !$deletedEventIds.has(event.id));
102102
}
103+
$: if (browser && pubkey) mergeProfileEventsFromGlobalStore(pubkey, $events);
103104
$: if (browser && routeKey && userItems.length && routeKey !== restoredProfileRouteKey) void restoreProfileScrollPosition(routeKey);
104105
105106
beforeNavigate(() => {
@@ -357,6 +358,20 @@
357358
void refreshProfileStats();
358359
}
359360
361+
function mergeProfileEventsFromGlobalStore(targetPubkey: string, storeEvents: NostrEvent[]) {
362+
if (!targetPubkey || !storeEvents.length) return;
363+
const existingIds = new Set([...profileEvents, ...profileSummaryEvents].map((event) => event.id));
364+
const freshProfileEvents = storeEvents.filter(
365+
(event) =>
366+
event.pubkey === targetPubkey &&
367+
!existingIds.has(event.id) &&
368+
eventInProfileWindow(event, profileRecentWindowLowerBound(), profileRecentWindowUpperBound()) &&
369+
(event.kind === 1 || event.kind === 6 || event.kind === 30023)
370+
);
371+
if (!freshProfileEvents.length) return;
372+
addProfileEvents(freshProfileEvents);
373+
}
374+
360375
function refreshProfileStats() {
361376
const statIds = [...new Set(profileNoteItems.map((item) => item.event.id))];
362377
if (statIds.length) void refreshEventStats(statIds, true);

src/routes/thread/[id]/+page.svelte

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
const threadScrollStateMaxAgeMs = 30 * 60 * 1000;
2626
const threadReplyWindowSeconds = 60 * 60 * 24 * 30;
2727
const threadReplyWindowScanLimit = 6;
28+
const threadReplyRetryDelaysMs = [2500, 5000, 10000, 20000, 30000];
29+
const threadReplyRetryMaxAttempts = threadReplyRetryDelaysMs.length;
2830
2931
let loading = true;
3032
let loadingOlderReplies = false;
@@ -43,6 +45,9 @@
4345
let liveThreadSub: { close: (reason?: string) => void } | undefined;
4446
let liveThreadKey = '';
4547
let liveThreadToken = 0;
48+
let threadReplyRetryKey = '';
49+
let threadReplyRetryAttempts = 0;
50+
let threadReplyRetryTimer: ReturnType<typeof setTimeout> | undefined;
4651
const seenLiveThreadEventIds = new Set<string>();
4752
4853
$: routeId = decodeURIComponent($page.params.id ?? '');
@@ -65,7 +70,9 @@
6570
$: if (browser && routeKey && focusedReplyId && threadReplyItems.some((item) => item.event.id === focusedReplyId) && `${routeKey}:${focusedReplyId}` !== focusedThreadRouteKey) {
6671
void scrollFocusedReplyIntoView(routeKey, focusedReplyId);
6772
}
73+
$: if (browser && root?.id) mergeThreadEventsFromGlobalStore(root.id, $events);
6874
$: if (browser) syncThreadLiveSubscription(root?.id ?? '', [root?.id ?? '', ...replies.map((reply) => reply.id)]);
75+
$: if (browser) syncMissingReplyRetry(root?.id ?? '', $eventStats[root?.id ?? '']?.replies ?? 0, replies.length);
6976
7077
beforeNavigate(() => {
7178
saveCurrentThreadState();
@@ -89,6 +96,7 @@
8996
saveCurrentThreadScrollPosition();
9097
bottomObserver?.disconnect();
9198
stopThreadLiveSubscription('leaving thread page');
99+
clearThreadReplyRetry();
92100
});
93101
94102
$: if (browser && id && id !== hydratedId) void hydrateThread();
@@ -332,6 +340,29 @@
332340
}
333341
}
334342
343+
function mergeThreadEventsFromGlobalStore(rootId: string, storeEvents: NostrEvent[]) {
344+
if (!rootId || !storeEvents.length) return;
345+
const existingItems = threadEventsForCache();
346+
const existingIds = new Set(existingItems.map((event) => event.id));
347+
const rootFromStore = storeEvents.find((event) => event.id === rootId);
348+
const freshRoot = rootFromStore && !rootEvent ? rootFromStore : undefined;
349+
const candidateThread = mergeThreadEvents([...(freshRoot ? [freshRoot] : []), ...storeEvents], existingItems);
350+
const freshReplies = threadReplyEvents(candidateThread, rootId).filter((event) => !existingIds.has(event.id));
351+
352+
if (!freshRoot && !freshReplies.length) return;
353+
if (freshRoot) rootEvent = freshRoot;
354+
if (freshReplies.length) {
355+
localThreadEvents = mergeThreadEvents(freshReplies, localThreadEvents);
356+
trimThreadReplyWindow('bottom');
357+
}
358+
359+
const freshItems = [...(freshRoot ? [freshRoot] : []), ...freshReplies];
360+
hydrateThreadProfiles(freshItems);
361+
refreshThreadStats(freshItems);
362+
void pruneDeletedEvents(freshItems);
363+
saveCurrentThreadState();
364+
}
365+
335366
function cachedThreadSeed(rootId: string, focusId: string) {
336367
const directSeed = readThreadSeed(rootId);
337368
const focusSeed = focusId && focusId !== rootId ? readThreadSeed(focusId) : null;
@@ -367,6 +398,7 @@
367398
loadingOlderReplies = false;
368399
hasOlderReplies = true;
369400
olderThreadReplyCursor = 0;
401+
resetThreadReplyRetry();
370402
stopThreadLiveSubscription('thread changed');
371403
if (restoreHydratedThread(nextId)) {
372404
loading = false;
@@ -497,6 +529,71 @@
497529
liveThreadKey = '';
498530
}
499531
532+
function syncMissingReplyRetry(rootId: string, expectedReplies: number, loadedReplies: number) {
533+
if (!rootId) {
534+
clearThreadReplyRetry();
535+
return;
536+
}
537+
if (threadReplyRetryKey !== rootId) {
538+
clearThreadReplyRetry();
539+
threadReplyRetryKey = rootId;
540+
threadReplyRetryAttempts = 0;
541+
}
542+
if (loading || loadingOlderReplies || threadReplyRetryTimer) return;
543+
if (loadedReplies >= expectedReplies || expectedReplies <= 0) {
544+
clearThreadReplyRetry();
545+
threadReplyRetryKey = rootId;
546+
threadReplyRetryAttempts = 0;
547+
return;
548+
}
549+
if (threadReplyRetryAttempts >= threadReplyRetryMaxAttempts) return;
550+
551+
const delay = threadReplyRetryDelaysMs[Math.min(threadReplyRetryAttempts, threadReplyRetryDelaysMs.length - 1)];
552+
threadReplyRetryTimer = setTimeout(() => {
553+
threadReplyRetryTimer = undefined;
554+
void retryMissingThreadReplies(rootId);
555+
}, delay);
556+
}
557+
558+
async function retryMissingThreadReplies(rootId: string) {
559+
if (id !== rootId || loading || loadingOlderReplies) return;
560+
threadReplyRetryAttempts += 1;
561+
loading = true;
562+
const loadedBefore = threadReplyEvents(threadEventsForCache(), rootId).length;
563+
try {
564+
const replyBatch = await fetchThreadReplyWindow({ limit: initialThreadReplyLimit });
565+
if (id !== rootId) return;
566+
if (!replyBatch.events.length) return;
567+
568+
localThreadEvents = mergeThreadEvents(replyBatch.events, localThreadEvents);
569+
trimThreadReplyWindow('bottom');
570+
hydrateThreadProfiles(replyBatch.events);
571+
refreshThreadStats(replyBatch.events);
572+
void pruneDeletedEvents(replyBatch.events);
573+
574+
const loadedAfter = threadReplyEvents(threadEventsForCache(), rootId).length;
575+
if (loadedAfter > loadedBefore) {
576+
threadReplyRetryAttempts = 0;
577+
olderThreadReplyCursor = nextThreadForwardCursor(replyBatch.events, olderThreadReplyCursor || rootEvent?.created_at || 0);
578+
hasOlderReplies = hasOlderReplies || replyBatch.directCount >= initialThreadReplyLimit;
579+
}
580+
saveCurrentThreadState();
581+
} finally {
582+
if (id === rootId) loading = false;
583+
}
584+
}
585+
586+
function resetThreadReplyRetry() {
587+
clearThreadReplyRetry();
588+
threadReplyRetryKey = '';
589+
threadReplyRetryAttempts = 0;
590+
}
591+
592+
function clearThreadReplyRetry() {
593+
if (threadReplyRetryTimer) clearTimeout(threadReplyRetryTimer);
594+
threadReplyRetryTimer = undefined;
595+
}
596+
500597
function handleLiveThreadEvent(rootId: string, statIds: string[], event: NostrEvent, token: number) {
501598
if (token !== liveThreadToken || id !== rootId || seenLiveThreadEventIds.has(event.id)) return;
502599
seenLiveThreadEventIds.add(event.id);

0 commit comments

Comments
 (0)