Skip to content

Commit 6e84413

Browse files
Merge pull request #58 from HORNET-Storage/ui/event-filtering-improvements
UI/event filtering improvements
2 parents 1685b8c + f4d1ead commit 6e84413

17 files changed

Lines changed: 460 additions & 436 deletions

File tree

src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { usePWA } from './hooks/usePWA';
1313
import { useThemeWatcher } from './hooks/useThemeWatcher';
1414
import { useAppSelector } from './hooks/reduxHooks';
1515
import { themeObject } from './styles/themes/themeVariables';
16-
import NDK, { NDKEvent, NDKNip07Signer, NDKRelayAuthPolicies } from '@nostr-dev-kit/ndk';
16+
import NDK, { NDKNip07Signer, NDKRelayAuthPolicies } from '@nostr-dev-kit/ndk';
1717
import { useNDKInit } from '@nostr-dev-kit/ndk-hooks';
1818
import config from './config/config';
1919

src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx

Lines changed: 200 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,139 @@ import { UserOutlined } from '@ant-design/icons';
2020
import { CreatorButton } from './avatar/SubscriberAvatar.styles';
2121
const { Text } = Typography;
2222

23+
// LRU Cache implementation for profile caching
24+
interface CachedProfile {
25+
profile: SubscriberProfile;
26+
timestamp: number;
27+
accessCount: number;
28+
lastAccessed: number;
29+
}
30+
31+
const PROFILE_CACHE_DURATION = 600000; // 10 minutes in milliseconds
32+
const MAX_CACHE_SIZE = 5000; // Maximum number of cached profiles
33+
const CLEANUP_INTERVAL = 300000; // Clean up every 5 minutes
34+
const MAX_REQUEST_CACHE_SIZE = 100; // Maximum concurrent requests
35+
36+
class ProfileCache {
37+
private cache = new Map<string, CachedProfile>();
38+
private requestCache = new Map<string, Promise<SubscriberProfile>>();
39+
private cleanupTimer: NodeJS.Timeout | null = null;
40+
41+
constructor() {
42+
this.startCleanupTimer();
43+
}
44+
45+
private startCleanupTimer(): void {
46+
this.cleanupTimer = setInterval(() => {
47+
this.cleanup();
48+
}, CLEANUP_INTERVAL);
49+
}
50+
51+
private cleanup(): void {
52+
const now = Date.now();
53+
const expiredKeys: string[] = [];
54+
55+
// Find expired entries - convert to array first to avoid iterator issues
56+
const cacheEntries = Array.from(this.cache.entries());
57+
for (const [key, cached] of cacheEntries) {
58+
if (now - cached.timestamp > PROFILE_CACHE_DURATION) {
59+
expiredKeys.push(key);
60+
}
61+
}
62+
63+
// Remove expired entries
64+
expiredKeys.forEach(key => this.cache.delete(key));
65+
66+
// If still over capacity, remove least recently used entries
67+
if (this.cache.size > MAX_CACHE_SIZE) {
68+
const entries = Array.from(this.cache.entries());
69+
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed);
70+
71+
const toRemove = entries.slice(0, this.cache.size - MAX_CACHE_SIZE);
72+
toRemove.forEach(([key]) => this.cache.delete(key));
73+
}
74+
75+
// Cleanup request cache if it gets too large
76+
if (this.requestCache.size > MAX_REQUEST_CACHE_SIZE) {
77+
this.requestCache.clear();
78+
}
79+
80+
}
81+
82+
getCachedProfile(pubkey: string): SubscriberProfile | null {
83+
const cached = this.cache.get(pubkey);
84+
if (!cached) return null;
85+
86+
const isExpired = Date.now() - cached.timestamp > PROFILE_CACHE_DURATION;
87+
if (isExpired) {
88+
this.cache.delete(pubkey);
89+
return null;
90+
}
91+
92+
// Update access statistics
93+
cached.accessCount++;
94+
cached.lastAccessed = Date.now();
95+
96+
return cached.profile;
97+
}
98+
99+
setCachedProfile(pubkey: string, profile: SubscriberProfile): void {
100+
const now = Date.now();
101+
this.cache.set(pubkey, {
102+
profile,
103+
timestamp: now,
104+
accessCount: 1,
105+
lastAccessed: now
106+
});
107+
108+
// Trigger cleanup if cache is getting too large
109+
if (this.cache.size > MAX_CACHE_SIZE * 1.1) {
110+
this.cleanup();
111+
}
112+
}
113+
114+
getRequestPromise(pubkey: string): Promise<SubscriberProfile> | null {
115+
return this.requestCache.get(pubkey) || null;
116+
}
117+
118+
setRequestPromise(pubkey: string, promise: Promise<SubscriberProfile>): void {
119+
this.requestCache.set(pubkey, promise);
120+
121+
// Clean up when promise completes
122+
promise.finally(() => {
123+
this.requestCache.delete(pubkey);
124+
});
125+
}
126+
127+
getCacheStats(): { size: number; requestCacheSize: number } {
128+
return {
129+
size: this.cache.size,
130+
requestCacheSize: this.requestCache.size
131+
};
132+
}
133+
134+
destroy(): void {
135+
if (this.cleanupTimer) {
136+
clearInterval(this.cleanupTimer);
137+
this.cleanupTimer = null;
138+
}
139+
this.cache.clear();
140+
this.requestCache.clear();
141+
}
142+
}
143+
144+
// Global profile cache instance
145+
const globalProfileCache = new ProfileCache();
146+
147+
// Helper functions for backward compatibility
148+
const getCachedProfile = (pubkey: string): SubscriberProfile | null => {
149+
return globalProfileCache.getCachedProfile(pubkey);
150+
};
151+
152+
const setCachedProfile = (pubkey: string, profile: SubscriberProfile): void => {
153+
globalProfileCache.setCachedProfile(pubkey, profile);
154+
};
155+
23156
export const PaidSubscribers: React.FC = () => {
24157
const hookResult = usePaidSubscribers(12);
25158
const { subscribers, fetchMore, hasMore, loading, useDummyData } = hookResult;
@@ -31,7 +164,6 @@ export const PaidSubscribers: React.FC = () => {
31164

32165
// Modal state for view all subscribers
33166
const [isViewAllModalVisible, setIsViewAllModalVisible] = useState(false);
34-
const [allSubscribers, setAllSubscribers] = useState<SubscriberProfile[]>([]);
35167
const [loadingProfiles, setLoadingProfiles] = useState(true);
36168

37169
const [subscriberProfiles, setSubscriberProfiles] = useState<Map<string, SubscriberProfile>>(
@@ -68,11 +200,12 @@ export const PaidSubscribers: React.FC = () => {
68200
newMap.set(pubkey, profile);
69201
return newMap;
70202
});
203+
// Cache the profile globally
204+
setCachedProfile(pubkey, profile);
71205
};
72206
// Handle opening view all modal
73207
const handleViewAll = async () => {
74208
setIsViewAllModalVisible(true);
75-
setAllSubscribers([...subscribers]); // Start with current subscribers
76209

77210
// Fetch more subscribers if available
78211
let canFetchMore = hasMore;
@@ -90,23 +223,77 @@ export const PaidSubscribers: React.FC = () => {
90223
};
91224

92225
useEffect(() => {
93-
// Implement hybrid profile fetching: NDK first, fallback to backend data
226+
// Implement hybrid profile fetching with 10-minute caching
94227
if (useDummyData) {
95228
setLoadingProfiles(false);
96229
return;
97230
}
98231

99-
const fetchProfiles = async () => {
100-
if (!ndkInstance || !ndkInstance.ndk) {
101-
setLoadingProfiles(false);
102-
return;
232+
const fetchSingleProfile = async (subscriber: SubscriberProfile): Promise<SubscriberProfile> => {
233+
// Check if we already have a cached profile that's still valid
234+
const cachedProfile = getCachedProfile(subscriber.pubkey);
235+
if (cachedProfile) {
236+
return cachedProfile;
103237
}
104238

239+
// Check if there's already a request in progress for this profile
240+
const existingRequest = globalProfileCache.getRequestPromise(subscriber.pubkey);
241+
if (existingRequest) {
242+
return existingRequest;
243+
}
105244

106-
// Process each subscriber with hybrid approach
245+
// Create new request
246+
const profileRequest = (async (): Promise<SubscriberProfile> => {
247+
try {
248+
249+
if (!ndkInstance || !ndkInstance.ndk) {
250+
// No NDK available, return backend data
251+
return {
252+
...subscriber,
253+
name: subscriber.name || 'Anonymous Subscriber',
254+
picture: subscriber.picture || '',
255+
about: subscriber.about || ''
256+
};
257+
}
258+
259+
// Try to fetch profile from NDK (user's relay + other relays)
260+
const user = await ndkInstance.ndk?.getUser({ pubkey: subscriber.pubkey }).fetchProfile();
261+
262+
if (user && (user.name || user.picture || user.about)) {
263+
// NDK returned a profile - use it as the primary source
264+
const ndkProfile = convertNDKUserProfileToSubscriberProfile(subscriber.pubkey, user);
265+
return ndkProfile;
266+
} else {
267+
// NDK came up empty - fallback to backend data
268+
return {
269+
...subscriber,
270+
name: subscriber.name || 'Anonymous Subscriber',
271+
picture: subscriber.picture || '',
272+
about: subscriber.about || ''
273+
};
274+
}
275+
} catch (error) {
276+
// Error occurred - fallback to backend data
277+
return {
278+
...subscriber,
279+
name: subscriber.name || 'Anonymous Subscriber',
280+
picture: subscriber.picture || '',
281+
about: subscriber.about || ''
282+
};
283+
}
284+
})();
285+
286+
// Store the promise in cache
287+
globalProfileCache.setRequestPromise(subscriber.pubkey, profileRequest);
288+
289+
return profileRequest;
290+
};
291+
292+
const fetchProfiles = async () => {
293+
// Process each subscriber with cached hybrid approach
107294
await Promise.all(
108295
subscribers.map(async (subscriber) => {
109-
// Skip if we already have a complete profile in our map
296+
// Skip if we already have a complete profile in our local map
110297
const existingProfile = subscriberProfiles.get(subscriber.pubkey);
111298
const hasValidProfile = existingProfile && (
112299
(existingProfile.name && existingProfile.name !== 'Anonymous Subscriber') ||
@@ -119,30 +306,10 @@ export const PaidSubscribers: React.FC = () => {
119306
}
120307

121308
try {
122-
123-
// Try to fetch profile from NDK (user's relay + other relays)
124-
const user = await ndkInstance.ndk?.getUser({ pubkey: subscriber.pubkey }).fetchProfile();
125-
126-
if (user && (user.name || user.picture || user.about)) {
127-
// NDK returned a profile - use it as the primary source
128-
129-
const ndkProfile = convertNDKUserProfileToSubscriberProfile(subscriber.pubkey, user);
130-
updateSubscriberProfile(subscriber.pubkey, ndkProfile);
131-
} else {
132-
// NDK came up empty - fallback to backend data
133-
134-
// Use the backend data as-is since NDK had no better information
135-
updateSubscriberProfile(subscriber.pubkey, {
136-
...subscriber,
137-
// Ensure we have fallback values if backend data is also incomplete
138-
name: subscriber.name || 'Anonymous Subscriber',
139-
picture: subscriber.picture || '',
140-
about: subscriber.about || ''
141-
});
142-
}
309+
const profile = await fetchSingleProfile(subscriber);
310+
updateSubscriberProfile(subscriber.pubkey, profile);
143311
} catch (error) {
144-
145-
// Error occurred - fallback to backend data
312+
// Use fallback profile
146313
updateSubscriberProfile(subscriber.pubkey, {
147314
...subscriber,
148315
name: subscriber.name || 'Anonymous Subscriber',
@@ -157,7 +324,7 @@ export const PaidSubscribers: React.FC = () => {
157324
};
158325

159326
fetchProfiles();
160-
}, [subscribers, ndkInstance]);
327+
}, [subscribers, ndkInstance, useDummyData, subscriberProfiles]);
161328

162329
// Handle closing view all modal
163330
const handleCloseViewAllModal = () => {

src/components/relay-settings/layouts/DesktopLayout.tsx

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { TotalEarning } from '@app/components/relay-dashboard/totalEarning/Total
99
import { ActivityStory } from '@app/components/relay-dashboard/transactions/Transactions';
1010
import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles';
1111
import { NetworkSection } from '@app/components/relay-settings/sections/NetworkSection';
12-
import { AppBucketsSection } from '@app/components/relay-settings/sections/AppBucketsSection';
1312
import { KindsSection } from '@app/components/relay-settings/sections/KindsSection';
1413
import { MediaSection } from '@app/components/relay-settings/sections/MediaSection';
1514
import { ModerationSection } from '@app/components/relay-settings/sections/ModerationSection';
@@ -25,13 +24,6 @@ interface DesktopLayoutProps {
2524
isFileStorageActive: boolean;
2625
onProtocolsChange: (protocols: string[]) => void;
2726
onFileStorageChange: (active: boolean) => void;
28-
// App buckets section props
29-
appBuckets: string[];
30-
dynamicAppBuckets: string[];
31-
onAppBucketsChange: (values: string[]) => void;
32-
onDynamicAppBucketsChange: (values: string[]) => void;
33-
onAddBucket: (bucket: string) => void;
34-
onRemoveBucket: (bucket: string) => void;
3527
// Kinds section props
3628
isKindsActive: boolean;
3729
selectedKinds: string[];
@@ -82,13 +74,6 @@ export const DesktopLayout: React.FC<DesktopLayoutProps> = ({
8274
isFileStorageActive,
8375
onProtocolsChange,
8476
onFileStorageChange,
85-
// App buckets props
86-
appBuckets,
87-
dynamicAppBuckets,
88-
onAppBucketsChange,
89-
onDynamicAppBucketsChange,
90-
onAddBucket,
91-
onRemoveBucket,
9277
// Kinds props
9378
isKindsActive,
9479
selectedKinds,
@@ -97,6 +82,7 @@ export const DesktopLayout: React.FC<DesktopLayoutProps> = ({
9782
onKindsActiveChange,
9883
onKindsChange,
9984
onDynamicKindsChange,
85+
onAddKind,
10086
onRemoveKind,
10187
// Media props
10288
photos,
@@ -124,14 +110,6 @@ export const DesktopLayout: React.FC<DesktopLayoutProps> = ({
124110
onFileStorageChange={onFileStorageChange}
125111
/>
126112

127-
<AppBucketsSection
128-
appBuckets={appBuckets}
129-
dynamicAppBuckets={dynamicAppBuckets}
130-
onAppBucketsChange={onAppBucketsChange}
131-
onDynamicAppBucketsChange={onDynamicAppBucketsChange}
132-
onAddBucket={onAddBucket}
133-
onRemoveBucket={onRemoveBucket}
134-
/>
135113

136114

137115
<ModerationSection
@@ -171,6 +149,7 @@ export const DesktopLayout: React.FC<DesktopLayoutProps> = ({
171149
onKindsActiveChange={onKindsActiveChange}
172150
onKindsChange={onKindsChange}
173151
onDynamicKindsChange={onDynamicKindsChange}
152+
onAddKind={onAddKind}
174153
onRemoveKind={onRemoveKind}
175154
/>
176155

0 commit comments

Comments
 (0)