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/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/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/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 726f277a..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, @@ -97,6 +82,7 @@ export const DesktopLayout: React.FC = ({ onKindsActiveChange, onKindsChange, onDynamicKindsChange, + onAddKind, onRemoveKind, // Media props photos, @@ -124,14 +110,6 @@ export const DesktopLayout: React.FC = ({ onFileStorageChange={onFileStorageChange} /> - = ({ 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..d685954e 100644 --- a/src/components/relay-settings/layouts/MobileLayout.tsx +++ b/src/components/relay-settings/layouts/MobileLayout.tsx @@ -6,7 +6,6 @@ import { BaseCol } from '@app/components/common/BaseCol/BaseCol'; import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; 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'; @@ -22,13 +21,6 @@ interface MobileLayoutProps { 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[]; @@ -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, @@ -94,6 +79,7 @@ export const MobileLayout: React.FC = ({ onKindsActiveChange, onKindsChange, onDynamicKindsChange, + onAddKind, onRemoveKind, // Media props photos, @@ -119,15 +105,6 @@ export const MobileLayout: React.FC = ({ onFileStorageChange={onFileStorageChange} /> - - = ({ onKindsActiveChange={onKindsActiveChange} onKindsChange={onKindsChange} onDynamicKindsChange={onDynamicKindsChange} + onAddKind={onAddKind} onRemoveKind={onRemoveKind} /> diff --git a/src/components/relay-settings/sections/AppBucketsSection/AppBucketsSection.tsx b/src/components/relay-settings/sections/AppBucketsSection/AppBucketsSection.tsx deleted file mode 100644 index 7ef8ba4b..00000000 --- a/src/components/relay-settings/sections/AppBucketsSection/AppBucketsSection.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// src/components/relay-settings/sections/AppBucketsSection/AppBucketsSection.tsx - -import React from 'react'; -import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; -import { themeObject } from '@app/styles/themes/themeVariables'; -import { useAppSelector } from '@app/hooks/reduxHooks'; -import { CollapsibleSection } from '../../shared/CollapsibleSection/CollapsibleSection'; -import { BucketsList } from './components/BucketsList'; -import { AddBucketForm } from './components/AddBucketForm'; -import { DynamicBucketsList } from './components/DynamicBucketsList'; - -export interface AppBucketsSectionProps { - appBuckets: string[]; - dynamicAppBuckets: string[]; - onAppBucketsChange: (values: string[]) => 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/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) { + // Ensure the kind is in the correct format + const formattedKind = newKind.startsWith('kind') ? newKind : `kind${newKind}`; + onAddKind(formattedKind); + setNewKind(''); + } + }; + + return ( +
+

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

+
+ setNewKind(e.target.value)} + placeholder="Enter kind number (e.g., 12345)" + /> + + Add Kind + +
+
+ ); +}; + +export default AddKindForm; \ No newline at end of file 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} { 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) } > - + { + // 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: { diff --git a/src/constants/coreKinds.ts b/src/constants/coreKinds.ts index 9fb173b7..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 @@ -22,4 +23,52 @@ 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('./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; +}; + +// 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..97314ba8 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 }, @@ -50,21 +48,11 @@ 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 }, ]; -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/useGenericSettings.ts b/src/hooks/useGenericSettings.ts index 3eb826ce..1900a339 100644 --- a/src/hooks/useGenericSettings.ts +++ b/src/hooks/useGenericSettings.ts @@ -170,6 +170,7 @@ const extractSettingsForGroup = (settings: any, groupName: string) => { '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/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 6f12db6b..4ff8f7e1 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, 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,19 +43,27 @@ 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; } - lastMode.current = relaySettings.mode; + console.log(`[useRelaySettings] Mode change detected: ${lastMode.current} -> ${relaySettings.mode}`); - if (relaySettings.mode === 'blacklist') { - // Store current settings before clearing + // When user manually switches TO blacklist mode from whitelist mode + if (relaySettings.mode === 'blacklist' && lastMode.current === 'whitelist') { + // Store current whitelist settings before clearing setPreviousSmartSettings({ kinds: relaySettings.kinds, photos: relaySettings.photos, @@ -65,6 +71,7 @@ const useRelaySettings = () => { audio: relaySettings.audio, }); + // Always clear selections when switching to blacklist mode - user starts fresh setRelaySettings(prev => ({ ...prev, kinds: [], @@ -72,7 +79,7 @@ const useRelaySettings = () => { videos: [], audio: [], })); - } else if (relaySettings.mode === 'whitelist' && previousSmartSettings) { + } 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 */ @@ -93,20 +103,85 @@ 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'; 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); + + // 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 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 = 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 predefined kinds:', settings.kinds); + console.log('[useRelaySettings] Blacklist mode - Calculated blocked dynamic kinds:', settings.dynamicKinds); + } else { + // In whitelist mode: backend sends allowed kinds directly + // 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 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; @@ -121,7 +196,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, @@ -142,20 +218,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 } @@ -166,7 +255,20 @@ const useRelaySettings = () => { event_filtering: { mode: settings.mode, moderation_mode: settings.moderationMode, - kind_whitelist: ensureCoreKinds(settings.kinds), // Always include core kinds + 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, 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, 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