From 2124aa98b82918c0ef3f4e305e9f6d0860a5ad19 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Fri, 4 Jul 2025 13:56:08 +0200 Subject: [PATCH 1/6] Fix blacklist mode logic for event filtering - Fix backend API communication for blacklist mode - In blacklist mode: selected items are blocked, backend receives inverse (allowed items) - In whitelist mode: selected items are allowed, backend receives them directly - Fix mode switching to preserve blocked items when loading from backend - Add helper functions to calculate inverse kinds for blacklist mode - Ensure core kinds are never blockable in blacklist mode - Add AddKindForm component for dynamic kind addition --- .../relay-settings/layouts/DesktopLayout.tsx | 2 + .../relay-settings/layouts/MobileLayout.tsx | 2 + .../sections/KindsSection/KindsSection.tsx | 8 +++ .../KindsSection/components/AddKindForm.tsx | 43 ++++++++++++ src/constants/coreKinds.ts | 18 +++++ src/hooks/useRelaySettings.ts | 66 ++++++++++++------- 6 files changed, 117 insertions(+), 22 deletions(-) create mode 100644 src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx diff --git a/src/components/relay-settings/layouts/DesktopLayout.tsx b/src/components/relay-settings/layouts/DesktopLayout.tsx index 726f277a..84579739 100644 --- a/src/components/relay-settings/layouts/DesktopLayout.tsx +++ b/src/components/relay-settings/layouts/DesktopLayout.tsx @@ -97,6 +97,7 @@ export const DesktopLayout: React.FC = ({ onKindsActiveChange, onKindsChange, onDynamicKindsChange, + onAddKind, onRemoveKind, // Media props photos, @@ -171,6 +172,7 @@ export const DesktopLayout: React.FC = ({ onKindsActiveChange={onKindsActiveChange} onKindsChange={onKindsChange} onDynamicKindsChange={onDynamicKindsChange} + onAddKind={onAddKind} onRemoveKind={onRemoveKind} /> diff --git a/src/components/relay-settings/layouts/MobileLayout.tsx b/src/components/relay-settings/layouts/MobileLayout.tsx index af7f9c0c..542242b4 100644 --- a/src/components/relay-settings/layouts/MobileLayout.tsx +++ b/src/components/relay-settings/layouts/MobileLayout.tsx @@ -94,6 +94,7 @@ export const MobileLayout: React.FC = ({ onKindsActiveChange, onKindsChange, onDynamicKindsChange, + onAddKind, onRemoveKind, // Media props photos, @@ -162,6 +163,7 @@ export const MobileLayout: React.FC = ({ onKindsActiveChange={onKindsActiveChange} onKindsChange={onKindsChange} onDynamicKindsChange={onDynamicKindsChange} + onAddKind={onAddKind} onRemoveKind={onRemoveKind} /> diff --git a/src/components/relay-settings/sections/KindsSection/KindsSection.tsx b/src/components/relay-settings/sections/KindsSection/KindsSection.tsx index 87e62334..7c7bfa07 100644 --- a/src/components/relay-settings/sections/KindsSection/KindsSection.tsx +++ b/src/components/relay-settings/sections/KindsSection/KindsSection.tsx @@ -5,6 +5,7 @@ import { BaseSwitch } from '@app/components/common/BaseSwitch/BaseSwitch'; import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; import { CollapsibleSection } from '../../shared/CollapsibleSection/CollapsibleSection'; import { KindsList } from './components/KindsList'; +import { AddKindForm } from './components/AddKindForm'; import { DynamicKindsList } from './components/DynamicKindsList'; export interface KindsSectionProps { @@ -16,6 +17,7 @@ export interface KindsSectionProps { onKindsActiveChange: (active: boolean) => void; onKindsChange: (values: string[]) => void; onDynamicKindsChange: (values: string[]) => void; + onAddKind: (kind: string) => void; onRemoveKind: (kind: string) => void; } @@ -28,6 +30,7 @@ export const KindsSection: React.FC = ({ onKindsActiveChange, onKindsChange, onDynamicKindsChange, + onAddKind, onRemoveKind, }) => { const header = mode !== 'whitelist' ? 'Blacklisted Kind Numbers' : 'Kind Numbers'; @@ -54,6 +57,11 @@ export const KindsSection: React.FC = ({ onKindsChange={onKindsChange} /> + + void; + mode: string; +} + +export const AddKindForm: React.FC = ({ onAddKind, mode }) => { + const [newKind, setNewKind] = useState(''); + + const handleAddKind = () => { + if (newKind) { + onAddKind(newKind); + setNewKind(''); + } + }; + + if (mode === 'whitelist') { + return null; + } + + return ( +
+

{'Add to Blacklist'}

+
+ setNewKind(e.target.value)} + placeholder="Enter new kind" + /> + + Add Kind + +
+
+ ); +}; + +export default AddKindForm; \ No newline at end of file diff --git a/src/constants/coreKinds.ts b/src/constants/coreKinds.ts index 9fb173b7..c58df4d7 100644 --- a/src/constants/coreKinds.ts +++ b/src/constants/coreKinds.ts @@ -22,4 +22,22 @@ export const ensureCoreKinds = (kindList: string[]): string[] => { // Helper function to check if a kind is protected export const isCoreKind = (kind: string): boolean => { return CORE_KINDS.includes(kind); +}; + +// Helper function to get all possible kinds from noteOptions +export const getAllPossibleKinds = (): string[] => { + // Import noteOptions dynamically to avoid circular dependency + const { noteOptions } = require('../constants/relaySettings'); + return noteOptions.map((option: any) => option.kindString); +}; + +// Helper function to calculate inverse for blacklist mode +export const calculateInverseKinds = (selectedKinds: string[]): string[] => { + const allPossibleKinds = getAllPossibleKinds(); + // In blacklist mode: selected = blocked, so remove selected from all possible kinds + // Core kinds can never be blocked, so they're always in the whitelist + const allowedKinds = allPossibleKinds.filter(kind => + !selectedKinds.includes(kind) || isCoreKind(kind) + ); + return allowedKinds; }; \ No newline at end of file diff --git a/src/hooks/useRelaySettings.ts b/src/hooks/useRelaySettings.ts index 6f12db6b..2e9c61e3 100644 --- a/src/hooks/useRelaySettings.ts +++ b/src/hooks/useRelaySettings.ts @@ -3,7 +3,7 @@ import config from '@app/config/config'; import { readToken } from '@app/services/localStorage.service'; import { useHandleLogout } from './authUtils'; import { Settings } from '@app/constants/relaySettings'; -import { CORE_KINDS, ensureCoreKinds } from '@app/constants/coreKinds'; +import { CORE_KINDS, ensureCoreKinds, calculateInverseKinds, getAllPossibleKinds, isCoreKind } from '@app/constants/coreKinds'; // Legacy interface - no longer used with new API // interface BackendRelaySettings { ... } @@ -54,25 +54,32 @@ const useRelaySettings = () => { return; } - lastMode.current = relaySettings.mode; - - if (relaySettings.mode === 'blacklist') { - // Store current settings before clearing - setPreviousSmartSettings({ - kinds: relaySettings.kinds, - photos: relaySettings.photos, - videos: relaySettings.videos, - audio: relaySettings.audio, - }); + // Only clear arrays when user manually switches TO blacklist mode + // Don't clear if we're in blacklist mode and already have blocked items (loaded from backend) + if (relaySettings.mode === 'blacklist' && lastMode.current === 'whitelist') { + // Only clear if we don't have any blocked items (user switching, not loading from backend) + const hasBlockedItems = relaySettings.kinds.length > 0 || relaySettings.photos.length > 0 || + relaySettings.videos.length > 0 || relaySettings.audio.length > 0; + + if (!hasBlockedItems) { + // Store current settings before clearing + setPreviousSmartSettings({ + kinds: relaySettings.kinds, + photos: relaySettings.photos, + videos: relaySettings.videos, + audio: relaySettings.audio, + }); - setRelaySettings(prev => ({ - ...prev, - kinds: [], - photos: [], - videos: [], - audio: [], - })); - } else if (relaySettings.mode === 'whitelist' && previousSmartSettings) { + // Clear selections in blacklist mode - user starts fresh and selects what to block + setRelaySettings(prev => ({ + ...prev, + kinds: [], + photos: [], + videos: [], + audio: [], + })); + } + } else if (relaySettings.mode === 'whitelist' && lastMode.current === 'blacklist' && previousSmartSettings) { // Restore previous whitelist mode settings setRelaySettings(prev => ({ ...prev, @@ -82,6 +89,9 @@ const useRelaySettings = () => { audio: previousSmartSettings.audio, })); } + + // Update lastMode after processing + lastMode.current = relaySettings.mode; }, [relaySettings.mode, previousSmartSettings]); /* eslint-enable react-hooks/exhaustive-deps */ @@ -97,9 +107,19 @@ const useRelaySettings = () => { if (backendData.event_filtering) { settings.mode = backendData.event_filtering.mode || 'whitelist'; settings.moderationMode = backendData.event_filtering.moderation_mode || 'strict'; - // Always ensure core kinds are included from backend data + // Handle kinds based on mode const backendKinds = backendData.event_filtering.kind_whitelist || []; - settings.kinds = ensureCoreKinds(backendKinds); + + if (settings.mode === 'blacklist') { + // In blacklist mode: backend sends allowed kinds, we need to calculate blocked kinds + const allPossibleKinds = getAllPossibleKinds(); + const blockedKinds = allPossibleKinds.filter(kind => !backendKinds.includes(kind)); + // Only show non-core kinds as blocked (core kinds can't be blocked) + settings.kinds = blockedKinds.filter(kind => !isCoreKind(kind)); + } else { + // In whitelist mode: backend sends allowed kinds directly + settings.kinds = ensureCoreKinds(backendKinds); + } // Extract mime types and file sizes from actual backend format const mediaDefinitions = backendData.event_filtering.media_definitions || {}; @@ -166,7 +186,9 @@ const useRelaySettings = () => { event_filtering: { mode: settings.mode, moderation_mode: settings.moderationMode, - kind_whitelist: ensureCoreKinds(settings.kinds), // Always include core kinds + kind_whitelist: settings.mode === 'blacklist' + ? calculateInverseKinds(settings.kinds) // For blacklist: send inverse (all kinds except blocked ones) + : ensureCoreKinds(settings.kinds), // For whitelist: send selected kinds directly media_definitions: mediaDefinitions, dynamic_kinds: { enabled: false, From 83277c1a008c14c077fd45d3ee4406a416f23975 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Fri, 4 Jul 2025 22:00:17 +0200 Subject: [PATCH 2/6] Fix profile caching and blacklist mode reconciliation - Add 10-minute caching to subscriber profile fetching to prevent backend spam - Implement LRU cache with automatic cleanup for thousands of users - Fix blacklist mode reconciliation to properly show blocked items on page refresh - Use Set-based logic for more reliable blacklist calculations - Remove App Buckets functionality from relay settings - Clean up excessive console logging --- src/App.tsx | 2 +- .../paid-subscribers/PaidSubscribers.tsx | 233 +++++++++++++++--- .../relay-settings/layouts/DesktopLayout.tsx | 23 -- .../relay-settings/layouts/MobileLayout.tsx | 24 -- .../AppBucketsSection/AppBucketsSection.tsx | 62 ----- .../components/AddBucketForm.tsx | 39 --- .../components/BucketsList.tsx | 49 ---- .../components/DynamicBucketsList.tsx | 59 ----- .../sections/AppBucketsSection/index.ts | 4 - src/constants/coreKinds.ts | 32 ++- src/constants/relaySettings.ts | 15 +- src/hooks/usePaidSubscribers.ts | 67 +++-- src/hooks/useRelaySettings.ts | 128 +++++++--- src/pages/RelaySettingsPage.tsx | 74 +----- 14 files changed, 359 insertions(+), 452 deletions(-) delete mode 100644 src/components/relay-settings/sections/AppBucketsSection/AppBucketsSection.tsx delete mode 100644 src/components/relay-settings/sections/AppBucketsSection/components/AddBucketForm.tsx delete mode 100644 src/components/relay-settings/sections/AppBucketsSection/components/BucketsList.tsx delete mode 100644 src/components/relay-settings/sections/AppBucketsSection/components/DynamicBucketsList.tsx delete mode 100644 src/components/relay-settings/sections/AppBucketsSection/index.ts diff --git a/src/App.tsx b/src/App.tsx index 2c9bf942..b6173215 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,7 @@ import { usePWA } from './hooks/usePWA'; import { useThemeWatcher } from './hooks/useThemeWatcher'; import { useAppSelector } from './hooks/reduxHooks'; import { themeObject } from './styles/themes/themeVariables'; -import NDK, { NDKEvent, NDKNip07Signer, NDKRelayAuthPolicies } from '@nostr-dev-kit/ndk'; +import NDK, { NDKNip07Signer, NDKRelayAuthPolicies } from '@nostr-dev-kit/ndk'; import { useNDKInit } from '@nostr-dev-kit/ndk-hooks'; import config from './config/config'; diff --git a/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx b/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx index a41165c9..7fc56dde 100644 --- a/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx +++ b/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx @@ -20,6 +20,139 @@ import { UserOutlined } from '@ant-design/icons'; import { CreatorButton } from './avatar/SubscriberAvatar.styles'; const { Text } = Typography; +// LRU Cache implementation for profile caching +interface CachedProfile { + profile: SubscriberProfile; + timestamp: number; + accessCount: number; + lastAccessed: number; +} + +const PROFILE_CACHE_DURATION = 600000; // 10 minutes in milliseconds +const MAX_CACHE_SIZE = 5000; // Maximum number of cached profiles +const CLEANUP_INTERVAL = 300000; // Clean up every 5 minutes +const MAX_REQUEST_CACHE_SIZE = 100; // Maximum concurrent requests + +class ProfileCache { + private cache = new Map(); + private requestCache = new Map>(); + private cleanupTimer: NodeJS.Timeout | null = null; + + constructor() { + this.startCleanupTimer(); + } + + private startCleanupTimer(): void { + this.cleanupTimer = setInterval(() => { + this.cleanup(); + }, CLEANUP_INTERVAL); + } + + private cleanup(): void { + const now = Date.now(); + const expiredKeys: string[] = []; + + // Find expired entries - convert to array first to avoid iterator issues + const cacheEntries = Array.from(this.cache.entries()); + for (const [key, cached] of cacheEntries) { + if (now - cached.timestamp > PROFILE_CACHE_DURATION) { + expiredKeys.push(key); + } + } + + // Remove expired entries + expiredKeys.forEach(key => this.cache.delete(key)); + + // If still over capacity, remove least recently used entries + if (this.cache.size > MAX_CACHE_SIZE) { + const entries = Array.from(this.cache.entries()); + entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed); + + const toRemove = entries.slice(0, this.cache.size - MAX_CACHE_SIZE); + toRemove.forEach(([key]) => this.cache.delete(key)); + } + + // Cleanup request cache if it gets too large + if (this.requestCache.size > MAX_REQUEST_CACHE_SIZE) { + this.requestCache.clear(); + } + + } + + getCachedProfile(pubkey: string): SubscriberProfile | null { + const cached = this.cache.get(pubkey); + if (!cached) return null; + + const isExpired = Date.now() - cached.timestamp > PROFILE_CACHE_DURATION; + if (isExpired) { + this.cache.delete(pubkey); + return null; + } + + // Update access statistics + cached.accessCount++; + cached.lastAccessed = Date.now(); + + return cached.profile; + } + + setCachedProfile(pubkey: string, profile: SubscriberProfile): void { + const now = Date.now(); + this.cache.set(pubkey, { + profile, + timestamp: now, + accessCount: 1, + lastAccessed: now + }); + + // Trigger cleanup if cache is getting too large + if (this.cache.size > MAX_CACHE_SIZE * 1.1) { + this.cleanup(); + } + } + + getRequestPromise(pubkey: string): Promise | null { + return this.requestCache.get(pubkey) || null; + } + + setRequestPromise(pubkey: string, promise: Promise): void { + this.requestCache.set(pubkey, promise); + + // Clean up when promise completes + promise.finally(() => { + this.requestCache.delete(pubkey); + }); + } + + getCacheStats(): { size: number; requestCacheSize: number } { + return { + size: this.cache.size, + requestCacheSize: this.requestCache.size + }; + } + + destroy(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + this.cache.clear(); + this.requestCache.clear(); + } +} + +// Global profile cache instance +const globalProfileCache = new ProfileCache(); + +// Helper functions for backward compatibility +const getCachedProfile = (pubkey: string): SubscriberProfile | null => { + return globalProfileCache.getCachedProfile(pubkey); +}; + +const setCachedProfile = (pubkey: string, profile: SubscriberProfile): void => { + globalProfileCache.setCachedProfile(pubkey, profile); +}; + export const PaidSubscribers: React.FC = () => { const hookResult = usePaidSubscribers(12); const { subscribers, fetchMore, hasMore, loading, useDummyData } = hookResult; @@ -31,7 +164,6 @@ export const PaidSubscribers: React.FC = () => { // Modal state for view all subscribers const [isViewAllModalVisible, setIsViewAllModalVisible] = useState(false); - const [allSubscribers, setAllSubscribers] = useState([]); const [loadingProfiles, setLoadingProfiles] = useState(true); const [subscriberProfiles, setSubscriberProfiles] = useState>( @@ -68,11 +200,12 @@ export const PaidSubscribers: React.FC = () => { newMap.set(pubkey, profile); return newMap; }); + // Cache the profile globally + setCachedProfile(pubkey, profile); }; // Handle opening view all modal const handleViewAll = async () => { setIsViewAllModalVisible(true); - setAllSubscribers([...subscribers]); // Start with current subscribers // Fetch more subscribers if available let canFetchMore = hasMore; @@ -90,23 +223,77 @@ export const PaidSubscribers: React.FC = () => { }; useEffect(() => { - // Implement hybrid profile fetching: NDK first, fallback to backend data + // Implement hybrid profile fetching with 10-minute caching if (useDummyData) { setLoadingProfiles(false); return; } - const fetchProfiles = async () => { - if (!ndkInstance || !ndkInstance.ndk) { - setLoadingProfiles(false); - return; + const fetchSingleProfile = async (subscriber: SubscriberProfile): Promise => { + // Check if we already have a cached profile that's still valid + const cachedProfile = getCachedProfile(subscriber.pubkey); + if (cachedProfile) { + return cachedProfile; } + // Check if there's already a request in progress for this profile + const existingRequest = globalProfileCache.getRequestPromise(subscriber.pubkey); + if (existingRequest) { + return existingRequest; + } - // Process each subscriber with hybrid approach + // Create new request + const profileRequest = (async (): Promise => { + try { + + if (!ndkInstance || !ndkInstance.ndk) { + // No NDK available, return backend data + return { + ...subscriber, + name: subscriber.name || 'Anonymous Subscriber', + picture: subscriber.picture || '', + about: subscriber.about || '' + }; + } + + // Try to fetch profile from NDK (user's relay + other relays) + const user = await ndkInstance.ndk?.getUser({ pubkey: subscriber.pubkey }).fetchProfile(); + + if (user && (user.name || user.picture || user.about)) { + // NDK returned a profile - use it as the primary source + const ndkProfile = convertNDKUserProfileToSubscriberProfile(subscriber.pubkey, user); + return ndkProfile; + } else { + // NDK came up empty - fallback to backend data + return { + ...subscriber, + name: subscriber.name || 'Anonymous Subscriber', + picture: subscriber.picture || '', + about: subscriber.about || '' + }; + } + } catch (error) { + // Error occurred - fallback to backend data + return { + ...subscriber, + name: subscriber.name || 'Anonymous Subscriber', + picture: subscriber.picture || '', + about: subscriber.about || '' + }; + } + })(); + + // Store the promise in cache + globalProfileCache.setRequestPromise(subscriber.pubkey, profileRequest); + + return profileRequest; + }; + + const fetchProfiles = async () => { + // Process each subscriber with cached hybrid approach await Promise.all( subscribers.map(async (subscriber) => { - // Skip if we already have a complete profile in our map + // Skip if we already have a complete profile in our local map const existingProfile = subscriberProfiles.get(subscriber.pubkey); const hasValidProfile = existingProfile && ( (existingProfile.name && existingProfile.name !== 'Anonymous Subscriber') || @@ -119,30 +306,10 @@ export const PaidSubscribers: React.FC = () => { } try { - - // Try to fetch profile from NDK (user's relay + other relays) - const user = await ndkInstance.ndk?.getUser({ pubkey: subscriber.pubkey }).fetchProfile(); - - if (user && (user.name || user.picture || user.about)) { - // NDK returned a profile - use it as the primary source - - const ndkProfile = convertNDKUserProfileToSubscriberProfile(subscriber.pubkey, user); - updateSubscriberProfile(subscriber.pubkey, ndkProfile); - } else { - // NDK came up empty - fallback to backend data - - // Use the backend data as-is since NDK had no better information - updateSubscriberProfile(subscriber.pubkey, { - ...subscriber, - // Ensure we have fallback values if backend data is also incomplete - name: subscriber.name || 'Anonymous Subscriber', - picture: subscriber.picture || '', - about: subscriber.about || '' - }); - } + const profile = await fetchSingleProfile(subscriber); + updateSubscriberProfile(subscriber.pubkey, profile); } catch (error) { - - // Error occurred - fallback to backend data + // Use fallback profile updateSubscriberProfile(subscriber.pubkey, { ...subscriber, name: subscriber.name || 'Anonymous Subscriber', @@ -157,7 +324,7 @@ export const PaidSubscribers: React.FC = () => { }; fetchProfiles(); - }, [subscribers, ndkInstance]); + }, [subscribers, ndkInstance, useDummyData, subscriberProfiles]); // Handle closing view all modal const handleCloseViewAllModal = () => { diff --git a/src/components/relay-settings/layouts/DesktopLayout.tsx b/src/components/relay-settings/layouts/DesktopLayout.tsx index 84579739..52ffc833 100644 --- a/src/components/relay-settings/layouts/DesktopLayout.tsx +++ b/src/components/relay-settings/layouts/DesktopLayout.tsx @@ -9,7 +9,6 @@ import { TotalEarning } from '@app/components/relay-dashboard/totalEarning/Total import { ActivityStory } from '@app/components/relay-dashboard/transactions/Transactions'; import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; import { NetworkSection } from '@app/components/relay-settings/sections/NetworkSection'; -import { AppBucketsSection } from '@app/components/relay-settings/sections/AppBucketsSection'; import { KindsSection } from '@app/components/relay-settings/sections/KindsSection'; import { MediaSection } from '@app/components/relay-settings/sections/MediaSection'; import { ModerationSection } from '@app/components/relay-settings/sections/ModerationSection'; @@ -25,13 +24,6 @@ interface DesktopLayoutProps { isFileStorageActive: boolean; onProtocolsChange: (protocols: string[]) => void; onFileStorageChange: (active: boolean) => void; - // App buckets section props - appBuckets: string[]; - dynamicAppBuckets: string[]; - onAppBucketsChange: (values: string[]) => void; - onDynamicAppBucketsChange: (values: string[]) => void; - onAddBucket: (bucket: string) => void; - onRemoveBucket: (bucket: string) => void; // Kinds section props isKindsActive: boolean; selectedKinds: string[]; @@ -82,13 +74,6 @@ export const DesktopLayout: React.FC = ({ isFileStorageActive, onProtocolsChange, onFileStorageChange, - // App buckets props - appBuckets, - dynamicAppBuckets, - onAppBucketsChange, - onDynamicAppBucketsChange, - onAddBucket, - onRemoveBucket, // Kinds props isKindsActive, selectedKinds, @@ -125,14 +110,6 @@ export const DesktopLayout: React.FC = ({ onFileStorageChange={onFileStorageChange} /> - void; onFileStorageChange: (active: boolean) => void; - // App buckets section props - appBuckets: string[]; - dynamicAppBuckets: string[]; - onAppBucketsChange: (values: string[]) => void; - onDynamicAppBucketsChange: (values: string[]) => void; - onAddBucket: (bucket: string) => void; - onRemoveBucket: (bucket: string) => void; // Kinds section props isKindsActive: boolean; selectedKinds: string[]; @@ -79,13 +71,6 @@ export const MobileLayout: React.FC = ({ isFileStorageActive, onProtocolsChange, onFileStorageChange, - // App buckets props - appBuckets, - dynamicAppBuckets, - onAppBucketsChange, - onDynamicAppBucketsChange, - onAddBucket, - onRemoveBucket, // Kinds props isKindsActive, selectedKinds, @@ -120,15 +105,6 @@ export const MobileLayout: React.FC = ({ onFileStorageChange={onFileStorageChange} /> - - void; - onDynamicAppBucketsChange: (values: string[]) => void; - onAddBucket: (bucket: string) => void; - onRemoveBucket: (bucket: string) => void; -} - -export const AppBucketsSection: React.FC = ({ - appBuckets, - dynamicAppBuckets, - onAppBucketsChange, - onDynamicAppBucketsChange, - onAddBucket, - onRemoveBucket, -}) => { - const theme = useAppSelector((state) => state.theme.theme); - - return ( - - -
- - - - - - {'Enabling buckets will organize data stored within the relay to quicken retrieval times for users. Disabling buckets will not turn off data storage.'} - - - - - - - -
-
-
- ); -}; - -export default AppBucketsSection; \ No newline at end of file diff --git a/src/components/relay-settings/sections/AppBucketsSection/components/AddBucketForm.tsx b/src/components/relay-settings/sections/AppBucketsSection/components/AddBucketForm.tsx deleted file mode 100644 index 49159065..00000000 --- a/src/components/relay-settings/sections/AppBucketsSection/components/AddBucketForm.tsx +++ /dev/null @@ -1,39 +0,0 @@ -// src/components/relay-settings/sections/AppBucketsSection/components/AddBucketForm.tsx - -import React, { useState } from 'react'; -import { Input } from 'antd'; -import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; -import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; - -interface AddBucketFormProps { - onAddBucket: (bucket: string) => void; -} - -export const AddBucketForm: React.FC = ({ onAddBucket }) => { - const [newBucket, setNewBucket] = useState(''); - - const handleAddBucket = () => { - if (newBucket) { - onAddBucket(newBucket); - setNewBucket(''); - } - }; - - return ( -
-

