Skip to content

Commit 26bb46d

Browse files
authored
feat: add Following tab, profile stats & npub UX (#245)
* feat: add Following tab, profile stats, and npub UX improvements - Add Following tab on user profiles showing who they follow - Uses Primal cache for fast contact list retrieval with NDK fallback - Batch-fetches profile metadata with timeouts to prevent hangs - Sorted alphabetically with unresolved npubs at the bottom - Add follower/following counts via Primal's user_profile cache endpoint - New PrimalUserStats interface and kind 10000105 event handling - Counts displayed between bio and tabs, "Following" links to tab - Move inline npub pill to "more options" menu on other users' profiles - Own profile still shows inline npub for quick access - Toast notification on copy for better feedback - Optimize tab bar for mobile: remove icons from Reads and Following tabs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Copilot audit — race guards, safe timeouts, type-safe resolvers - Replace reject-based Promise.race timeouts with a fetchEventsWithTimeout helper that resolves to an empty Set (no unhandled rejections) - Add navigation race guards to loadFollowing() and fetchUserStatsFromPrimal — capture requestedPubkey and bail if hexpubkey changes mid-request - Fix PendingRequest resolve casts: wrap each resolve with a narrowing function instead of unsafe union cast
1 parent 7df9508 commit 26bb46d

File tree

2 files changed

+427
-13
lines changed

2 files changed

+427
-13
lines changed

src/lib/primalCache.ts

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,25 @@ interface PrimalFeedResponse {
4242
profiles: PrimalProfile[];
4343
}
4444

45+
export interface PrimalUserStats {
46+
followers_count: number;
47+
follows_count: number;
48+
note_count: number;
49+
reply_count: number;
50+
total_zap_count: number;
51+
total_satszapped: number;
52+
time_joined: number;
53+
}
54+
4555
interface PendingRequest {
46-
resolve: (value: PrimalSearchResult | PrimalFeedResponse | string[]) => void;
56+
resolve: (value: PrimalSearchResult | PrimalFeedResponse | string[] | PrimalUserStats | null) => void;
4757
reject: (error: Error) => void;
4858
timeout: ReturnType<typeof setTimeout>;
4959
profiles: PrimalProfile[];
5060
events: PrimalEvent[];
5161
follows: string[];
52-
type: 'search' | 'feed' | 'contacts' | 'global' | 'articles';
62+
userStats: PrimalUserStats | null;
63+
type: 'search' | 'feed' | 'contacts' | 'global' | 'articles' | 'user_stats';
5364
}
5465

5566
export interface PrimalArticleOptions {
@@ -186,6 +197,22 @@ export class PrimalCacheService {
186197
.filter((tag: string[]) => tag[0] === 'p' && tag[1])
187198
.map((tag: string[]) => tag[1]);
188199
pending.follows.push(...follows);
200+
} else if (event.kind === 10000105) {
201+
// User stats (Primal-specific kind)
202+
try {
203+
const stats = JSON.parse(event.content);
204+
pending.userStats = {
205+
followers_count: stats.followers_count ?? 0,
206+
follows_count: stats.follows_count ?? 0,
207+
note_count: stats.note_count ?? 0,
208+
reply_count: stats.reply_count ?? 0,
209+
total_zap_count: stats.total_zap_count ?? 0,
210+
total_satszapped: stats.total_satszapped ?? 0,
211+
time_joined: stats.time_joined ?? 0
212+
};
213+
} catch (error) {
214+
console.error('[PrimalCache] Error parsing user stats:', error);
215+
}
189216
}
190217
} else if (messageType === 'EOSE' && requestId) {
191218
const pending = this.pendingRequests.get(requestId as string);
@@ -201,6 +228,8 @@ export class PrimalCacheService {
201228
});
202229
} else if (pending.type === 'contacts') {
203230
(pending.resolve as (value: string[]) => void)(pending.follows);
231+
} else if (pending.type === 'user_stats') {
232+
(pending.resolve as (value: PrimalUserStats | null) => void)(pending.userStats);
204233
}
205234

206235
this.pendingRequests.delete(requestId as string);
@@ -249,6 +278,7 @@ export class PrimalCacheService {
249278
profiles: [],
250279
events: [],
251280
follows: [],
281+
userStats: null,
252282
type: 'search'
253283
});
254284

@@ -294,12 +324,13 @@ export class PrimalCacheService {
294324
}, timeoutMs);
295325

296326
this.pendingRequests.set(requestId, {
297-
resolve: resolve as (value: PrimalSearchResult | PrimalFeedResponse | string[]) => void,
327+
resolve: (value) => resolve(value as string[]),
298328
reject,
299329
timeout,
300330
profiles: [],
301331
events: [],
302332
follows: [],
333+
userStats: null,
303334
type: 'contacts'
304335
});
305336