{'Add an App Bucket'}

-
- setNewBucket(e.target.value)} - placeholder="Enter new app bucket" - /> - - Add bucket - -
-
- ); -}; - -export default AddBucketForm; \ No newline at end of file diff --git a/src/components/relay-settings/sections/AppBucketsSection/components/BucketsList.tsx b/src/components/relay-settings/sections/AppBucketsSection/components/BucketsList.tsx deleted file mode 100644 index 60ab2c2c..00000000 --- a/src/components/relay-settings/sections/AppBucketsSection/components/BucketsList.tsx +++ /dev/null @@ -1,49 +0,0 @@ -// src/components/relay-settings/sections/AppBucketsSection/components/BucketsList.tsx - -import React from 'react'; -import { CheckboxValueType } from 'antd/es/checkbox/Group'; -import { BaseCheckbox } from '@app/components/common/BaseCheckbox/BaseCheckbox'; -import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; -import { appBuckets as defaultAppBuckets } from '@app/constants/relaySettings'; -import { themeObject } from '@app/styles/themes/themeVariables'; -import { useAppSelector } from '@app/hooks/reduxHooks'; - -interface BucketsListProps { - selectedBuckets: string[]; - onBucketsChange: (values: string[]) => void; -} - -export const BucketsList: React.FC = ({ - selectedBuckets, - onBucketsChange, -}) => { - const theme = useAppSelector((state) => state.theme.theme); - - const bucketOptions = defaultAppBuckets.map(bucket => ({ - label: ( - - {bucket.label} - - ), - value: bucket.id, - })); - - const handleChange = (checkedValues: CheckboxValueType[]) => { - onBucketsChange(checkedValues as string[]); - }; - - return ( - - ); -}; - -export default BucketsList; \ No newline at end of file diff --git a/src/components/relay-settings/sections/AppBucketsSection/components/DynamicBucketsList.tsx b/src/components/relay-settings/sections/AppBucketsSection/components/DynamicBucketsList.tsx deleted file mode 100644 index 1a45a3f7..00000000 --- a/src/components/relay-settings/sections/AppBucketsSection/components/DynamicBucketsList.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// src/components/relay-settings/sections/AppBucketsSection/components/DynamicBucketsList.tsx - -import React from 'react'; -import { CheckboxValueType } from 'antd/es/checkbox/Group'; -import { BaseCheckbox } from '@app/components/common/BaseCheckbox/BaseCheckbox'; -import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; -import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; - -interface DynamicBucketsListProps { - buckets: string[]; - selectedBuckets: string[]; - onBucketsChange: (values: string[]) => void; - onRemoveBucket: (bucket: string) => void; -} - -export const DynamicBucketsList: React.FC = ({ - buckets, - selectedBuckets, - onBucketsChange, - onRemoveBucket, -}) => { - const handleChange = (checkedValues: CheckboxValueType[]) => { - onBucketsChange(checkedValues as string[]); - }; - - return ( - - {buckets.map((bucket) => ( -
-
- - - {bucket} - -
- onRemoveBucket(bucket)} - > - Remove - -
- ))} -
- ); -}; - -export default DynamicBucketsList; \ No newline at end of file diff --git a/src/components/relay-settings/sections/AppBucketsSection/index.ts b/src/components/relay-settings/sections/AppBucketsSection/index.ts deleted file mode 100644 index 8f3d9f92..00000000 --- a/src/components/relay-settings/sections/AppBucketsSection/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// src/components/relay-settings/sections/AppBucketsSection/index.ts - -import AppBucketsSection from './AppBucketsSection'; -export { AppBucketsSection }; diff --git a/src/constants/coreKinds.ts b/src/constants/coreKinds.ts index c58df4d7..fb6bcac9 100644 --- a/src/constants/coreKinds.ts +++ b/src/constants/coreKinds.ts @@ -27,7 +27,7 @@ export const isCoreKind = (kind: string): boolean => { // Helper function to get all possible kinds from noteOptions export const getAllPossibleKinds = (): string[] => { // Import noteOptions dynamically to avoid circular dependency - const { noteOptions } = require('../constants/relaySettings'); + const { noteOptions } = require('./relaySettings'); return noteOptions.map((option: any) => option.kindString); }; @@ -40,4 +40,34 @@ export const calculateInverseKinds = (selectedKinds: string[]): string[] => { !selectedKinds.includes(kind) || isCoreKind(kind) ); return allowedKinds; +}; + +// Media type helper functions +export const getAllPossibleMediaTypes = (mediaType: 'photos' | 'videos' | 'audio'): string[] => { + // Import mimeTypeOptions dynamically to avoid circular dependency + const { mimeTypeOptions } = require('./relaySettings'); + + switch (mediaType) { + case 'photos': + return mimeTypeOptions + .filter((option: any) => option.value.startsWith('image/') || option.value === 'application/pdf' || option.value === 'application/postscript') + .map((option: any) => option.value); + case 'videos': + return mimeTypeOptions + .filter((option: any) => option.value.startsWith('video/')) + .map((option: any) => option.value); + case 'audio': + return mimeTypeOptions + .filter((option: any) => option.value.startsWith('audio/')) + .map((option: any) => option.value); + default: + return []; + } +}; + +// Helper function to calculate inverse for media types in blacklist mode +export const calculateInverseMediaTypes = (selectedMediaTypes: string[], mediaType: 'photos' | 'videos' | 'audio'): string[] => { + const allPossibleMediaTypes = getAllPossibleMediaTypes(mediaType); + // In blacklist mode: selected = blocked, so return all types except the selected (blocked) ones + return allPossibleMediaTypes.filter(type => !selectedMediaTypes.includes(type)); }; \ No newline at end of file diff --git a/src/constants/relaySettings.ts b/src/constants/relaySettings.ts index 09dd09f3..19a84c40 100644 --- a/src/constants/relaySettings.ts +++ b/src/constants/relaySettings.ts @@ -7,8 +7,6 @@ export type Settings = { videos: string[]; gitNestr: string[]; audio: string[]; - appBuckets: string[]; - dynamicAppBuckets: string[]; isKindsActive: boolean; isPhotosActive: boolean; isVideosActive: boolean; @@ -22,7 +20,7 @@ export type Settings = { audioMaxSizeMB: number; } -export type Category = 'kinds' | 'photos' | 'videos' | 'gitNestr' | 'audio' | 'dynamicKinds' | 'appBuckets' | 'dynamicAppBuckets' | 'photoMaxSizeMB' | 'videoMaxSizeMB' | 'audioMaxSizeMB'; +export type Category = 'kinds' | 'photos' | 'videos' | 'gitNestr' | 'audio' | 'dynamicKinds' | 'photoMaxSizeMB' | 'videoMaxSizeMB' | 'audioMaxSizeMB'; export const noteOptions = [ { kind: 0, kindString: 'kind0', description: 'Metadata', category: 1 }, { kind: 1, kindString: 'kind1', description: 'Text Note', category: 1 }, @@ -54,17 +52,6 @@ export const noteOptions = [ { kind: 19842, kindString: 'kind19842', description: 'Storage Metadata', category: 1 }, { kind: 19843, kindString: 'kind19843', description: 'Storage Delete', category: 1 }, ]; -export const appBuckets = [ - { id: 'nostr', label: 'Nostr' }, - { - id: 'gitnestr', - label: 'GitNestr', - }, - { - id: 'NostrBox', - label: 'NostrBox', - }, -]; export const categories = [ { id: 1, name: 'Basic Nostr Features' }, { id: 2, name: 'Extra Nostr Features' }, diff --git a/src/hooks/usePaidSubscribers.ts b/src/hooks/usePaidSubscribers.ts index ef00963e..140480db 100644 --- a/src/hooks/usePaidSubscribers.ts +++ b/src/hooks/usePaidSubscribers.ts @@ -58,6 +58,16 @@ const dummyProfiles: SubscriberProfile[] = [ // URL of the placeholder avatar that comes from the API const PLACEHOLDER_AVATAR_URL = 'http://localhost:3000/placeholder-avatar.png'; +// Global cache for subscriber data with 10-minute TTL +interface SubscriberCache { + data: SubscriberProfile[]; + timestamp: number; + hasMore: boolean; +} + +const SUBSCRIBER_CACHE_DURATION = 600000; // 10 minutes in milliseconds +const globalSubscriberCache = new Map(); + const usePaidSubscribers = (pageSize = 20) => { const [subscribers, setSubscribers] = useState([]); const [loading, setLoading] = useState(false); @@ -71,11 +81,9 @@ const usePaidSubscribers = (pageSize = 20) => { const fetchSubscribers = useCallback(async (reset = false) => { try { - console.log('[usePaidSubscribers] Starting to fetch subscribers...'); setLoading(true); const token = readToken(); if (!token) { - console.log('[usePaidSubscribers] No authentication token found, using dummy data'); setUseDummyData(true); setSubscribers(dummyProfiles); setHasMore(false); @@ -83,14 +91,24 @@ const usePaidSubscribers = (pageSize = 20) => { } const page = reset ? 1 : currentPage; + const cacheKey = `${page}-${pageSize}`; + + // Check cache first + const cached = globalSubscriberCache.get(cacheKey); + if (cached && (Date.now() - cached.timestamp) < SUBSCRIBER_CACHE_DURATION) { + setSubscribers(cached.data); + setHasMore(cached.hasMore); + setUseDummyData(false); + setCurrentPage(page + 1); + return; + } + const queryParams = new URLSearchParams({ page: page.toString(), limit: pageSize.toString(), }); const requestUrl = `${config.baseURL}/api/paid-subscriber-profiles?${queryParams}`; - console.log(`[usePaidSubscribers] Fetching from URL: ${requestUrl}`); - console.log(`[usePaidSubscribers] Current baseURL: ${config.baseURL}`); const response = await fetch(requestUrl, { headers: { @@ -99,31 +117,22 @@ const usePaidSubscribers = (pageSize = 20) => { }, }); - console.log(`[usePaidSubscribers] Response status: ${response.status}`); if (!response.ok) { if (response.status === 401) { handleLogout(); - console.log('[usePaidSubscribers] Authentication failed, using dummy data'); - return; + return; } throw new Error(`Request failed: ${response.status}`); } - // Clone the response before consuming it with json() so we can log the raw text if needed - const responseClone = response.clone(); let data: SubscriberProfile[] = []; try { data = await response.json(); - console.log('[usePaidSubscribers] Response data (raw):', data); - console.log('[usePaidSubscribers] Data constructor:', data.constructor?.name); - console.log('[usePaidSubscribers] Data properties:', Object.getOwnPropertyNames(data)); - console.log('[usePaidSubscribers] JSON.stringify(data):', JSON.stringify(data)); // Ensure data is always an array if (!Array.isArray(data)) { - console.warn('[usePaidSubscribers] Data is not an array, forcing to array format'); if (data && typeof data === 'object') { // If data is an object but not an array, try to convert it if (Object.keys(data).length > 0) { @@ -137,25 +146,17 @@ const usePaidSubscribers = (pageSize = 20) => { } } catch (jsonError) { console.error('[usePaidSubscribers] Error parsing JSON response:', jsonError); - // Try to get the raw text to see what's being returned - const rawText = await responseClone.text(); - console.log('[usePaidSubscribers] Raw response text:', rawText); data = []; } - console.log(`[usePaidSubscribers] Normalized data:`, data); - console.log(`[usePaidSubscribers] Data length: ${data?.length}, typeof data: ${typeof data}, Array.isArray(data): ${Array.isArray(data)}`); // If we have backend data, use it as the primary source and return subscribers for NDK enhancement if (data && Array.isArray(data) && data.length > 0) { - console.log(`[usePaidSubscribers] Backend data detected, using as primary source`); try { // Process the profiles to replace placeholder avatar URLs const processedProfiles: SubscriberProfile[] = []; - console.log(`[usePaidSubscribers] First item pubkey:`, data[0]?.pubkey); - console.log(`[usePaidSubscribers] First item picture:`, data[0]?.picture); for (const profile of data) { if (!profile || !profile.pubkey) { @@ -168,7 +169,6 @@ const usePaidSubscribers = (pageSize = 20) => { let pictureUrl = profile.picture; if (usesPlaceholder) { - console.log(`[usePaidSubscribers] Replacing placeholder for ${profile.pubkey}`); pictureUrl = adminDefaultAvatar; } @@ -181,8 +181,6 @@ const usePaidSubscribers = (pageSize = 20) => { }); } - console.log('[usePaidSubscribers] Backend data processed successfully'); - console.log('[usePaidSubscribers] Processed profiles count:', processedProfiles.length); // Update state with backend data setUseDummyData(false); @@ -190,7 +188,13 @@ const usePaidSubscribers = (pageSize = 20) => { setHasMore(data.length === pageSize); setCurrentPage(page + 1); - console.log('[usePaidSubscribers] Backend data set as primary source'); + // Cache the successful result + globalSubscriberCache.set(cacheKey, { + data: processedProfiles, + timestamp: Date.now(), + hasMore: data.length === pageSize + }); + return; // Exit early after processing backend data } catch (processingError) { console.error('[usePaidSubscribers] Error processing backend profiles:', processingError); @@ -200,14 +204,11 @@ const usePaidSubscribers = (pageSize = 20) => { // Fallback logic if no backend data - only use dummy data when truly no data available if (isMounted.current) { - console.log('[usePaidSubscribers] No backend data found'); // Only use dummy data if we have absolutely nothing and no existing real subscribers if (subscribers.length === 0 && !subscribers.some(s => !s.pubkey.startsWith('dummy-'))) { - console.log('[usePaidSubscribers] No existing subscribers, using dummy data as fallback'); setUseDummyData(true); setSubscribers(dummyProfiles); } else { - console.log('[usePaidSubscribers] Keeping existing subscribers data or have real subscribers'); setUseDummyData(false); } setHasMore(false); @@ -219,11 +220,9 @@ const usePaidSubscribers = (pageSize = 20) => { // Only use dummy data if we don't have any real subscribers if (subscribers.length === 0 || subscribers.every(s => s.pubkey.startsWith('dummy-'))) { - console.log(`[usePaidSubscribers] ${errorMessage}, using dummy data`); setUseDummyData(true); setSubscribers(dummyProfiles); } else { - console.log(`[usePaidSubscribers] ${errorMessage}, keeping existing real subscribers`); setUseDummyData(false); } setHasMore(false); @@ -231,20 +230,16 @@ const usePaidSubscribers = (pageSize = 20) => { if (isMounted.current) { setLoading(false); } - console.log('[usePaidSubscribers] Fetch operation completed'); } - }, [currentPage, pageSize, handleLogout]); + }, [currentPage, pageSize, handleLogout, subscribers]); useEffect(() => { - console.log('[usePaidSubscribers] Hook mounted'); return () => { - console.log('[usePaidSubscribers] Hook unmounting'); isMounted.current = false; }; }, []); useEffect(() => { - console.log('[usePaidSubscribers] Initial fetch triggered'); fetchSubscribers(true); }, [fetchSubscribers]); diff --git a/src/hooks/useRelaySettings.ts b/src/hooks/useRelaySettings.ts index 2e9c61e3..496ed4c2 100644 --- a/src/hooks/useRelaySettings.ts +++ b/src/hooks/useRelaySettings.ts @@ -3,7 +3,7 @@ import config from '@app/config/config'; import { readToken } from '@app/services/localStorage.service'; import { useHandleLogout } from './authUtils'; import { Settings } from '@app/constants/relaySettings'; -import { CORE_KINDS, ensureCoreKinds, calculateInverseKinds, getAllPossibleKinds, isCoreKind } from '@app/constants/coreKinds'; +import { CORE_KINDS, ensureCoreKinds, calculateInverseKinds, getAllPossibleKinds, isCoreKind, getAllPossibleMediaTypes, calculateInverseMediaTypes } from '@app/constants/coreKinds'; // Legacy interface - no longer used with new API // interface BackendRelaySettings { ... } @@ -17,8 +17,6 @@ const getInitialSettings = (): Settings => ({ videos: [], gitNestr: [], audio: [], - appBuckets: [], - dynamicAppBuckets: [], isKindsActive: true, isPhotosActive: true, isVideosActive: true, @@ -45,40 +43,42 @@ const useRelaySettings = () => { const token = readToken(); // Keep track of the last mode to prevent unnecessary updates - const lastMode = useRef(relaySettings.mode); + const lastMode = useRef(null); /* eslint-disable react-hooks/exhaustive-deps */ - // Effect to handle mode changes + // Effect to handle mode changes - only for manual user mode switches, not initial load useEffect(() => { + // Skip if this is the initial load (lastMode is undefined/null) + if (lastMode.current === undefined || lastMode.current === null) { + lastMode.current = relaySettings.mode; + return; + } + + // Skip if mode hasn't actually changed if (relaySettings.mode === lastMode.current) { return; } - // Only clear arrays when user manually switches TO blacklist mode - // Don't clear if we're in blacklist mode and already have blocked items (loaded from backend) + console.log(`[useRelaySettings] Mode change detected: ${lastMode.current} -> ${relaySettings.mode}`); + + // When user manually switches TO blacklist mode from whitelist mode if (relaySettings.mode === 'blacklist' && lastMode.current === 'whitelist') { - // Only clear if we don't have any blocked items (user switching, not loading from backend) - const hasBlockedItems = relaySettings.kinds.length > 0 || relaySettings.photos.length > 0 || - relaySettings.videos.length > 0 || relaySettings.audio.length > 0; - - if (!hasBlockedItems) { - // Store current settings before clearing - setPreviousSmartSettings({ - kinds: relaySettings.kinds, - photos: relaySettings.photos, - videos: relaySettings.videos, - audio: relaySettings.audio, - }); + // Store current whitelist settings before clearing + setPreviousSmartSettings({ + kinds: relaySettings.kinds, + photos: relaySettings.photos, + videos: relaySettings.videos, + audio: relaySettings.audio, + }); - // Clear selections in blacklist mode - user starts fresh and selects what to block - setRelaySettings(prev => ({ - ...prev, - kinds: [], - photos: [], - videos: [], - audio: [], - })); - } + // Always clear selections when switching to blacklist mode - user starts fresh + setRelaySettings(prev => ({ + ...prev, + kinds: [], + photos: [], + videos: [], + audio: [], + })); } else if (relaySettings.mode === 'whitelist' && lastMode.current === 'blacklist' && previousSmartSettings) { // Restore previous whitelist mode settings setRelaySettings(prev => ({ @@ -103,6 +103,12 @@ const useRelaySettings = () => { console.log('Raw backend settings:', backendData); const settings = getInitialSettings(); + // Set the mode first to avoid triggering mode change logic during initial load + if (backendData.event_filtering?.mode) { + console.log(`[useRelaySettings] Setting lastMode to ${backendData.event_filtering.mode} during data load`); + lastMode.current = backendData.event_filtering.mode; + } + // Map from actual backend structure if (backendData.event_filtering) { settings.mode = backendData.event_filtering.mode || 'whitelist'; @@ -113,9 +119,16 @@ const useRelaySettings = () => { if (settings.mode === 'blacklist') { // In blacklist mode: backend sends allowed kinds, we need to calculate blocked kinds const allPossibleKinds = getAllPossibleKinds(); - const blockedKinds = allPossibleKinds.filter(kind => !backendKinds.includes(kind)); + const backendKindsSet = new Set(backendKinds); + const allKindsSet = new Set(allPossibleKinds); + + // Calculate blocked kinds: all possible kinds minus backend allowed kinds + const blockedKinds = Array.from(allKindsSet).filter(kind => !backendKindsSet.has(kind)); // Only show non-core kinds as blocked (core kinds can't be blocked) settings.kinds = blockedKinds.filter(kind => !isCoreKind(kind)); + + console.log('[useRelaySettings] Blacklist mode - Backend allowed kinds:', backendKinds); + console.log('[useRelaySettings] Blacklist mode - Calculated blocked kinds:', settings.kinds); } else { // In whitelist mode: backend sends allowed kinds directly settings.kinds = ensureCoreKinds(backendKinds); @@ -124,9 +137,38 @@ const useRelaySettings = () => { // Extract mime types and file sizes from actual backend format const mediaDefinitions = backendData.event_filtering.media_definitions || {}; // Handle both old and new field names for backward compatibility - settings.photos = mediaDefinitions.image?.mime_patterns || mediaDefinitions.image?.mimepatterns || []; - settings.videos = mediaDefinitions.video?.mime_patterns || mediaDefinitions.video?.mimepatterns || []; - settings.audio = mediaDefinitions.audio?.mime_patterns || mediaDefinitions.audio?.mimepatterns || []; + const backendPhotos = mediaDefinitions.image?.mime_patterns || mediaDefinitions.image?.mimepatterns || []; + const backendVideos = mediaDefinitions.video?.mime_patterns || mediaDefinitions.video?.mimepatterns || []; + const backendAudio = mediaDefinitions.audio?.mime_patterns || mediaDefinitions.audio?.mimepatterns || []; + + // Handle media types based on mode (same logic as kinds) + if (settings.mode === 'blacklist') { + // In blacklist mode: backend sends allowed media types, we need to calculate blocked types + const allPossiblePhotos = getAllPossibleMediaTypes('photos'); + const allPossibleVideos = getAllPossibleMediaTypes('videos'); + const allPossibleAudio = getAllPossibleMediaTypes('audio'); + + // Use Sets for more reliable comparison + const backendPhotosSet = new Set(backendPhotos); + const backendVideosSet = new Set(backendVideos); + const backendAudioSet = new Set(backendAudio); + + settings.photos = allPossiblePhotos.filter(type => !backendPhotosSet.has(type)); + settings.videos = allPossibleVideos.filter(type => !backendVideosSet.has(type)); + settings.audio = allPossibleAudio.filter(type => !backendAudioSet.has(type)); + + console.log('[useRelaySettings] Blacklist mode - Backend allowed photos:', backendPhotos); + console.log('[useRelaySettings] Blacklist mode - Calculated blocked photos:', settings.photos); + console.log('[useRelaySettings] Blacklist mode - Backend allowed videos:', backendVideos); + console.log('[useRelaySettings] Blacklist mode - Calculated blocked videos:', settings.videos); + console.log('[useRelaySettings] Blacklist mode - Backend allowed audio:', backendAudio); + console.log('[useRelaySettings] Blacklist mode - Calculated blocked audio:', settings.audio); + } else { + // In whitelist mode: backend sends allowed media types directly + settings.photos = backendPhotos; + settings.videos = backendVideos; + settings.audio = backendAudio; + } // Extract file size limits (handle both old and new field names) settings.photoMaxSizeMB = mediaDefinitions.image?.max_size_mb || mediaDefinitions.image?.maxsizemb || 100; @@ -141,7 +183,8 @@ const useRelaySettings = () => { } } - // Store these as the previous whitelist settings if in whitelist mode + // Store these as the previous whitelist settings ONLY if in whitelist mode + // In blacklist mode, settings.kinds/photos/etc contain blocked items, not allowed items if (settings.mode === 'whitelist') { setPreviousSmartSettings({ kinds: settings.kinds, @@ -162,20 +205,33 @@ const useRelaySettings = () => { }, []); const transformToBackendSettings = useCallback((settings: Settings) => { + // Handle media types based on mode - same logic as kinds + const photoMimePatterns = settings.mode === 'blacklist' + ? calculateInverseMediaTypes(settings.photos, 'photos') // For blacklist: send inverse (all types except blocked ones) + : settings.photos; // For whitelist: send selected types directly + + const videoMimePatterns = settings.mode === 'blacklist' + ? calculateInverseMediaTypes(settings.videos, 'videos') // For blacklist: send inverse (all types except blocked ones) + : settings.videos; // For whitelist: send selected types directly + + const audioMimePatterns = settings.mode === 'blacklist' + ? calculateInverseMediaTypes(settings.audio, 'audio') // For blacklist: send inverse (all types except blocked ones) + : settings.audio; // For whitelist: send selected types directly + // Always create media definitions with correct field names to avoid backend conflicts const mediaDefinitions = { image: { - mime_patterns: settings.photos, // Only send correct field name + mime_patterns: photoMimePatterns, // Send processed mime patterns based on mode extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"], max_size_mb: settings.photoMaxSizeMB // Only send correct field name }, video: { - mime_patterns: settings.videos, // Only send correct field name + mime_patterns: videoMimePatterns, // Send processed mime patterns based on mode extensions: [".mp4", ".webm", ".avi", ".mov"], max_size_mb: settings.videoMaxSizeMB // Only send correct field name }, audio: { - mime_patterns: settings.audio, // Only send correct field name + mime_patterns: audioMimePatterns, // Send processed mime patterns based on mode extensions: [".mp3", ".wav", ".ogg", ".flac"], max_size_mb: settings.audioMaxSizeMB // Only send correct field name } diff --git a/src/pages/RelaySettingsPage.tsx b/src/pages/RelaySettingsPage.tsx index 88c1f487..8f6b9454 100644 --- a/src/pages/RelaySettingsPage.tsx +++ b/src/pages/RelaySettingsPage.tsx @@ -10,7 +10,7 @@ import { useResponsive } from '@app/hooks/useResponsive'; import useRelaySettings from '@app/hooks/useRelaySettings'; import { DesktopLayout } from '@app/components/relay-settings/layouts/DesktopLayout'; import { MobileLayout } from '@app/components/relay-settings/layouts/MobileLayout'; -import { Settings, Category } from '@app/constants/relaySettings'; +import { Settings } from '@app/constants/relaySettings'; const RelaySettingsPage: React.FC = () => { const { t } = useTranslation(); @@ -33,8 +33,6 @@ const RelaySettingsPage: React.FC = () => { videos: [], gitNestr: [], audio: [], - appBuckets: [], - dynamicAppBuckets: [], isKindsActive: true, isPhotosActive: true, isVideosActive: true, @@ -53,19 +51,6 @@ const RelaySettingsPage: React.FC = () => { JSON.parse(localStorage.getItem('dynamicKinds') || '[]'), ); - const [dynamicAppBuckets, setDynamicAppBuckets] = useState( - JSON.parse(localStorage.getItem('dynamicAppBuckets') || '[]'), - ); - - // Blacklist state - const [blacklist, setBlacklist] = useState({ - kinds: [], - photos: [], - videos: [], - gitNestr: [], - audio: [], - }); - // Fetch initial settings useEffect(() => { fetchSettings(); @@ -74,28 +59,13 @@ const RelaySettingsPage: React.FC = () => { // Sync settings with relay settings useEffect(() => { if (relaySettings) { - console.log('Raw relay settings:', relaySettings); // For debugging - - setSettings(prev => ({ + setSettings({ ...relaySettings, protocol: Array.isArray(relaySettings.protocol) ? relaySettings.protocol : [relaySettings.protocol] - })); - setDynamicAppBuckets(relaySettings.dynamicAppBuckets); + }); } }, [relaySettings]); - // Reset blacklist when mode changes - useEffect(() => { - if (settings.mode === 'blacklist') return; - setBlacklist({ - kinds: [], - photos: [], - videos: [], - gitNestr: [], - audio: [], - }); - }, [settings.mode]); - const handleModeChange = (checked: boolean) => { const newMode = checked ? 'whitelist' : 'blacklist'; setSettings(prev => ({ @@ -127,8 +97,6 @@ const RelaySettingsPage: React.FC = () => { updateSettings('audio', settings.isAudioActive ? settings.audio : []), updateSettings('protocol', settings.protocol), updateSettings('isFileStorageActive', settings.isFileStorageActive), - updateSettings('appBuckets', settings.appBuckets), - updateSettings('dynamicAppBuckets', settings.dynamicAppBuckets), updateSettings('moderationMode', settings.moderationMode), ]); @@ -153,35 +121,6 @@ const RelaySettingsPage: React.FC = () => { updateSettings('isFileStorageActive', active); }; - // App buckets handlers - const handleAppBucketsChange = (values: string[]) => { - setSettings(prev => ({ ...prev, appBuckets: values })); - updateSettings('appBuckets', values); - }; - - const handleDynamicAppBucketsChange = (values: string[]) => { - setSettings(prev => ({ ...prev, dynamicAppBuckets: values })); - updateSettings('dynamicAppBuckets', values); - }; - - const handleAddBucket = (bucket: string) => { - if (!bucket || dynamicAppBuckets.includes(bucket)) return; - - const updatedBuckets = [...dynamicAppBuckets, bucket]; - setDynamicAppBuckets(updatedBuckets); - setSettings(prev => ({ ...prev, dynamicAppBuckets: updatedBuckets })); - updateSettings('dynamicAppBuckets', updatedBuckets); - localStorage.setItem('dynamicAppBuckets', JSON.stringify(updatedBuckets)); - }; - - const handleRemoveBucket = (bucket: string) => { - const updatedBuckets = dynamicAppBuckets.filter(b => b !== bucket); - setDynamicAppBuckets(updatedBuckets); - setSettings(prev => ({ ...prev, dynamicAppBuckets: updatedBuckets })); - updateSettings('dynamicAppBuckets', updatedBuckets); - localStorage.setItem('dynamicAppBuckets', JSON.stringify(updatedBuckets)); - }; - // Kinds section handlers const handleKindsActiveChange = (active: boolean) => { setSettings(prev => ({ ...prev, isKindsActive: active })); @@ -246,13 +185,6 @@ const RelaySettingsPage: React.FC = () => { isFileStorageActive: settings.isFileStorageActive, onProtocolsChange: handleProtocolChange, onFileStorageChange: handleFileStorageChange, - // App buckets props - appBuckets: settings.appBuckets, - dynamicAppBuckets: settings.dynamicAppBuckets, - onAppBucketsChange: handleAppBucketsChange, - onDynamicAppBucketsChange: handleDynamicAppBucketsChange, - onAddBucket: handleAddBucket, - onRemoveBucket: handleRemoveBucket, // Kinds props isKindsActive: settings.isKindsActive, selectedKinds: settings.kinds, From f4d1ead1161944828b8a42b026e5d314376bd39d Mon Sep 17 00:00:00 2001 From: Maphikza Date: Fri, 4 Jul 2025 22:40:02 +0200 Subject: [PATCH 3/6] Fix dynamic kinds handling in blacklist mode - Properly include dynamic kinds in backend whitelist calculation - Separate predefined vs dynamic kinds in reconciliation logic - Fix kind formatting in AddKindForm - Improve UI labels for better UX - Show dynamic kinds in both whitelist and blacklist modes --- .../KindsSection/components/AddKindForm.tsx | 12 +++--- .../components/DynamicKindsList.tsx | 4 +- src/hooks/useRelaySettings.ts | 40 +++++++++++++++---- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx b/src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx index a2b70c66..4f1805e1 100644 --- a/src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx +++ b/src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx @@ -14,23 +14,21 @@ export const AddKindForm: React.FC = ({ onAddKind, mode }) => const handleAddKind = () => { if (newKind) { - onAddKind(newKind); + // Ensure the kind is in the correct format + const formattedKind = newKind.startsWith('kind') ? newKind : `kind${newKind}`; + onAddKind(formattedKind); setNewKind(''); } }; - if (mode === 'whitelist') { - return null; - } - return (
-

{'Add to Blacklist'}

+

{mode === 'blacklist' ? 'Add Custom Kind to Whitelist' : 'Add Custom Kind'}

setNewKind(e.target.value)} - placeholder="Enter new kind" + placeholder="Enter kind number (e.g., 12345)" /> Add Kind diff --git a/src/components/relay-settings/sections/KindsSection/components/DynamicKindsList.tsx b/src/components/relay-settings/sections/KindsSection/components/DynamicKindsList.tsx index 07c52a46..8324b961 100644 --- a/src/components/relay-settings/sections/KindsSection/components/DynamicKindsList.tsx +++ b/src/components/relay-settings/sections/KindsSection/components/DynamicKindsList.tsx @@ -21,7 +21,7 @@ export const DynamicKindsList: React.FC = ({ onRemoveKind, mode, }) => { - if (!dynamicKinds.length || mode === 'whitelist') { + if (!dynamicKinds.length) { return null; } @@ -50,7 +50,7 @@ export const DynamicKindsList: React.FC = ({ isActive={true} style={{ fontSize: '1rem', paddingRight: '.8rem', paddingLeft: '.8rem' }} > - {`kind` + kind} + {kind}
{ // Handle kinds based on mode const backendKinds = backendData.event_filtering.kind_whitelist || []; + // Get all stored dynamic kinds from localStorage + const allStoredDynamicKinds = JSON.parse(localStorage.getItem('dynamicKinds') || '[]'); + if (settings.mode === 'blacklist') { // In blacklist mode: backend sends allowed kinds, we need to calculate blocked kinds const allPossibleKinds = getAllPossibleKinds(); const backendKindsSet = new Set(backendKinds); const allKindsSet = new Set(allPossibleKinds); - // Calculate blocked kinds: all possible kinds minus backend allowed kinds - const blockedKinds = Array.from(allKindsSet).filter(kind => !backendKindsSet.has(kind)); + // Calculate blocked predefined kinds: all possible kinds minus backend allowed kinds + const blockedPredefinedKinds = Array.from(allKindsSet).filter(kind => !backendKindsSet.has(kind)); // Only show non-core kinds as blocked (core kinds can't be blocked) - settings.kinds = blockedKinds.filter(kind => !isCoreKind(kind)); + settings.kinds = blockedPredefinedKinds.filter(kind => !isCoreKind(kind)); + + // Calculate blocked dynamic kinds: stored dynamic kinds that are NOT in backend allowed kinds + settings.dynamicKinds = allStoredDynamicKinds.filter((kind: string) => !backendKindsSet.has(kind)); console.log('[useRelaySettings] Blacklist mode - Backend allowed kinds:', backendKinds); - console.log('[useRelaySettings] Blacklist mode - Calculated blocked kinds:', settings.kinds); + console.log('[useRelaySettings] Blacklist mode - Calculated blocked predefined kinds:', settings.kinds); + console.log('[useRelaySettings] Blacklist mode - Calculated blocked dynamic kinds:', settings.dynamicKinds); } else { // In whitelist mode: backend sends allowed kinds directly - settings.kinds = ensureCoreKinds(backendKinds); + // Separate predefined kinds from dynamic kinds + const allPossibleKinds = getAllPossibleKinds(); + const predefinedKinds = backendKinds.filter((kind: string) => allPossibleKinds.includes(kind)); + const dynamicKinds = backendKinds.filter((kind: string) => !allPossibleKinds.includes(kind) && allStoredDynamicKinds.includes(kind)); + + settings.kinds = ensureCoreKinds(predefinedKinds); + settings.dynamicKinds = dynamicKinds; } // Extract mime types and file sizes from actual backend format @@ -242,9 +255,20 @@ const useRelaySettings = () => { event_filtering: { mode: settings.mode, moderation_mode: settings.moderationMode, - kind_whitelist: settings.mode === 'blacklist' - ? calculateInverseKinds(settings.kinds) // For blacklist: send inverse (all kinds except blocked ones) - : ensureCoreKinds(settings.kinds), // For whitelist: send selected kinds directly + kind_whitelist: (() => { + if (settings.mode === 'blacklist') { + // For blacklist: get all predefined allowed kinds, then add unblocked dynamic kinds + const predefinedAllowed = calculateInverseKinds(settings.kinds); + // Get all stored dynamic kinds from localStorage + const allStoredDynamicKinds = JSON.parse(localStorage.getItem('dynamicKinds') || '[]'); + // Add dynamic kinds that are NOT blocked (not in settings.dynamicKinds) + const allowedDynamicKinds = allStoredDynamicKinds.filter((kind: string) => !settings.dynamicKinds.includes(kind)); + return [...predefinedAllowed, ...allowedDynamicKinds]; + } else { + // For whitelist: send all selected kinds (including dynamic) + return ensureCoreKinds([...settings.kinds, ...settings.dynamicKinds]); + } + })(), media_definitions: mediaDefinitions, dynamic_kinds: { enabled: false, From 24f41488df560417fe7cd6b44d7ef217f86d7fd5 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Sun, 6 Jul 2025 17:20:53 +0200 Subject: [PATCH 4/6] Add icon upload functionality to relay info settings - Implement dual icon upload: URL input or file upload with Blossom server integration - Add IconUpload component with drag-and-drop and URL validation - Integrate NIP-98 HTTP authentication for Blossom uploads - Add Kind 117 file metadata event publishing before upload - Update RelayInfoSettings to use new IconUpload component - Add relay_icon field to both frontend and backend type definitions - Make software and version fields read-only as per requirements - Fix form value synchronization for dynamic icon loading Icon uploads now support both direct URL entry and file uploads via Blossom server with proper Nostr authentication. --- src/components/common/IconUpload.tsx | 233 ++++++++++++++++++ src/components/settings/RelayInfoSettings.tsx | 60 ++--- src/hooks/useGenericSettings.ts | 10 +- src/react-app-env.d.ts | 18 ++ src/types/newSettings.types.ts | 1 + src/types/settings.types.ts | 1 + src/utils/blossomUpload.ts | 205 +++++++++++++++ 7 files changed, 491 insertions(+), 37 deletions(-) create mode 100644 src/components/common/IconUpload.tsx create mode 100644 src/utils/blossomUpload.ts diff --git a/src/components/common/IconUpload.tsx b/src/components/common/IconUpload.tsx new file mode 100644 index 00000000..6ff3e7de --- /dev/null +++ b/src/components/common/IconUpload.tsx @@ -0,0 +1,233 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Input, Upload, Button, message, Tabs, Avatar } from 'antd'; +import { UploadOutlined, LinkOutlined, LoadingOutlined } from '@ant-design/icons'; +import { uploadToBlossom, isValidUrl, isImageUrl } from '@app/utils/blossomUpload'; +import type { RcFile } from 'antd/es/upload/interface'; + +interface IconUploadProps { + value?: string; + onChange?: (url: string) => void; + placeholder?: string; + maxSize?: number; // in MB +} + +const IconUpload: React.FC = ({ + value = '', + onChange, + placeholder = 'https://example.com/icon.png', + maxSize = 5 +}) => { + const [uploading, setUploading] = useState(false); + const [urlInput, setUrlInput] = useState(value); + const [activeTab, setActiveTab] = useState('url'); + const fileInputRef = useRef(null); + + // Update local state when value prop changes (when form data loads) + useEffect(() => { + setUrlInput(value || ''); + }, [value]); + + // Handle URL input change + const handleUrlChange = (e: React.ChangeEvent) => { + const newUrl = e.target.value; + setUrlInput(newUrl); + + // Validate and update parent component + if (newUrl === '' || isValidUrl(newUrl)) { + onChange?.(newUrl); + } + }; + + // Handle file upload + const handleFileUpload = async (file: RcFile): Promise => { + try { + setUploading(true); + + // Validate file type + if (!file.type.startsWith('image/')) { + message.error('Please select an image file'); + return false; + } + + // Validate file size + if (file.size > maxSize * 1024 * 1024) { + message.error(`File size must be less than ${maxSize}MB`); + return false; + } + + // Upload to Blossom server + const result = await uploadToBlossom(file); + + // Update URL input and parent component + setUrlInput(result.url); + onChange?.(result.url); + + message.success('Icon uploaded successfully!'); + + // Switch to URL tab to show the uploaded URL + setActiveTab('url'); + + return true; + } catch (error) { + console.error('Upload failed:', error); + message.error(error instanceof Error ? error.message : 'Upload failed. Please try again.'); + return false; + } finally { + setUploading(false); + } + }; + + // Custom upload handler that prevents default upload behavior + const beforeUpload = (file: RcFile) => { + handleFileUpload(file); + return false; // Prevent default upload + }; + + // Handle file input change (for custom file input) + const handleFileInputChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + handleFileUpload(file as RcFile); + } + }; + + // Trigger file input click + const triggerFileInput = () => { + fileInputRef.current?.click(); + }; + + // Clear the current icon + const handleClear = () => { + setUrlInput(''); + onChange?.(''); + }; + + const tabItems = [ + { + key: 'url', + label: ( + + + URL + + ), + children: ( +
+ } + suffix={ + urlInput && ( + + ) + } + /> + {urlInput && !isValidUrl(urlInput) && ( +
+ Please enter a valid URL +
+ )} + {urlInput && isValidUrl(urlInput) && !isImageUrl(urlInput) && ( +
+ Warning: URL may not point to an image file +
+ )} +
+ ) + }, + { + key: 'upload', + label: ( + + {uploading ? : } + Upload + + ), + children: ( +
+ + + +
+ {uploading ? ( + <> + +

Uploading to Blossom server...

+ + ) : ( + <> + +

Click or drag image to upload

+

+ Supports: JPG, PNG, GIF, WebP (max {maxSize}MB) +

+ + )} +
+
+ +
OR
+ + +
+ ) + } + ]; + + return ( +
+ + + {/* Preview */} + {urlInput && isValidUrl(urlInput) && ( +
+
+ Preview: +
+ +
+ )} +
+ ); +}; + +export default IconUpload; \ No newline at end of file diff --git a/src/components/settings/RelayInfoSettings.tsx b/src/components/settings/RelayInfoSettings.tsx index 19d617a1..c2425a7e 100644 --- a/src/components/settings/RelayInfoSettings.tsx +++ b/src/components/settings/RelayInfoSettings.tsx @@ -1,23 +1,21 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { Form, Input, Select, Tooltip } from 'antd'; import { QuestionCircleOutlined, InfoCircleOutlined, UserOutlined, KeyOutlined, - UploadOutlined, } from '@ant-design/icons'; +import IconUpload from '@app/components/common/IconUpload'; import useGenericSettings from '@app/hooks/useGenericSettings'; import { SettingsGroupType } from '@app/types/settings.types'; import BaseSettingsForm from './BaseSettingsForm'; import * as S from './Settings.styles'; -import { ComingSoonWrapper } from '@app/styles/themes/reusableComponentStyles'; const { Option } = Select; const { TextArea } = Input; const RelayInfoSettings: React.FC = () => { const { settings, loading, error, fetchSettings, updateSettings, saveSettings } = useGenericSettings('relay_info'); - const [image, setImage] = useState(null); const [form] = Form.useForm(); // Update form values when settings change @@ -84,34 +82,22 @@ const RelayInfoSettings: React.FC = () => { > } placeholder="My Nostr Relay" /> - - - Relay Icon  - - - -  (Coming Soon) - - } - > - { - } - // > Upload Relay Icon} - /> - } - {image && ( - - - - )} - - + + Relay Icon  + + + + + } + > + + { label={ Relay Software  - + +  (Read-only) } > - + { label={ Version  - + +  (Read-only) } > - + { 'relayname': 'name', 'relaydescription': 'description', 'relaycontact': 'contact', + 'relayicon': 'icon', 'relaypubkey': 'public_key', // Backend sends 'public_key' 'relaydhtkey': 'dht_key', 'relaysoftware': 'software', @@ -181,11 +182,17 @@ const extractSettingsForGroup = (settings: any, groupName: string) => { Object.entries(relayInfoMappings).forEach(([frontendKey, backendKey]) => { if (rawData[backendKey] !== undefined) { processedData[frontendKey] = rawData[backendKey]; + if (frontendKey === 'relayicon') { + console.log(`Icon mapping: ${frontendKey} = ${rawData[backendKey]}`); + } } else { // Set default values for missing fields if (frontendKey === 'relaysupportednips') { processedData[frontendKey] = []; // Default empty array } + if (frontendKey === 'relayicon') { + console.log(`Icon field '${backendKey}' not found in rawData:`, Object.keys(rawData)); + } } }); @@ -255,7 +262,8 @@ const buildNestedUpdate = (groupName: string, data: any) => { const relayFieldMappings: Record = { 'name': 'relayname', 'description': 'relaydescription', - 'contact': 'relaycontact', + 'contact': 'relaycontact', + 'icon': 'relayicon', 'public_key': 'relaypubkey', // Frontend 'relaypubkey' -> backend 'public_key' 'dht_key': 'relaydhtkey', 'software': 'relaysoftware', diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 6431bc5f..930e61b5 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -1 +1,19 @@ /// + +declare global { + interface Window { + nostr?: { + getPublicKey: () => Promise; + signEvent: (event: any) => Promise; + getRelays?: () => Promise>; + nip04?: { + encrypt?: (pubkey: string, content: string) => Promise; + decrypt?: (pubkey: string, content: string) => Promise; + }; + nip44?: { + encrypt?: (pubkey: string, content: string) => Promise; + decrypt?: (pubkey: string, content: string) => Promise; + }; + }; + } +} diff --git a/src/types/newSettings.types.ts b/src/types/newSettings.types.ts index 2907cc49..d4466c9d 100644 --- a/src/types/newSettings.types.ts +++ b/src/types/newSettings.types.ts @@ -120,6 +120,7 @@ export interface RelayConfig { relay_description: string; relay_pubkey: string; relay_contact: string; + relay_icon: string; relay_software: string; relay_version: string; relay_supported_nips: number[]; diff --git a/src/types/settings.types.ts b/src/types/settings.types.ts index bb75649e..be49f40c 100644 --- a/src/types/settings.types.ts +++ b/src/types/settings.types.ts @@ -64,6 +64,7 @@ export interface RelayInfoSettings { relaycontact: string; relaydescription: string; relaydhtkey: string; + relayicon: string; relayname: string; relaypubkey: string; relaysoftware: string; diff --git a/src/utils/blossomUpload.ts b/src/utils/blossomUpload.ts new file mode 100644 index 00000000..c6a303ca --- /dev/null +++ b/src/utils/blossomUpload.ts @@ -0,0 +1,205 @@ +import config from '@app/config/config'; +import NDK, { NDKEvent } from '@nostr-dev-kit/ndk'; + +export interface BlossomUploadResult { + url: string; + hash: string; +} + +/** + * Calculate SHA-256 hash of a file + */ +export const calculateFileHash = async (file: File): Promise => { + const arrayBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +}; + +/** + * Create and sign NIP-98 authorization event for Blossom upload + */ +export const createNIP98AuthEvent = async ( + method: string, + absoluteUrl: string, + fileHash?: string +): Promise => { + if (!window.nostr) { + throw new Error('Nostr extension is not available. Please install a Nostr browser extension (Alby, nos2x, etc.)'); + } + + try { + // Get the user's public key + const pubkey = await window.nostr.getPublicKey(); + + const timestamp = Math.floor(Date.now() / 1000); + + // Create the unsigned event according to NIP-98 spec + const unsignedEvent = { + kind: 27235, // NIP-98 HTTP Auth + created_at: timestamp, + tags: [ + ['u', absoluteUrl], // MUST be absolute URL + ['method', method.toUpperCase()], // MUST be uppercase HTTP method + ...(fileHash ? [['payload', fileHash]] : []) // SHA256 hash for PUT/POST with body + ], + content: '', // SHOULD be empty + pubkey: pubkey, + }; + + console.log('Creating NIP-98 auth event with URL:', absoluteUrl); + console.log('Full NIP-98 event:', unsignedEvent); + console.log('NIP-98 event tags:', unsignedEvent.tags); + + // Sign the event using the browser extension + const signedEvent = await window.nostr.signEvent(unsignedEvent); + console.log('Signed NIP-98 event:', signedEvent); + + return signedEvent; + } catch (error) { + console.error('Failed to create/sign NIP-98 auth event:', error); + throw new Error('Failed to sign authorization event. Please check your Nostr extension.'); + } +}; + +/** + * Publish Kind 117 file metadata event to relay before upload + */ +export const publishKind117Event = async ( + file: File, + fileHash: string +): Promise => { + if (!window.nostr) { + throw new Error('Nostr extension is not available. Please install a Nostr browser extension (Alby, nos2x, etc.)'); + } + + try { + // Get the user's public key + const pubkey = await window.nostr.getPublicKey(); + + // Create Kind 117 file metadata event + const kind117Event = { + kind: 117, + created_at: Math.floor(Date.now() / 1000), + content: 'Relay icon upload', + tags: [ + ['blossom_hash', fileHash], + ['name', file.name], + ['size', file.size.toString()], + ['type', file.type] + ], + pubkey: pubkey, + }; + + console.log('Creating Kind 117 event:', kind117Event); + + // Sign the event using browser extension + const signedKind117 = await window.nostr.signEvent(kind117Event); + console.log('Signed Kind 117 event:', signedKind117); + + // Create NDK instance for publishing + const ndk = new NDK({ + explicitRelayUrls: config.ownRelayUrl ? [config.ownRelayUrl] : config.nostrRelayUrls, + }); + + await ndk.connect(); + + // Create NDK event from signed event + const ndkEvent = new NDKEvent(ndk, signedKind117); + + // Publish to relay + await ndkEvent.publish(); + console.log('Kind 117 event published successfully'); + + // Wait for event to be processed + await new Promise(resolve => setTimeout(resolve, 1000)); + + } catch (error) { + console.error('Failed to publish Kind 117 event:', error); + throw new Error('Failed to publish file metadata event. Please try again.'); + } +}; + +/** + * Upload file to Blossom server + */ +export const uploadToBlossom = async (file: File): Promise => { + // Validate file type + if (!file.type.startsWith('image/')) { + throw new Error('Please select an image file'); + } + + // Validate file size (max 5MB) + if (file.size > 5 * 1024 * 1024) { + throw new Error('File size must be less than 5MB'); + } + + try { + // 1. Calculate SHA-256 hash of the file for both Blossom and NIP-98 + const hash = await calculateFileHash(file); + + // 2. FIRST: Publish Kind 117 file metadata event to relay + console.log('Publishing Kind 117 event...'); + await publishKind117Event(file, hash); + + // 3. Create the upload URL using the exact base URL from config + const uploadUrl = `${config.baseURL}/blossom/upload`; + + console.log('Config baseURL:', config.baseURL); + console.log('Final upload URL:', uploadUrl); + + // 4. Create NIP-98 authorization event with the EXACT same URL that will be fetched + const authEvent = await createNIP98AuthEvent('PUT', uploadUrl, hash); + + // 5. Upload to Blossom server using the same URL + console.log('Uploading file to Blossom server...'); + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Authorization': `Nostr ${btoa(JSON.stringify(authEvent))}`, + 'Content-Type': file.type, + }, + body: file, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Upload failed: ${response.status} ${errorText}`); + } + + // 5. Return the Blossom URL + const blossomUrl = `${config.baseURL}/blossom/${hash}`; + + return { + url: blossomUrl, + hash + }; + } catch (error) { + console.error('Blossom upload failed:', error); + throw error instanceof Error ? error : new Error('Upload failed'); + } +}; + +/** + * Validate if a string is a valid URL + */ +export const isValidUrl = (urlString: string): boolean => { + try { + new URL(urlString); + return true; + } catch { + return false; + } +}; + +/** + * Validate if a URL points to an image + */ +export const isImageUrl = (url: string): boolean => { + if (!isValidUrl(url)) return false; + + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']; + const urlPath = new URL(url).pathname.toLowerCase(); + + return imageExtensions.some(ext => urlPath.endsWith(ext)); +}; \ No newline at end of file From 7bf2e9a1b3b98f99973bada4cb91d28e9ad962bc Mon Sep 17 00:00:00 2001 From: Maphikza Date: Sun, 6 Jul 2025 17:54:48 +0200 Subject: [PATCH 5/6] Implement dynamic URL detection for true deployment portability Replace hardcoded environment variables with runtime URL detection using window.location.origin. This eliminates the need for environment-specific builds and makes the panel deployable anywhere. Key improvements: - Panel API URLs auto-detected from current domain in production - Wallet service URLs auto-detected from current domain in production - Relay WebSocket URLs auto-detected from current domain in production - One build now works on ANY domain (localhost, custom domains, ngrok tunnels) - No more REACT_APP_BASE_URL or REACT_APP_WALLET_BASE_URL needed in production - Simplified deployment process with minimal environment configuration - Updated README with comprehensive deployment guidance and launch-ready improvements Production URLs now resolve to: - Panel API: ${window.location.origin}/panel - Wallet API: ${window.location.origin}/wallet - Relay WebSocket: wss://${window.location.host} Development mode still uses localhost URLs for easy local development. --- README.md | 160 ++++++++++++++++++++++++++++++++++--------- src/config/config.ts | 47 +++++++++++-- 2 files changed, 167 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 90d7d13e..b905ae28 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,15 @@ This repository is home to the hornet storage panel which is a typescript / react web application designed for managing a hornet storage nostr multimedia relay which can be found here: https://github.com/HORNET-Storage/HORNETS-Nostr-Relay +## ⚑ What You Need Before Starting + +**Before installing, ensure you have:** +1. **A Nostr browser extension** ([Alby](https://getalby.com/), [nos2x](https://github.com/fiatjaf/nos2x), etc.) - **REQUIRED** +2. **Node.js 16+** and **yarn** installed +3. **The HORNETS relay service** running (see [here](https://github.com/HORNET-Storage/HORNETS-Nostr-Relay)) + +**Without these, the panel will not function.** + ### Live Demo We have a live demo that can be found at http://hornetstorage.net for anyone that wants to see what the panel looks like. @@ -14,6 +23,36 @@ We have a live demo that can be found at http://hornetstorage.net for anyone tha - Choose which supported transport protocols to enable such as libp2p and websockets - Enable / disable which media extensions are accepted by the relay such as png and mp4 - View statistics about stored notes and media +- Upload relay icons with integrated Blossom server support + +## πŸ”‘ Important Prerequisites + +### NIP-07 Browser Extension Required +**The HORNETS Relay Panel requires a NIP-07 compatible Nostr browser extension to function.** + +You must install one of these browser extensions before using the panel: +- **[Alby](https://getalby.com/)** - Bitcoin Lightning & Nostr browser extension +- **[nos2x](https://github.com/fiatjaf/nos2x)** - Simple Nostr browser extension +- **[Flamingo](https://flamingo.me/)** - Nostr browser extension +- **[Horse](https://github.com/freakonometrics/horse)** - Nostr browser extension + +The panel uses **NIP-07** ([window.nostr capability](https://nostr-nips.com/nip-07)) for: +- User authentication and login +- Event signing for relay configuration +- File uploads with cryptographic verification + +**πŸ“– Learn more about NIP-07**: [https://nostr-nips.com/nip-07](https://nostr-nips.com/nip-07) + +## πŸš€ Quick Start + +**Essential steps to get running:** + +1. **Install a NIP-07 browser extension** (required - see above) +2. **Install dependencies**: `npm install -g serve` and `yarn install` +3. **Start development**: `yarn start` +4. **For production**: `yarn build` then `serve -s build` + +**For full deployment with reverse proxy, see the detailed setup guide below.** ## Previews *All preview images are taken from the live demo* @@ -32,18 +71,18 @@ We have a live demo that can be found at http://hornetstorage.net for anyone tha The HORNETS Relay Panel is built with a microservices architecture comprising: -### Services +### Services & Dependencies - **Frontend (React App)**: Port 3000 (dev) - The admin dashboard interface - **Panel API**: Port 9002 - Backend service for panel operations -- **Relay Service**: Port 9001 - WebSocket service for Nostr relay functionality -- **Wallet Service**: Port 9003 - Backend service for wallet operations -- **Transcribe API**: Port 8000 - Service for transcription features +- **[Relay Service](https://github.com/HORNET-Storage/HORNETS-Nostr-Relay)**: Port 9001 - WebSocket service for Nostr relay functionality +- **[Wallet Service](https://github.com/HORNET-Storage/Super-Neutrino-Wallet)**: Port 9003 - Backend service for wallet operations +- **[Media Moderation](https://github.com/HORNET-Storage/NestShield)**: Port 8000 - Content moderation and filtering service ### Reverse Proxy Architecture ``` Client Request ↓ -Nginx (Port 80/443) +Nginx (Port 80/443) ↓ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Route Distribution: β”‚ @@ -52,8 +91,8 @@ Nginx (Port 80/443) β”‚ β”‚ (Port 9001) β”‚ β”‚ (Port 3000) β”‚ β”‚ (Port 9002) β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ /wallet/ β†’ Walletβ”‚ β”‚/transcribe/ β†’ APIβ”‚ β”‚ -β”‚ β”‚ (Port 9003) β”‚ β”‚ (Port 8000) β”‚ β”‚ +β”‚ β”‚ /wallet/ β†’ Walletβ”‚ β”‚/moderate/ β†’ Mediaβ”‚ β”‚ +β”‚ β”‚ (Port 9003) β”‚ β”‚ (Port 8000) β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` @@ -82,12 +121,16 @@ While possible, direct port access has limitations: - [Node.js](https://nodejs.org/en/) version **>=16.0.0** - [Yarn](https://yarnpkg.com/) package manager - [Git](https://git-scm.com/) for version control +- **[serve](https://www.npmjs.com/package/serve)** for production builds: `npm install -g serve` ### Optional (For Production) -- [Nginx](https://nginx.org/) for reverse proxy +- [Nginx](https://nginx.org/) for reverse proxy *(Linux server configuration)* - SSL certificate (Let's Encrypt recommended) - Domain name +### Browser Requirements +- **NIP-07 compatible browser extension** (see Important Prerequisites section above) + ## πŸ› οΈ Installation & Setup ### 1. Clone the Repository @@ -113,28 +156,34 @@ REACT_APP_DEMO_MODE=false ``` #### Production Setup -Copy the example environment file and customize: +For production, minimal environment configuration is needed thanks to **dynamic URL detection**: + ```bash cp .env.production.example .env.production ``` -Edit `.env.production` with your actual values: +Edit `.env.production` (most values are now auto-detected): ```env -# Production Environment Configuration -REACT_APP_BASE_URL=https://your-domain.com/panel -REACT_APP_WALLET_BASE_URL=https://your-domain.com/wallet -REACT_APP_ASSETS_BUCKET=https://your-domain.com -REACT_APP_DEMO_MODE=false - # Router configuration for reverse proxy REACT_APP_BASENAME=/front PUBLIC_URL=/front +# Optional: Demo mode (defaults to false) +REACT_APP_DEMO_MODE=false + +# Optional: Custom Nostr relay URLs (defaults to popular relays) +# REACT_APP_NOSTR_RELAY_URLS=wss://relay.damus.io,wss://relay.nostr.band + # Development optimizations ESLINT_NO_DEV_ERRORS=true TSC_COMPILE_ON_ERROR=true ``` +**🎯 Key Improvement**: The panel now **automatically detects** API URLs from `window.location.origin`, meaning: +- βœ… **No need to specify `REACT_APP_BASE_URL` or `REACT_APP_WALLET_BASE_URL`** +- βœ… **Same build works on ANY domain** (localhost, your-domain.com, ngrok tunnels, etc.) +- βœ… **No environment-specific rebuilds required** + ### 4. Start Development Server #### Using yarn (standard) @@ -200,10 +249,20 @@ server { proxy_pass http://127.0.0.1:9003; } + # Media moderation service (optional) + location /moderate/ { + rewrite ^/moderate/(.*)$ /$1 break; + proxy_pass http://127.0.0.1:8000; + } + # Frontend React app location /front/ { rewrite ^/front/(.*)$ /$1 break; - proxy_pass http://127.0.0.1:3000; # Or serve static files + proxy_pass http://127.0.0.1:3000; # Development: proxy to dev server + + # Production: Serve static files instead (uncomment and comment above) + # try_files $uri $uri/ /front/index.html; + # root /var/www/html; # Path to your built files } # Default location - Relay service with WebSocket support @@ -249,10 +308,10 @@ sudo cp -r build/* /var/www/html/front/ Ensure all backend services are running: ```bash # Start in order of dependency -./relay-service & # Port 9001 -./panel-api & # Port 9002 -./wallet-service & # Port 9003 -./transcribe-api & # Port 8000 +./relay-service & # Port 9001 +./panel-api & # Port 9002 +./wallet-service & # Port 9003 +./media-moderation & # Port 8000 (optional) ``` #### Step 5: Start Nginx @@ -268,8 +327,7 @@ Update `.env.production`: ```env REACT_APP_BASENAME= PUBLIC_URL= -REACT_APP_BASE_URL=http://localhost:9002 -REACT_APP_WALLET_BASE_URL=http://localhost:9003 +# Note: API URLs are now auto-detected, no need to specify them! ``` #### Step 2: Build and Serve @@ -303,14 +361,18 @@ ngrok http 3000 ``` ### Environment Variables for Tunneling -When using tunnels, update your `.env.production`: -```env -REACT_APP_BASE_URL=https://your-tunnel-url.com/panel -REACT_APP_WALLET_BASE_URL=https://your-tunnel-url.com/wallet -``` +**Great news!** Thanks to dynamic URL detection, **no environment variable changes are needed** when using tunnels. The panel automatically adapts to any domain: + +- βœ… `ngrok http 80` β†’ Panel works immediately at `https://abc123.ngrok.io/front/` +- βœ… Custom domain tunnel β†’ Panel works immediately +- βœ… Any hosting provider β†’ Panel works immediately + +**No rebuilds or environment changes required!** ## πŸ”§ Configuration Options +> **πŸš€ Major Improvement**: The panel now uses **dynamic URL detection** instead of hardcoded environment variables. This means **one build works everywhere** - no more environment-specific builds or complex URL configuration! + ### REACT_APP_BASENAME Controls where the React app is served from: - `/front` - App accessible at `https://domain.com/front/` @@ -318,9 +380,15 @@ Controls where the React app is served from: - `` (empty) - App accessible at `https://domain.com/` ### Service URLs -- **REACT_APP_BASE_URL**: Panel API endpoint -- **REACT_APP_WALLET_BASE_URL**: Wallet service endpoint -- **REACT_APP_ASSETS_BUCKET**: Static assets URL +**🎯 Auto-Detection**: Service URLs are now automatically detected in production: +- **Panel API**: `${window.location.origin}/panel` (auto-detected) +- **Wallet Service**: `${window.location.origin}/wallet` (auto-detected) +- **Relay WebSocket**: `wss://${window.location.host}` (auto-detected) + +**Manual Override** (development only): +- **REACT_APP_BASE_URL**: Panel API endpoint (dev mode only) +- **REACT_APP_WALLET_BASE_URL**: Wallet service endpoint (dev mode only) +- **REACT_APP_NOSTR_RELAY_URLS**: Additional Nostr relays (optional) ### Demo Mode Set `REACT_APP_DEMO_MODE=true` to enable demo functionality with mock data. @@ -344,6 +412,27 @@ export NODE_OPTIONS="--openssl-legacy-provider --max-old-space-size=4096" **Error**: Network errors or 404s **Solution**: Verify service URLs in environment variables and ensure backend services are running. +#### 3.1. CORS Configuration Issues +**Error**: `Access to fetch at 'X' from origin 'Y' has been blocked by CORS policy` +**Solution**: Ensure your backend services are configured to accept requests from your frontend origin: + +For development with direct access: +```env +# Frontend running on http://localhost:3000 +# Backend services must allow this origin in their CORS configuration +REACT_APP_BASE_URL=http://localhost:9002 +REACT_APP_WALLET_BASE_URL=http://localhost:9003 +``` + +For production with reverse proxy (recommended): +```env +# All services behind same domain - no CORS issues +REACT_APP_BASE_URL=https://your-domain.com/panel +REACT_APP_WALLET_BASE_URL=https://your-domain.com/wallet +``` + +**Note**: When using direct port access, each backend service must be configured to allow your frontend's origin in their CORS settings. Using a reverse proxy eliminates CORS issues entirely. + #### 4. Routing Issues with Reverse Proxy **Error**: 404 on refresh or direct URL access **Solution**: Configure nginx to handle React Router: @@ -366,7 +455,7 @@ Start services in this order: 1. Relay Service (Port 9001) - Core WebSocket functionality 2. Panel API (Port 9002) - Main backend 3. Wallet Service (Port 9003) - Payment processing -4. Transcribe API (Port 8000) - Additional features +4. Media Moderation (Port 8000) - Content filtering (optional) 5. Frontend (Port 3000) - User interface ### Health Checks @@ -454,4 +543,9 @@ For issues and support: --- -**Note**: This panel is designed to work with the [HORNETS Nostr Relay](https://github.com/HORNET-Storage/HORNETS-Nostr-Relay). Ensure you have the relay service running for full functionality. \ No newline at end of file +**Note**: This panel is designed to work with the HORNETS Storage ecosystem: +- **[HORNETS Nostr Relay](https://github.com/HORNET-Storage/HORNETS-Nostr-Relay)** - Core relay service (required) +- **[Super Neutrino Wallet](https://github.com/HORNET-Storage/Super-Neutrino-Wallet)** - Payment processing (required for paid features) +- **[NestShield](https://github.com/HORNET-Storage/NestShield)** - Media moderation service (optional) + +Ensure you have at minimum the relay service running for basic functionality. \ No newline at end of file diff --git a/src/config/config.ts b/src/config/config.ts index fce48ba0..2a9553c4 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,12 +1,41 @@ // config.ts + +// Dynamic URL detection - panel works from anywhere! +const getBaseURL = (): string => { + // Demo mode override for testing + if (process.env.REACT_APP_DEMO_MODE === 'true') { + return 'http://localhost:10002'; + } + + // Development mode - use localhost + if (process.env.NODE_ENV === 'development') { + return process.env.REACT_APP_BASE_URL || 'http://localhost:9002'; + } + + // Production - use current origin + /panel path + // This makes the panel work from ANY domain without rebuilding + return `${window.location.origin}/panel`; +}; + +const getWalletURL = (): string => { + // Demo mode override for testing + if (process.env.REACT_APP_DEMO_MODE === 'true') { + return 'http://localhost:9003'; + } + + // Development mode - use localhost + if (process.env.NODE_ENV === 'development') { + return process.env.REACT_APP_WALLET_BASE_URL?.trim() || 'http://localhost:9003'; + } + + // Production - use current origin + /wallet path + return `${window.location.origin}/wallet`; +}; + const config = { - baseURL: process.env.REACT_APP_DEMO_MODE === 'true' - ? 'http://localhost:10002' - : process.env.NODE_ENV === 'production' - ? process.env.REACT_APP_BASE_URL || 'http://localhost:9002' - : process.env.REACT_APP_BASE_URL || 'http://localhost:9002', + baseURL: getBaseURL(), isDemoMode: process.env.REACT_APP_DEMO_MODE === 'true', - walletBaseURL: process.env.REACT_APP_WALLET_BASE_URL?.trim() || 'http://localhost:9003', + walletBaseURL: getWalletURL(), // Nostr relay configuration nostrRelayUrls: process.env.REACT_APP_NOSTR_RELAY_URLS?.split(',').map(url => url.trim()) || [ @@ -17,7 +46,11 @@ const config = { ], // User's own relay URL (primary relay for profile fetching) - ownRelayUrl: process.env.REACT_APP_OWN_RELAY_URL?.trim() || null, + // In production, use the current domain as the relay WebSocket URL + ownRelayUrl: process.env.REACT_APP_OWN_RELAY_URL?.trim() || + (process.env.NODE_ENV === 'production' + ? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}` + : null), // Notification settings notifications: { From 13da4a8d63e39c26bea59bb2292ef3051723ab20 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Sun, 6 Jul 2025 18:12:20 +0200 Subject: [PATCH 6/6] Add Kind 117 (Blossom file metadata) to core kinds Kind 117 events are required for Blossom file uploads to work properly. Without this kind in the relay's whitelist, users cannot upload files such as relay icons, as the Kind 117 metadata events would be rejected. Changes: - Added kind117 to CORE_KINDS array (cannot be removed by users) - Added Kind 117 to noteOptions with description 'Blossom File Metadata' - Ensures file upload functionality works out of the box --- src/constants/coreKinds.ts | 1 + src/constants/relaySettings.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/constants/coreKinds.ts b/src/constants/coreKinds.ts index fb6bcac9..4d6983b3 100644 --- a/src/constants/coreKinds.ts +++ b/src/constants/coreKinds.ts @@ -3,6 +3,7 @@ export const CORE_KINDS = [ 'kind0', // User profiles - Required for user management (CRITICAL - relay unusable without) 'kind22242', // Auth events - Required for NIP-42 authentication 'kind10010', // Mute list - Required for content filtering/mute words + 'kind117', // Blossom file metadata - Required for file uploads (relay icons, media uploads) 'kind19841', // Storage manifest - Required for file tracking 'kind19842', // Storage metadata - Required for file info 'kind19843', // Storage delete - Required for file cleanup diff --git a/src/constants/relaySettings.ts b/src/constants/relaySettings.ts index 19a84c40..97314ba8 100644 --- a/src/constants/relaySettings.ts +++ b/src/constants/relaySettings.ts @@ -48,6 +48,7 @@ export const noteOptions = [ // Core kinds essential for relay operation { kind: 10010, kindString: 'kind10010', description: 'Content Filtering', category: 1 }, { kind: 22242, kindString: 'kind22242', description: 'NIP-42 Auth Events', category: 1 }, + { kind: 117, kindString: 'kind117', description: 'Blossom File Metadata', category: 1 }, { kind: 19841, kindString: 'kind19841', description: 'Storage Manifest', category: 1 }, { kind: 19842, kindString: 'kind19842', description: 'Storage Metadata', category: 1 }, { kind: 19843, kindString: 'kind19843', description: 'Storage Delete', category: 1 },