@@ -364,12 +395,13 @@ export class PrimalCacheService {
364395
}, timeoutMs);
365396

366397
this.pendingRequests.set(requestId, {
367-
resolve: resolve as (value: PrimalSearchResult | PrimalFeedResponse | string[]) => void,
398+
resolve: (value) => resolve(value as PrimalFeedResponse),
368399
reject,
369400
timeout,
370401
profiles: [],
371402
events: [],
372403
follows: [],
404+
userStats: null,
373405
type: 'feed'
374406
});
375407

@@ -427,12 +459,13 @@ export class PrimalCacheService {
427459
}, timeoutMs);
428460

429461
this.pendingRequests.set(requestId, {
430-
resolve: resolve as (value: PrimalSearchResult | PrimalFeedResponse | string[]) => void,
462+
resolve: (value) => resolve(value as PrimalFeedResponse),
431463
reject,
432464
timeout,
433465
profiles: [],
434466
events: [],
435467
follows: [],
468+
userStats: null,
436469
type: 'global'
437470
});
438471

@@ -496,12 +529,13 @@ export class PrimalCacheService {
496529
}, timeoutMs);
497530

498531
this.pendingRequests.set(requestId, {
499-
resolve: resolve as (value: PrimalSearchResult | PrimalFeedResponse | string[]) => void,
532+
resolve: (value) => resolve(value as PrimalFeedResponse),
500533
reject,
501534
timeout,
502535
profiles: [],
503536
events: [],
504537
follows: [],
538+
userStats: null,
505539
type: 'articles'
506540
});
507541

@@ -556,12 +590,13 @@ export class PrimalCacheService {
556590
}, timeoutMs);
557591

558592
this.pendingRequests.set(requestId, {
559-
resolve: resolve as (value: PrimalSearchResult | PrimalFeedResponse | string[]) => void,
593+
resolve: (value) => resolve(value as PrimalFeedResponse),
560594
reject,
561595
timeout,
562596
profiles: [],
563597
events: [],
564598
follows: [],
599+
userStats: null,
565600
type: 'articles'
566601
});
567602

@@ -606,6 +641,7 @@ export class PrimalCacheService {
606641
profiles: [],
607642
events: [],
608643
follows: [],
644+
userStats: null,
609645
type: 'search'
610646
});
611647

@@ -619,6 +655,57 @@ export class PrimalCacheService {
619655
});
620656
}
621657

658+
/**
659+
* Fetch user profile stats (follower count, following count, etc.) from Primal cache
660+
* Uses the user_profile cache endpoint which returns kind 10000105 events
661+
*/
662+
public async fetchUserStats(pubkey: string, timeoutMs: number = 5000): Promise<PrimalUserStats | null> {
663+
if (!pubkey) return null;
664+
665+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
666+
await this.connect();
667+
}
668+
669+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
670+
throw new Error('WebSocket not connected');
671+
}
672+
673+
const requestId = this.generateRequestId();
674+
const request = [
675+
'REQ',
676+
requestId,
677+
{
678+
cache: ['user_profile', { pubkey }]
679+
}
680+
];
681+
682+
return new Promise((resolve, reject) => {
683+
const timeout = setTimeout(() => {
684+
this.pendingRequests.delete(requestId);
685+
reject(new Error('User stats request timed out'));
686+
}, timeoutMs);
687+
688+
this.pendingRequests.set(requestId, {
689+
resolve: (value) => resolve(value as PrimalUserStats | null),
690+
reject,
691+
timeout,
692+
profiles: [],
693+
events: [],
694+
follows: [],
695+
userStats: null,
696+
type: 'user_stats'
697+
});
698+
699+
try {
700+
this.ws!.send(JSON.stringify(request));
701+
} catch (error) {
702+
clearTimeout(timeout);
703+
this.pendingRequests.delete(requestId);
704+
reject(error as Error);
705+
}
706+
});
707+
}
708+
622709
public isConnected(): boolean {
623710
return this.ws?.readyState === WebSocket.OPEN;
624711
}
@@ -641,6 +728,22 @@ export const getPrimalCache = (): PrimalCacheService | null => {
641728
return primalCache;
642729
};
643730

731+
/**
732+
* Fetch user profile stats (followers, following, etc.) from Primal cache
733+
* Convenience wrapper that handles connection and errors
734+
*/
735+
export async function fetchUserStatsFromPrimal(pubkey: string): Promise<PrimalUserStats | null> {
736+
const cache = getPrimalCache();
737+
if (!cache) return null;
738+
739+
try {
740+
return await cache.fetchUserStats(pubkey);
741+
} catch (error) {
742+
console.debug('[PrimalCache] Failed to fetch user stats:', error);
743+
return null;
744+
}
745+
}
746+
644747
// ═══════════════════════════════════════════════════════════════
645748
// CONVENIENCE FUNCTIONS FOR FEED INTEGRATION
646749
// ═══════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)