diff --git a/.env.development b/.env.development index 1b5c435d..5d22305c 100644 --- a/.env.development +++ b/.env.development @@ -4,9 +4,7 @@ REACT_APP_ASSETS_BUCKET=http://localhost REACT_APP_DEMO_MODE=false REACT_APP_BASENAME= -# Nostr relay configuration for profile fetching -REACT_APP_OWN_RELAY_URL=ws://localhost:9001 -# REACT_APP_NOSTR_RELAY_URLS=wss://your-relay1.com,wss://your-relay2.com,wss://your-relay3.com +# Nostr operations now use panel API - no relay URLs needed # More info https://create-react-app.dev/docs/advanced-configuration ESLINT_NO_DEV_ERRORS=true diff --git a/.env.production b/.env.production index e7eba346..1accd809 100644 --- a/.env.production +++ b/.env.production @@ -4,19 +4,16 @@ # Demo mode (set to false for production) REACT_APP_DEMO_MODE=false -# Direct wallet service access -REACT_APP_WALLET_BASE_URL= +# Wallet operations now routed through panel API - no direct URL needed # Router configuration (empty since served from relay server root) REACT_APP_BASENAME= -PUBLIC_URL= +PUBLIC_URL=/ # Asset serving configuration (optional - adjust for your domain) # REACT_APP_ASSETS_BUCKET=https://your-domain.com -# Nostr relay configuration for profile fetching -REACT_APP_OWN_RELAY_URL=ws://localhost:9001 -# REACT_APP_NOSTR_RELAY_URLS=wss://your-relay1.com,wss://your-relay2.com +# Nostr operations now use panel API - no relay URLs needed # Development optimizations ESLINT_NO_DEV_ERRORS=true diff --git a/.env.production.example b/.env.production.example index 31ab8908..557e43ff 100644 --- a/.env.production.example +++ b/.env.production.example @@ -11,9 +11,7 @@ PUBLIC_URL=/panel # Asset serving configuration (optional - adjust for your domain) # REACT_APP_ASSETS_BUCKET=https://your-domain.com -# Nostr relay configuration for profile fetching -# REACT_APP_OWN_RELAY_URL= # ✅ Auto-detected from current domain -# REACT_APP_NOSTR_RELAY_URLS=wss://your-relay1.com,wss://your-relay2.com +# Nostr operations now use panel API - no relay URLs needed # Development optimizations ESLINT_NO_DEV_ERRORS=true diff --git a/README.md b/README.md index 4a691b74..c445a521 100644 --- a/README.md +++ b/README.md @@ -111,11 +111,132 @@ For development, you can run services directly: ### Nginx Proxy (Production Recommended) For production deployment, nginx handles: -1. **Wallet Service Proxying**: `/wallet/*` → `localhost:9003` -2. **SSL Termination**: Single certificate for entire application -3. **WebSocket Proxying**: Proper upgrade headers for relay WebSocket -4. **Static Asset Caching**: Optimal performance for React app -5. **Security Headers**: CORS, CSP, and other protections +1. **Relay WebSocket Proxying**: `/relay` and `/relay/` → `localhost:9001` (strips prefix) +2. **Wallet Service Proxying**: `/wallet/*` → `localhost:9003` +3. **SSL Termination**: Single certificate for entire application +4. **WebSocket Proxying**: Proper upgrade headers for relay WebSocket +5. **Static Asset Caching**: Optimal performance for React app +6. **Security Headers**: CORS, CSP, and other protections + +#### Complete Working Nginx Configuration +Here's a complete working nginx configuration for the HORNETS Relay Panel (tested on macOS and Linux): + +```nginx +# Define upstream servers for each service (using explicit IPv4 addresses) +upstream transcribe_api { + server 127.0.0.1:8000; +} + +upstream relay_service { + server 127.0.0.1:9001; +} + +upstream panel_service { + server 127.0.0.1:9002; +} + +upstream wallet_service { + server 127.0.0.1:9003; +} + +# WebSocket connection upgrade mapping +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# Main server block listening on HTTP +server { + listen 80; # Nginx listens on port 80 locally + server_name _; # Accept all hostnames (localhost, ngrok, custom domains, etc.) + + # Basic Security Headers + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + add_header X-XSS-Protection "1; mode=block"; + server_tokens off; + + # Increase buffer sizes for large files + client_max_body_size 100M; + + # Forward client IP and protocol + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + + # Health check endpoint - exact match first + location = /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Relay WebSocket service - handle both /relay and /relay/ + location ~ ^/relay/?$ { + # Strip the /relay prefix (with or without trailing slash) when forwarding to the service + rewrite ^/relay/?$ / break; + + proxy_pass http://relay_service; + + # WebSocket-specific headers + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + + # Extended timeouts for WebSocket connections + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_connect_timeout 60s; + + # Additional headers for tunnel compatibility + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Real-IP $remote_addr; + } + + # Transcribe service + location /transcribe/ { + rewrite ^/transcribe/(.*)$ /$1 break; + proxy_pass http://transcribe_api; + } + + # Wallet service + location /wallet/ { + rewrite ^/wallet/(.*)$ /$1 break; + proxy_pass http://wallet_service; + } + + # Default location - Panel service (frontend + API) - MUST BE LAST + location / { + proxy_pass http://panel_service; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Handle WebSocket if needed + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } +} +``` + +**Key Configuration Details:** +- **Relay WebSocket**: Uses regex matching `^/relay/?$` to handle both `/relay` and `/relay/` paths +- **Rewrite Rule**: Strips the `/relay` prefix before forwarding to the relay service at port 9001 +- **WebSocket Support**: Proper upgrade headers and extended timeouts for WebSocket connections +- **Service Routing**: Panel (root), wallet (`/wallet/`), transcribe (`/transcribe/`), and relay (`/relay`) +- **Security**: Basic security headers and proper client IP forwarding + +**Deployment Steps:** +1. Save this configuration to `/etc/nginx/sites-available/hornets` (or `/opt/homebrew/etc/nginx/conf.d/hornets.conf` on macOS) +2. Enable the site: `sudo ln -s /etc/nginx/sites-available/hornets /etc/nginx/sites-enabled/` +3. Test configuration: `sudo nginx -t` +4. Reload nginx: `sudo nginx -s reload` ## 📋 Prerequisites @@ -157,9 +278,7 @@ REACT_APP_ASSETS_BUCKET=http://localhost REACT_APP_DEMO_MODE=false REACT_APP_BASENAME= -# Nostr relay configuration for profile fetching -REACT_APP_OWN_RELAY_URL=ws://localhost:9001 -# REACT_APP_NOSTR_RELAY_URLS=wss://your-relay1.com,wss://your-relay2.com,wss://your-relay3.com +# Nostr operations now use panel API - no relay URLs needed # More info https://create-react-app.dev/docs/advanced-configuration ESLINT_NO_DEV_ERRORS=true @@ -176,15 +295,13 @@ Create `.env.production` for production builds: REACT_APP_DEMO_MODE=false # Service URLs -REACT_APP_WALLET_BASE_URL=http://localhost:9003 # Optional - leave empty to disable wallet features -REACT_APP_OWN_RELAY_URL=ws://localhost:9001 # Required for profile fetching +# REACT_APP_WALLET_BASE_URL - No longer needed! Wallet operations routed through panel API # Router configuration (empty for direct access) REACT_APP_BASENAME= PUBLIC_URL= -# Optional: Custom Nostr relay URLs (comma-separated list) -# REACT_APP_NOSTR_RELAY_URLS=wss://your-relay1.com,wss://your-relay2.com +# Nostr operations now use panel API - no relay URLs needed # Development optimizations ESLINT_NO_DEV_ERRORS=true @@ -193,10 +310,9 @@ TSC_COMPILE_ON_ERROR=true **🎯 Key Requirements**: -- ✅ **Relay URL Required** - REACT_APP_OWN_RELAY_URL must be configured for profile fetching -- ✅ **Wallet URL Optional** - REACT_APP_WALLET_BASE_URL can be empty to disable wallet features +- ✅ **Wallet Always Available** - Wallet operations routed through panel API, no configuration needed - ✅ **Panel Routing Auto-Detection** - Panel paths (REACT_APP_BASENAME/PUBLIC_URL) can be auto-detected -- ✅ **Build-Time Configuration** - Service URLs are baked into the JavaScript bundle during build +- ✅ **Simplified Configuration** - Uses default Nostr relay URLs, no custom configuration needed - ✅ **Simple Deployment** - No reverse proxy needed for basic functionality ### 4. Start Development Server @@ -264,16 +380,13 @@ Controls the React app's routing base path: ### Service URLs **🎯 Configuration Requirements**: -- **Wallet Service**: `REACT_APP_WALLET_BASE_URL=http://localhost:9003` (optional - leave empty to disable wallet features) -- **Relay WebSocket**: `REACT_APP_OWN_RELAY_URL=ws://localhost:9001` (required for profile fetching) +- **Wallet Service**: No longer requires configuration! Wallet operations are routed through panel API (`/api/wallet-proxy/*`) - **Panel API**: Auto-detected from current origin (no configuration needed) -**Note**: When wallet URL is not configured, send/receive buttons will show a helpful message about rebuilding with wallet configuration. +**✅ Simplified**: Wallet functionality is now always available through the panel's backend proxy. **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. diff --git a/craco.config.js b/craco.config.js index cd4526b7..f0834f3c 100644 --- a/craco.config.js +++ b/craco.config.js @@ -16,6 +16,28 @@ module.exports = { include: /node_modules/, type: 'javascript/auto', }); + + // Split chunks to avoid large files that cause ngrok issues + webpackConfig.optimization = { + ...webpackConfig.optimization, + splitChunks: { + chunks: 'all', + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + chunks: 'all', + maxSize: 1024 * 1024, // 1MB max chunk size + }, + common: { + minChunks: 2, + chunks: 'all', + maxSize: 1024 * 1024, // 1MB max chunk size + }, + }, + }, + }; + return webpackConfig; }, plugins: [ diff --git a/fixed_nginx_config.conf b/fixed_nginx_config.conf index b55f89f8..35e0cdae 100644 --- a/fixed_nginx_config.conf +++ b/fixed_nginx_config.conf @@ -32,41 +32,27 @@ server { add_header X-XSS-Protection "1; mode=block"; server_tokens off; + # Increase buffer sizes for large files + client_max_body_size 100M; + # Forward client IP and protocol proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; - location /transcribe/ { - rewrite ^/transcribe/(.*)$ /$1 break; - proxy_pass http://transcribe_api; - } - - # Panel access - Admin dashboard (back to working rewrite pattern) - location /panel/ { - rewrite ^/panel/(.*)$ /$1 break; - proxy_pass http://panel_service; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /wallet/ { - rewrite ^/wallet/(.*)$ /$1 break; - proxy_pass http://wallet_service; - } - - # Health check endpoint - location /health { + # Health check endpoint - exact match first + location = /health { access_log off; return 200 "healthy\n"; add_header Content-Type text/plain; } - # Default location - Relay service with WebSocket support - location / { + # Relay WebSocket service - handle both /relay and /relay/ + location ~ ^/relay/?$ { + # Strip the /relay prefix (with or without trailing slash) when forwarding to the service + rewrite ^/relay/?$ / break; + proxy_pass http://relay_service; # WebSocket-specific headers @@ -86,4 +72,30 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; } + + # Transcribe service + location /transcribe/ { + rewrite ^/transcribe/(.*)$ /$1 break; + proxy_pass http://transcribe_api; + } + + # Wallet service + location /wallet/ { + rewrite ^/wallet/(.*)$ /$1 break; + proxy_pass http://wallet_service; + } + + # Default location - Panel service (frontend + API) - MUST BE LAST + location / { + proxy_pass http://panel_service; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Handle WebSocket if needed + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } } \ No newline at end of file diff --git a/public/logo-dark-192.png b/public/logo-dark-192.png index 30d40e64..0df1e691 100644 Binary files a/public/logo-dark-192.png and b/public/logo-dark-192.png differ diff --git a/public/logo-dark-512.png b/public/logo-dark-512.png index 8ab298a5..d180245b 100644 Binary files a/public/logo-dark-512.png and b/public/logo-dark-512.png differ diff --git a/src/App.tsx b/src/App.tsx index b6173215..684d63eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { ConfigProvider } from 'antd'; import { HelmetProvider } from 'react-helmet-async'; import deDe from 'antd/lib/locale/de_DE'; @@ -13,43 +13,11 @@ import { usePWA } from './hooks/usePWA'; import { useThemeWatcher } from './hooks/useThemeWatcher'; import { useAppSelector } from './hooks/reduxHooks'; import { themeObject } from './styles/themes/themeVariables'; -import NDK, { NDKNip07Signer, NDKRelayAuthPolicies } from '@nostr-dev-kit/ndk'; -import { useNDKInit } from '@nostr-dev-kit/ndk-hooks'; -import config from './config/config'; - -// Configure NDK with user's relay URLs from environment variables -const getRelayUrls = () => { - const relayUrls = [...config.nostrRelayUrls]; - - // Add user's own relay URL as the first priority if provided - if (config.ownRelayUrl) { - relayUrls.unshift(config.ownRelayUrl); - } - - return relayUrls; -}; - -const ndk = new NDK({ - explicitRelayUrls: getRelayUrls(), - signer: new NDKNip07Signer(), -}); - -// Set up NIP-42 authentication policy following the example -ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk }); - -ndk - .connect() - .then(() => console.log('NDK connected with relay URLs and NIP-42 auth policy:', getRelayUrls())) - .catch((error) => console.error('NDK connection error:', error)); +// NDK removed - login uses window.nostr directly, profile API uses panel API const App: React.FC = () => { const { language } = useLanguage(); const theme = useAppSelector((state) => state.theme.theme); - const initializeNDK = useNDKInit(); - - useEffect(() => { - initializeNDK(ndk); - }, [initializeNDK]); usePWA(); diff --git a/src/assets/logo-dark-192.png b/src/assets/logo-dark-192.png index 30d40e64..dd24417b 100644 Binary files a/src/assets/logo-dark-192.png and b/src/assets/logo-dark-192.png differ diff --git a/src/assets/logo-dark-512.png b/src/assets/logo-dark-512.png index 8ab298a5..d180245b 100644 Binary files a/src/assets/logo-dark-512.png and b/src/assets/logo-dark-512.png differ diff --git a/src/components/common/IconUpload.tsx b/src/components/common/IconUpload.tsx index c97c8021..f1db9350 100644 --- a/src/components/common/IconUpload.tsx +++ b/src/components/common/IconUpload.tsx @@ -1,7 +1,7 @@ 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 { isValidUrl, isImageUrl } from '@app/utils/blossomUpload'; +import { isValidUrl, isImageUrl } from '@app/utils/utils'; import { readToken } from '@app/services/localStorage.service'; import config from '@app/config/config'; import type { RcFile } from 'antd/es/upload/interface'; diff --git a/src/components/header/components/searchDropdown/SearchDropdown.tsx b/src/components/header/components/searchDropdown/SearchDropdown.tsx index 5061ea28..8c2eaeb6 100644 --- a/src/components/header/components/searchDropdown/SearchDropdown.tsx +++ b/src/components/header/components/searchDropdown/SearchDropdown.tsx @@ -5,9 +5,8 @@ import { CategoryComponents } from '@app/components/header/components/HeaderSear import { Btn, InputSearch } from '../HeaderSearch/HeaderSearch.styles'; import { useTranslation } from 'react-i18next'; import { BasePopover } from '@app/components/common/BasePopover/BasePopover'; -import { NDKUserProfile, useNDK } from '@nostr-dev-kit/ndk-hooks'; import usePaidSubscribers from '@app/hooks/usePaidSubscribers'; -import { convertNDKUserProfileToSubscriberProfile } from '@app/utils/utils'; +import { useProfileAPI } from '@app/hooks/useProfileAPI'; import { InvalidPubkey } from '../../Header.styles'; import { SubscriberProfile } from '@app/hooks/usePaidSubscribers'; @@ -34,7 +33,7 @@ export const SearchDropdown: React.FC = ({ const [subscriberProfile, setSubscriberProfile] = useState(null); const [invalidPubkey, setInvalidPubkey] = useState(false); const { subscribers } = usePaidSubscribers(); - const ndkInstance = useNDK(); + const { fetchSingleProfile, loading: profileAPILoading } = useProfileAPI(); const { t } = useTranslation(); useEffect(() => { @@ -44,19 +43,19 @@ export const SearchDropdown: React.FC = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any const ref = useRef(null); - const fetchProfile = async (pubkey: string): Promise => { - if (!ndkInstance) return null; - + const fetchProfile = async (pubkey: string): Promise => { try { setFetchingProfile(true); - const profile = await ndkInstance.ndk?.getUser({ pubkey: pubkey }).fetchProfile(); - + setFetchingFailed(false); + + const profile = await fetchSingleProfile(pubkey); + if (profile) { setFetchingProfile(false); setFetchingFailed(false); return profile; } else { - console.error('Profile not found for pubkey:', pubkey); + console.log('Profile not found for pubkey:', pubkey); setFetchingProfile(false); setFetchingFailed(true); return null; @@ -71,34 +70,35 @@ export const SearchDropdown: React.FC = ({ const handleSearchProfile = async () => { if (!query) return; - //verify that it's a pubkey + + // Verify that it's a valid pubkey (hex format) if (/^[a-fA-F0-9]{64}$/.test(query)) { setSubscriberDetailModalOpen(true); + setInvalidPubkey(false); - //See if the pubkey exists in the subscribers const pubkey = query; - const subscriber = subscribers.find((sub) => sub.pubkey === query); - // If it exists, open the modal with the subscriber details. If name,picture, or about are not set, fetch profile. - //if It doesnt exist, fetch the profile from NDK - //once fetched, convert it to SubscriberProfile and open the modal - if (subscriber) { - if (!subscriber.name || !subscriber.picture || !subscriber.about) { - const profile = await fetchProfile(pubkey); - if (profile) { - const subscriberProfile: SubscriberProfile = convertNDKUserProfileToSubscriberProfile(pubkey, profile); - // Open the modal with the fetched subscriber profile - setSubscriberProfile(subscriberProfile); - } - } + + // First check if the pubkey exists in current subscribers list + const existingSubscriber = subscribers.find((sub) => sub.pubkey === pubkey); + + if (existingSubscriber && existingSubscriber.name && existingSubscriber.picture) { + // We already have complete profile data, use it directly + setSubscriberProfile(existingSubscriber); + setFetchingProfile(false); + setFetchingFailed(false); } else { + // Fetch the profile from API (either new subscriber or incomplete data) const profile = await fetchProfile(pubkey); if (profile) { - const subscriberProfile: SubscriberProfile = convertNDKUserProfileToSubscriberProfile(pubkey, profile); - // Open the modal with the fetched subscriber profile - setSubscriberProfile(subscriberProfile); + setSubscriberProfile(profile); + } else { + // If API doesn't have the profile, use existing subscriber data if available + if (existingSubscriber) { + setSubscriberProfile(existingSubscriber); + } } } - }else{ + } else { setInvalidPubkey(true); } }; @@ -152,7 +152,7 @@ export const SearchDropdown: React.FC = ({ {isSubscriberDetailModalOpen && ( { const { theme } = useAppSelector((state) => state.theme); const [isModalOpen, setIsModalOpen] = useState(false); const handleButtonClick = () => { - if (!config.isWalletEnabled) { - message.warning('Wallet functionality is not available. Please rebuild the panel with REACT_APP_WALLET_BASE_URL configured in your environment file.'); - return; - } setIsModalOpen(true); }; const handleModalClose = () => { diff --git a/src/components/relay-dashboard/Balance/components/SendForm/SendForm.tsx b/src/components/relay-dashboard/Balance/components/SendForm/SendForm.tsx index 1b198437..0367ffd2 100644 --- a/src/components/relay-dashboard/Balance/components/SendForm/SendForm.tsx +++ b/src/components/relay-dashboard/Balance/components/SendForm/SendForm.tsx @@ -96,11 +96,18 @@ const SendForm: React.FC = ({ onSend }) => { return; } - let response = await fetch(`${config.walletBaseURL}/calculate-tx-size`, { + // Get panel JWT token for authentication + const panelToken = readToken(); + if (!panelToken) { + console.log('Panel authentication required for transaction calculation'); + return; + } + + let response = await fetch(`${config.baseURL}/api/wallet-proxy/calculate-tx-size`, { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${panelToken}`, }, body: JSON.stringify({ recipient_address: formData.address, @@ -118,11 +125,11 @@ const SendForm: React.FC = ({ onSend }) => { await login(); // Retry the request with the new token - response = await fetch(`${config.walletBaseURL}/calculate-tx-size`, { + response = await fetch(`${config.baseURL}/api/wallet-proxy/calculate-tx-size`, { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${panelToken}`, }, body: JSON.stringify({ recipient_address: formData.address, @@ -246,12 +253,18 @@ const SendForm: React.FC = ({ onSend }) => { } - // Step 2: Initiate the new transaction with the JWT token - const response = await fetch(`${config.walletBaseURL}/transaction`, { + // Get panel JWT token for authentication + const panelToken = readToken(); + if (!panelToken) { + throw new Error('Panel authentication required for transaction'); + } + + // Step 2: Initiate the new transaction with the JWT token via panel API + const response = await fetch(`${config.baseURL}/api/wallet-proxy/transaction`, { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, // Include JWT token in headers + Authorization: `Bearer ${panelToken}`, // Include panel JWT token in headers }, body: JSON.stringify(transactionRequest), }); diff --git a/src/components/relay-dashboard/Balance/components/TopUpBalanceButton/TopUpBalanceButton.tsx b/src/components/relay-dashboard/Balance/components/TopUpBalanceButton/TopUpBalanceButton.tsx index 129ab7b8..67898e18 100644 --- a/src/components/relay-dashboard/Balance/components/TopUpBalanceButton/TopUpBalanceButton.tsx +++ b/src/components/relay-dashboard/Balance/components/TopUpBalanceButton/TopUpBalanceButton.tsx @@ -1,9 +1,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { message } from 'antd'; import { useAppSelector } from '@app/hooks/reduxHooks'; import { TopUpBalanceModal } from '../TopUpBalanceModal/TopUpBalanceModal'; -import config from '@app/config/config'; import * as S from './TopUpBalanceButton.styles'; export const TopUpBalanceButton: React.FC = () => { @@ -12,10 +10,6 @@ export const TopUpBalanceButton: React.FC = () => { const [isModalOpen, setIsModalOpen] = useState(false); const handleButtonClick = () => { - if (!config.isWalletEnabled) { - message.warning('Wallet functionality is not available. Please rebuild the panel with REACT_APP_WALLET_BASE_URL configured in your environment file.'); - return; - } setIsModalOpen(true); }; diff --git a/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx b/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx index 7fc56dde..9d632bb2 100644 --- a/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx +++ b/src/components/relay-dashboard/paid-subscribers/PaidSubscribers.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect, useMemo } from 'react'; +import React, { useRef, useState, useMemo } from 'react'; import { Splide, SplideSlide, SplideTrack } from '@splidejs/react-splide'; import { LeftOutlined, RightOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; @@ -14,149 +14,13 @@ import { useResponsive } from '@app/hooks/useResponsive'; import usePaidSubscribers, { SubscriberProfile } from '@app/hooks/usePaidSubscribers'; import { Row, Col, Modal, Typography } from 'antd'; import { nip19 } from 'nostr-tools'; -import { useNDK } from '@nostr-dev-kit/ndk-hooks'; -import { convertNDKUserProfileToSubscriberProfile } from '@app/utils/utils'; 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); -}; +const { Text } = Typography; export const PaidSubscribers: React.FC = () => { - const hookResult = usePaidSubscribers(12); - const { subscribers, fetchMore, hasMore, loading, useDummyData } = hookResult; - const ndkInstance = useNDK(); + const { subscribers, fetchMore, hasMore, loading, useDummyData } = usePaidSubscribers(12); // Modal state for subscriber details const [selectedSubscriber, setSelectedSubscriber] = useState(null); @@ -164,24 +28,10 @@ export const PaidSubscribers: React.FC = () => { // Modal state for view all subscribers const [isViewAllModalVisible, setIsViewAllModalVisible] = useState(false); - const [loadingProfiles, setLoadingProfiles] = useState(true); - const [subscriberProfiles, setSubscriberProfiles] = useState>( - () => new Map(subscribers.map((s) => [s.pubkey, s])), - ); + // Sort profiles for consistent display const sortedProfiles = useMemo(() => { - return Array.from(subscriberProfiles.entries()).sort(([a], [b]) => a.localeCompare(b)); - }, [subscriberProfiles]); - useEffect(() => { - setSubscriberProfiles((prev) => { - const map = new Map(prev); - for (const s of subscribers) { - if (!map.has(s.pubkey)) { - map.set(s.pubkey, s); - } - } - return map; - }); + return [...subscribers].sort((a, b) => a.pubkey.localeCompare(b.pubkey)); }, [subscribers]); // Handle opening subscriber detail modal @@ -193,138 +43,24 @@ export const PaidSubscribers: React.FC = () => { // Handle closing subscriber detail modal const handleCloseModal = () => { setIsModalVisible(false); + setSelectedSubscriber(null); }; - const updateSubscriberProfile = (pubkey: string, profile: SubscriberProfile) => { - setSubscriberProfiles((prev) => { - const newMap = new Map(prev); - newMap.set(pubkey, profile); - return newMap; - }); - // Cache the profile globally - setCachedProfile(pubkey, profile); - }; + // Handle opening view all modal const handleViewAll = async () => { setIsViewAllModalVisible(true); // Fetch more subscribers if available let canFetchMore = hasMore; - while (canFetchMore) { try { await fetchMore(); - // Note: This is a simplified approach. In a real scenario, you'd want to - // track the updated state properly or use a separate hook for fetching all canFetchMore = false; // For now, just fetch once more } catch (error) { break; } } }; - - useEffect(() => { - // Implement hybrid profile fetching with 10-minute caching - if (useDummyData) { - 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; - } - - // 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 local map - const existingProfile = subscriberProfiles.get(subscriber.pubkey); - const hasValidProfile = existingProfile && ( - (existingProfile.name && existingProfile.name !== 'Anonymous Subscriber') || - existingProfile.picture || - existingProfile.about - ); - - if (hasValidProfile) { - return; - } - - try { - const profile = await fetchSingleProfile(subscriber); - updateSubscriberProfile(subscriber.pubkey, profile); - } catch (error) { - // Use fallback profile - updateSubscriberProfile(subscriber.pubkey, { - ...subscriber, - name: subscriber.name || 'Anonymous Subscriber', - picture: subscriber.picture || '', - about: subscriber.about || '' - }); - } - }), - ); - - setLoadingProfiles(false); - }; - - fetchProfiles(); - }, [subscribers, ndkInstance, useDummyData, subscriberProfiles]); // Handle closing view all modal const handleCloseViewAllModal = () => { @@ -332,7 +68,6 @@ export const PaidSubscribers: React.FC = () => { setIsViewAllModalVisible(false); }; - const sliderRef = useRef(null); const { isTablet: isTabletOrHigher } = useResponsive(); const { t } = useTranslation(); @@ -365,8 +100,8 @@ export const PaidSubscribers: React.FC = () => { - {sortedProfiles.map(([pubkey, subscriber], index) => ( - + {sortedProfiles.map((subscriber, index) => ( + {subscriber.picture ? ( { onStoryOpen={() => handleOpenSubscriberDetails(subscriber)} /> ) : ( - + handleOpenSubscriberDetails(subscriber)}> )} @@ -382,7 +117,7 @@ export const PaidSubscribers: React.FC = () => { ))} - + {/* View All Subscribers Modal */} { style={{ top: 20 }} > - {sortedProfiles.map(([pubkey, subscriber]) => ( - + {sortedProfiles.map((subscriber) => ( +
{ }} >
- {subscriber.name + {subscriber.picture ? ( + {subscriber.name + ) : ( +
+ +
+ )}
{ - {!loadingProfiles && - sortedProfiles.map(([pubkey, subscriber], index) => ( - - + {!loading && + sortedProfiles.map((subscriber, index) => ( + + {subscriber.picture ? ( handleOpenSubscriberDetails(subscriber)} @@ -551,7 +303,7 @@ export const PaidSubscribers: React.FC = () => { viewed={false} /> ) : ( - + handleOpenSubscriberDetails(subscriber)}> )} @@ -575,8 +327,8 @@ export const PaidSubscribers: React.FC = () => { style={{ top: 20 }} > - {sortedProfiles.map(([pubkey, subscriber]) => ( - + {sortedProfiles.map((subscriber) => ( +
{ }} >
- {subscriber.name + {subscriber.picture ? ( + {subscriber.name + ) : ( +
+ +
+ )}
{ ); }; -export default PaidSubscribers; +export default PaidSubscribers; \ No newline at end of file diff --git a/src/components/relay-dashboard/paid-subscribers/SubscriberDetailModal/SubscriberDetailModal.tsx b/src/components/relay-dashboard/paid-subscribers/SubscriberDetailModal/SubscriberDetailModal.tsx index 5cdf6352..ab601468 100644 --- a/src/components/relay-dashboard/paid-subscribers/SubscriberDetailModal/SubscriberDetailModal.tsx +++ b/src/components/relay-dashboard/paid-subscribers/SubscriberDetailModal/SubscriberDetailModal.tsx @@ -74,7 +74,26 @@ export const SubscriberDetailModal: React.FC = ({ su if (key.length <= 16) return key; return `${key.substring(0, 8)}...${key.substring(key.length - 8)}`; }; - const subscribed: boolean = !!subscriber.metadata?.subscriptionTier && !!subscriber.metadata?.subscribedSince; + + // Format date for display + const formatDate = (dateString: string) => { + // Check if it's a zero time value (Go default) + if (dateString === '0001-01-01T00:00:00Z' || !dateString) { + return 'Not available'; + } + + try { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } catch (error) { + return 'Invalid date'; + } + }; + const subscribed: boolean = !!subscriber.metadata?.subscriptionTier && !!subscriber.metadata?.subscribedSince && subscriber.metadata.subscribedSince !== '0001-01-01T00:00:00Z'; const subscribedLabel = subscribed ? 'Subscribed' : 'Not Subscribed'; return ( @@ -146,7 +165,7 @@ export const SubscriberDetailModal: React.FC = ({ su Subscribed Since - {subscriber.metadata.subscribedSince} + {formatDate(subscriber.metadata.subscribedSince)} )} diff --git a/src/components/relay-dashboard/unconfirmed-transactions/components/ReplaceTransaction/ReplaceTransaction.tsx b/src/components/relay-dashboard/unconfirmed-transactions/components/ReplaceTransaction/ReplaceTransaction.tsx index 3935e3d2..9455253b 100644 --- a/src/components/relay-dashboard/unconfirmed-transactions/components/ReplaceTransaction/ReplaceTransaction.tsx +++ b/src/components/relay-dashboard/unconfirmed-transactions/components/ReplaceTransaction/ReplaceTransaction.tsx @@ -62,11 +62,19 @@ const ReplaceTransaction: React.FC = ({ onCancel, onRep return; } - const response = await fetch(`${config.walletBaseURL}/calculate-tx-size`, { + // Get panel JWT token for authentication + const panelToken = readToken(); + if (!panelToken) { + console.log('Panel authentication required for transaction size calculation'); + setIsLoadingSize(false); + return; + } + + const response = await fetch(`${config.baseURL}/api/wallet-proxy/calculate-tx-size`, { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${panelToken}`, }, body: JSON.stringify({ recipient_address: transaction.recipient_address, @@ -145,11 +153,17 @@ const ReplaceTransaction: React.FC = ({ onCancel, onRep new_fee_rate: newFeeRate, // Send the updated fee rate }; - const response = await fetch(`${config.walletBaseURL}/transaction`, { + // Get panel JWT token for authentication + const panelToken = readToken(); + if (!panelToken) { + throw new Error('Panel authentication required for transaction replacement'); + } + + const response = await fetch(`${config.baseURL}/api/wallet-proxy/transaction`, { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, // Include JWT token + Authorization: `Bearer ${panelToken}`, // Include panel JWT token }, body: JSON.stringify(replaceRequest), }); diff --git a/src/components/settings/RelayInfoSettings.tsx b/src/components/settings/RelayInfoSettings.tsx index c2425a7e..19a921ce 100644 --- a/src/components/settings/RelayInfoSettings.tsx +++ b/src/components/settings/RelayInfoSettings.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { Form, Input, Select, Tooltip } from 'antd'; +import { Form, Input, Select, Tooltip, message } from 'antd'; import { QuestionCircleOutlined, InfoCircleOutlined, @@ -18,15 +18,60 @@ const RelayInfoSettings: React.FC = () => { const { settings, loading, error, fetchSettings, updateSettings, saveSettings } = useGenericSettings('relay_info'); const [form] = Form.useForm(); + // Function to get the default bee logo URL (no upload needed) + const getDefaultIconUrl = (): string => { + const currentOrigin = window.location.origin; + return `${currentOrigin}/logo-dark-192.png`; + }; + // Update form values when settings change useEffect(() => { if (settings) { - form.setFieldsValue(settings); + const currentOrigin = window.location.origin; + const defaultIconUrl = getDefaultIconUrl(); + const isEmptyIcon = !settings.relayicon || settings.relayicon.trim() === ''; + const isLocalhostIcon = settings.relayicon && settings.relayicon.includes('localhost'); + const isProductionDomain = !currentOrigin.includes('localhost'); + + // Auto-set default icon URL if: + // 1. Icon is empty, OR + // 2. We're on production domain but icon still points to localhost + if (isEmptyIcon || (isLocalhostIcon && isProductionDomain)) { + console.log('Auto-setting default icon URL...', { + isEmptyIcon, + isLocalhostIcon, + isProductionDomain, + currentOrigin, + currentIcon: settings.relayicon, + defaultIconUrl + }); + + // Update the settings with the default icon URL + updateSettings({ relayicon: defaultIconUrl }); + form.setFieldsValue({ relayicon: defaultIconUrl }); + + if (isEmptyIcon) { + message.success('Default bee logo set automatically'); + } else { + message.success('Icon updated for new domain'); + } + } else { + form.setFieldsValue(settings); + } } - }, [settings, form]); + }, [settings, form, updateSettings]); // Handle form value changes const handleValuesChange = (changedValues: Partial>) => { + // If relay icon is being cleared, automatically set default URL + if (changedValues.relayicon !== undefined && (!changedValues.relayicon || changedValues.relayicon.trim() === '')) { + const defaultIconUrl = getDefaultIconUrl(); + updateSettings({ relayicon: defaultIconUrl }); + form.setFieldsValue({ relayicon: defaultIconUrl }); + message.success('Default bee logo set automatically'); + return; // Don't update with empty value + } + updateSettings(changedValues); }; diff --git a/src/config/config.ts b/src/config/config.ts index 0b00dcb0..98b09489 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -12,38 +12,16 @@ const getBaseURL = (): string => { return process.env.REACT_APP_BASE_URL || window.location.origin; }; -const getWalletURL = (): string | null => { - // Demo mode override for testing - if (process.env.REACT_APP_DEMO_MODE === 'true') { - return 'http://localhost:9003'; - } - - // Wallet URL is optional - return null if not configured - return process.env.REACT_APP_WALLET_BASE_URL || null; -}; +// Wallet operations now go through panel API, no direct URL needed const config = { baseURL: getBaseURL(), isDemoMode: process.env.REACT_APP_DEMO_MODE === 'true', - walletBaseURL: getWalletURL(), - isWalletEnabled: getWalletURL() !== null, + // Wallet operations now routed through panel API - always enabled + isWalletEnabled: true, - // Nostr relay configuration - nostrRelayUrls: process.env.REACT_APP_NOSTR_RELAY_URLS?.split(',').map(url => url.trim()) || [ - 'wss://relay.damus.io', - 'wss://relay.nostr.band', - 'wss://relay.snort.social', - 'wss://vault.iris.to' - ], + // Nostr relay configuration removed - using panel API for all operations - // User's own relay URL (primary relay for profile fetching) - // Always require explicit relay URL configuration - ownRelayUrl: (() => { - if (!process.env.REACT_APP_OWN_RELAY_URL?.trim()) { - throw new Error('REACT_APP_OWN_RELAY_URL must be explicitly configured in environment variables'); - } - return process.env.REACT_APP_OWN_RELAY_URL.trim(); - })(), // Notification settings notifications: { diff --git a/src/hooks/usePaidSubscribers.ts b/src/hooks/usePaidSubscribers.ts index 140480db..404dd9bd 100644 --- a/src/hooks/usePaidSubscribers.ts +++ b/src/hooks/usePaidSubscribers.ts @@ -2,8 +2,9 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import config from '@app/config/config'; import { readToken } from '@app/services/localStorage.service'; import { useHandleLogout } from './authUtils'; +import { useProfileAPI } from './useProfileAPI'; -// Import the profile images +// Import the profile images for dummy data import profile1 from '@app/assets/images/profile1.webp'; import profile2 from '@app/assets/images/profile2.jpg'; import profile3 from '@app/assets/images/profile3.webp'; @@ -16,7 +17,6 @@ import profile9 from '@app/assets/images/profile9.gif'; import profile11 from '@app/assets/images/profile11.png'; import profile12 from '@app/assets/images/profile12.webp'; import profile13 from '@app/assets/images/profile13.webp'; -import adminDefaultAvatar from '@app/assets/admin-default-avatar.png'; export interface SubscriberProfile { pubkey: string; @@ -28,45 +28,32 @@ export interface SubscriberProfile { subscribedSince?: string; }; } -const testSubscribers: SubscriberProfile[] = [ - { pubkey: '91dfb08db37712e74d892adbbf63abab43cb6aa3806950548f3146347d29b6ae' }, - { pubkey: '59cacbd83ad5c54ad91dacf51a49c06e0bef730ac0e7c235a6f6fa29b9230f02' }, - { pubkey: '32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245' }, - { pubkey: '78a317586cbc30d20f8aa94d8450eb0cd58b312bad94fc76139c72eb2e5c81d2' }, - { pubkey: '4657dfe8965be8980a93072bcfb5e59a65124406db0f819215ee78ba47934b3e' }, - { pubkey: '6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e' }, - { pubkey: '7b991f776d04d87cb5d4259688187a520f6afc16b2b9ad26dac6b8ee76c2840d'} -]; // Define dummy profiles using the imported images const dummyProfiles: SubscriberProfile[] = [ - { pubkey: 'dummy-1', picture: profile1 }, - { pubkey: 'dummy-2', picture: profile2 }, - { pubkey: 'dummy-3', picture: profile3 }, - { pubkey: 'dummy-4', picture: profile6 }, - { pubkey: 'dummy-5', picture: profile7 }, - { pubkey: 'dummy-6', picture: profile13 }, - { pubkey: 'dummy-7', picture: profile8 }, - { pubkey: 'dummy-8', picture: profile12 }, - { pubkey: 'dummy-9', picture: profile5 }, - { pubkey: 'dummy-10', picture: profile4 }, - { pubkey: 'dummy-11', picture: profile9 }, - { pubkey: 'dummy-12', picture: profile11 }, + { pubkey: 'dummy-1', picture: profile1, name: 'Demo User 1', about: 'Demo subscriber' }, + { pubkey: 'dummy-2', picture: profile2, name: 'Demo User 2', about: 'Demo subscriber' }, + { pubkey: 'dummy-3', picture: profile3, name: 'Demo User 3', about: 'Demo subscriber' }, + { pubkey: 'dummy-4', picture: profile6, name: 'Demo User 4', about: 'Demo subscriber' }, + { pubkey: 'dummy-5', picture: profile7, name: 'Demo User 5', about: 'Demo subscriber' }, + { pubkey: 'dummy-6', picture: profile13, name: 'Demo User 6', about: 'Demo subscriber' }, + { pubkey: 'dummy-7', picture: profile8, name: 'Demo User 7', about: 'Demo subscriber' }, + { pubkey: 'dummy-8', picture: profile12, name: 'Demo User 8', about: 'Demo subscriber' }, + { pubkey: 'dummy-9', picture: profile5, name: 'Demo User 9', about: 'Demo subscriber' }, + { pubkey: 'dummy-10', picture: profile4, name: 'Demo User 10', about: 'Demo subscriber' }, + { pubkey: 'dummy-11', picture: profile9, name: 'Demo User 11', about: 'Demo subscriber' }, + { pubkey: 'dummy-12', picture: profile11, name: 'Demo User 12', about: 'Demo subscriber' }, ]; - -// 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 { +// Simple cache for subscriber list pagination (not profiles - those are cached in profileCache) +interface SubscriberListCache { data: SubscriberProfile[]; timestamp: number; hasMore: boolean; } -const SUBSCRIBER_CACHE_DURATION = 600000; // 10 minutes in milliseconds -const globalSubscriberCache = new Map(); +const SUBSCRIBER_LIST_CACHE_DURATION = 300000; // 5 minutes for the subscriber list +const subscriberListCache = new Map(); const usePaidSubscribers = (pageSize = 20) => { const [subscribers, setSubscribers] = useState([]); @@ -78,10 +65,13 @@ const usePaidSubscribers = (pageSize = 20) => { const isMounted = useRef(true); const handleLogout = useHandleLogout(); + const { fetchProfiles, loading: profileLoading } = useProfileAPI(); const fetchSubscribers = useCallback(async (reset = false) => { try { setLoading(true); + setError(null); + const token = readToken(); if (!token) { setUseDummyData(true); @@ -91,147 +81,103 @@ const usePaidSubscribers = (pageSize = 20) => { } const page = reset ? 1 : currentPage; - const cacheKey = `${page}-${pageSize}`; + const cacheKey = `subscribers_${page}_${pageSize}`; - // Check cache first - const cached = globalSubscriberCache.get(cacheKey); - if (cached && (Date.now() - cached.timestamp) < SUBSCRIBER_CACHE_DURATION) { - setSubscribers(cached.data); + // Check cache first for subscriber list (not individual profiles) + const cached = subscriberListCache.get(cacheKey); + if (cached && (Date.now() - cached.timestamp) < SUBSCRIBER_LIST_CACHE_DURATION) { + // Get the cached subscriber list but fetch fresh profile data + const pubkeys = cached.data.map(s => s.pubkey); + const enhancedProfiles = await fetchProfiles(pubkeys); + + // Merge with basic subscriber data, prioritizing enhanced profile data + const mergedProfiles = cached.data.map(subscriber => { + const enhanced = enhancedProfiles.find(p => p.pubkey === subscriber.pubkey); + return enhanced || subscriber; + }); + + setSubscribers(mergedProfiles); setHasMore(cached.hasMore); setUseDummyData(false); setCurrentPage(page + 1); return; } + // Fetch subscriber list from API (this gives us basic info + pubkeys) const queryParams = new URLSearchParams({ page: page.toString(), limit: pageSize.toString(), }); - const requestUrl = `${config.baseURL}/api/paid-subscriber-profiles?${queryParams}`; - - const response = await fetch(requestUrl, { + const response = await fetch(`${config.baseURL}/api/paid-subscriber-profiles?${queryParams}`, { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, }); - if (!response.ok) { if (response.status === 401) { handleLogout(); - return; + return; } throw new Error(`Request failed: ${response.status}`); } - let data: SubscriberProfile[] = []; + let basicSubscriberData: SubscriberProfile[] = []; try { - data = await response.json(); - - // Ensure data is always an array - if (!Array.isArray(data)) { - if (data && typeof data === 'object') { - // If data is an object but not an array, try to convert it - if (Object.keys(data).length > 0) { - data = [data] as SubscriberProfile[]; // Wrap in an array if it's a single object - } else { - data = []; // Empty array if it's an empty object - } - } else { - data = []; // Default to empty array - } - } + const data = await response.json(); + basicSubscriberData = Array.isArray(data) ? data : []; } catch (jsonError) { - console.error('[usePaidSubscribers] Error parsing JSON response:', jsonError); - data = []; + console.error('Error parsing subscriber list response:', jsonError); + basicSubscriberData = []; } - - // 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) { - - try { - // Process the profiles to replace placeholder avatar URLs - const processedProfiles: SubscriberProfile[] = []; - - - for (const profile of data) { - if (!profile || !profile.pubkey) { - console.error('[usePaidSubscribers] Invalid profile, skipping:', profile); - continue; - } - - // Fix placeholder avatar if needed - const usesPlaceholder = profile.picture === PLACEHOLDER_AVATAR_URL; - let pictureUrl = profile.picture; - - if (usesPlaceholder) { - pictureUrl = adminDefaultAvatar; - } - - processedProfiles.push({ - pubkey: profile.pubkey, - picture: pictureUrl, - name: profile.name, - about: profile.about, - metadata: profile.metadata - }); - } - - - // Update state with backend data - setUseDummyData(false); - setSubscribers(processedProfiles); - setHasMore(data.length === pageSize); - setCurrentPage(page + 1); - - // 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); - // Continue to fallback logic below if processing fails - } - } - - // Fallback logic if no backend data - only use dummy data when truly no data available - if (isMounted.current) { - // 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-'))) { - setUseDummyData(true); - setSubscribers(dummyProfiles); - } else { - setUseDummyData(false); - } + if (basicSubscriberData.length > 0) { + // Cache the basic subscriber list + subscriberListCache.set(cacheKey, { + data: basicSubscriberData, + timestamp: Date.now(), + hasMore: basicSubscriberData.length === pageSize + }); + + // Fetch enhanced profile data for all subscribers + const pubkeys = basicSubscriberData.map(s => s.pubkey); + const enhancedProfiles = await fetchProfiles(pubkeys); + + // Merge basic subscriber data with enhanced profile data + const mergedProfiles = basicSubscriberData.map(subscriber => { + const enhanced = enhancedProfiles.find(p => p.pubkey === subscriber.pubkey); + return enhanced || subscriber; + }); + + setSubscribers(mergedProfiles); + setHasMore(basicSubscriberData.length === pageSize); + setUseDummyData(false); + setCurrentPage(page + 1); + } else { + // No data from backend, use dummy data + setUseDummyData(true); + setSubscribers(dummyProfiles); setHasMore(false); } + } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to fetch subscribers'; setError(errorMessage); - console.error(`[usePaidSubscribers] Error fetching subscribers:`, err); + console.error('Error fetching subscribers:', err); - // Only use dummy data if we don't have any real subscribers - if (subscribers.length === 0 || subscribers.every(s => s.pubkey.startsWith('dummy-'))) { - setUseDummyData(true); - setSubscribers(dummyProfiles); - } else { - setUseDummyData(false); - } + // Fallback to dummy data on error + setUseDummyData(true); + setSubscribers(dummyProfiles); setHasMore(false); } finally { if (isMounted.current) { setLoading(false); } } - }, [currentPage, pageSize, handleLogout, subscribers]); + }, [currentPage, pageSize, handleLogout, fetchProfiles]); useEffect(() => { return () => { @@ -244,8 +190,8 @@ const usePaidSubscribers = (pageSize = 20) => { }, [fetchSubscribers]); return { - subscribers, // Renamed from creators to subscribers - loading, + subscribers, + loading: loading || profileLoading, error, hasMore, useDummyData, @@ -254,4 +200,4 @@ const usePaidSubscribers = (pageSize = 20) => { }; }; -export default usePaidSubscribers; +export default usePaidSubscribers; \ No newline at end of file diff --git a/src/hooks/useProfileAPI.ts b/src/hooks/useProfileAPI.ts new file mode 100644 index 00000000..8ea6dee7 --- /dev/null +++ b/src/hooks/useProfileAPI.ts @@ -0,0 +1,149 @@ +import { useState, useCallback } from 'react'; +import { SubscriberProfile } from '@app/hooks/usePaidSubscribers'; +import { + getProfilesFromCache, + cacheProfiles, + getProfileFromCache, + cacheProfile +} from '@app/utils/profileCache'; +import { ensureProfilePicture } from '@app/utils/defaultProfilePicture'; +import config from '@app/config/config'; +import { readToken } from '@app/services/localStorage.service'; +import { useHandleLogout } from './authUtils'; + +interface ProfileAPIResponse { + profiles: SubscriberProfile[]; + not_found: string[]; +} + +interface UseProfileAPIReturn { + fetchProfiles: (pubkeys: string[]) => Promise; + fetchSingleProfile: (pubkey: string) => Promise; + loading: boolean; + error: string | null; +} + +export const useProfileAPI = (): UseProfileAPIReturn => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const handleLogout = useHandleLogout(); + + const fetchProfiles = useCallback(async (pubkeys: string[]): Promise => { + if (pubkeys.length === 0) return []; + + setError(null); + + try { + // Check cache first - get profiles that are already cached and not expired + const cachedProfiles = getProfilesFromCache(pubkeys); + const cachedPubkeys = Object.keys(cachedProfiles); + + // Find pubkeys that need to be fetched from API + const uncachedPubkeys = pubkeys.filter(pubkey => !cachedPubkeys.includes(pubkey)); + + // If all profiles are cached, return them immediately + if (uncachedPubkeys.length === 0) { + return pubkeys.map(pubkey => cachedProfiles[pubkey]).filter(Boolean); + } + + setLoading(true); + + // Fetch uncached profiles from API + const token = readToken(); + if (!token) { + throw new Error('Authentication required'); + } + + const response = await fetch(`${config.baseURL}/api/profiles`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + pubkeys: uncachedPubkeys + }) + }); + + if (!response.ok) { + if (response.status === 401) { + handleLogout(); + throw new Error('Authentication expired'); + } + throw new Error(`API request failed: ${response.status}`); + } + + const data: ProfileAPIResponse = await response.json(); + + // Cache the newly fetched profiles with default pictures ensured + if (data.profiles && data.profiles.length > 0) { + const profilesWithDefaults = data.profiles.map(profile => ({ + ...profile, + picture: ensureProfilePicture(profile.picture) + })); + cacheProfiles(profilesWithDefaults); + } + + // Combine cached and newly fetched profiles + const allProfiles: SubscriberProfile[] = []; + pubkeys.forEach(pubkey => { + // First check if we have it in cache + const cached = cachedProfiles[pubkey]; + if (cached) { + allProfiles.push({ + ...cached, + picture: ensureProfilePicture(cached.picture) + }); + return; + } + + // Then check if we just fetched it + const fetched = data.profiles.find(p => p.pubkey === pubkey); + if (fetched) { + allProfiles.push({ + ...fetched, + picture: ensureProfilePicture(fetched.picture) + }); + } + }); + + return allProfiles; + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch profiles'; + setError(errorMessage); + console.error('Error fetching profiles:', err); + + // Return cached profiles even if API failed, ensuring default pictures + const cachedProfiles = getProfilesFromCache(pubkeys); + return Object.values(cachedProfiles).map(profile => ({ + ...profile, + picture: ensureProfilePicture(profile.picture) + })); + } finally { + setLoading(false); + } + }, [handleLogout]); + + const fetchSingleProfile = useCallback(async (pubkey: string): Promise => { + // Check cache first + const cached = getProfileFromCache(pubkey); + if (cached) { + return { + ...cached, + picture: ensureProfilePicture(cached.picture) + }; + } + + // Fetch from API using the batch endpoint + const profiles = await fetchProfiles([pubkey]); + return profiles.length > 0 ? profiles[0] : null; + }, [fetchProfiles]); + + return { + fetchProfiles, + fetchSingleProfile, + loading, + error + }; +}; \ No newline at end of file diff --git a/src/hooks/useWalletAuth.ts b/src/hooks/useWalletAuth.ts index 73387a7c..4a6c30b0 100644 --- a/src/hooks/useWalletAuth.ts +++ b/src/hooks/useWalletAuth.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { persistWalletToken, readWalletToken, deleteWalletToken } from '@app/services/localStorage.service'; // Import the wallet-specific functions +import { persistWalletToken, readWalletToken, deleteWalletToken, readToken } from '@app/services/localStorage.service'; // Import the wallet-specific functions import { notificationController } from '@app/controllers/notificationController'; import config from '@app/config/config'; @@ -42,8 +42,10 @@ const useWalletAuth = () => { // Fetch the Nostr public key const npub = await window.nostr.getPublicKey(); - // Fetch the challenge from the server - const challengeResponse = await fetch(`${config.walletBaseURL}/challenge`, { method: 'GET' }); + // Fetch the challenge from the server via panel API (no authentication required) + const challengeResponse = await fetch(`${config.baseURL}/api/wallet-proxy/challenge`, { + method: 'GET' + }); // Check if the response is valid JSON if (!challengeResponse.ok) { @@ -66,10 +68,12 @@ const useWalletAuth = () => { // Sign the challenge using Nostr const signedEvent = await window.nostr.signEvent(event); - // Send the signed challenge to the backend for verification - const verifyResponse = await fetch(`${config.walletBaseURL}/verify`, { + // Send the signed challenge to the backend for verification via panel API (no authentication required) + const verifyResponse = await fetch(`${config.baseURL}/api/wallet-proxy/verify`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json' + }, body: JSON.stringify({ challenge, signature: signedEvent.sig, @@ -110,10 +114,17 @@ const useWalletAuth = () => { setHealthCheckInProgress(true); setHealthLoading(true); try { - let response = await fetch(`${config.walletBaseURL}/panel-health`, { + // Get panel JWT token for authentication + const panelToken = readToken(); + if (!panelToken) { + console.log('Panel authentication required for health check'); + return null; + } + + let response = await fetch(`${config.baseURL}/api/wallet-proxy/panel-health`, { method: 'GET', headers: { - 'Authorization': `Bearer ${token}`, + 'Authorization': `Bearer ${panelToken}`, }, }); @@ -124,10 +135,10 @@ const useWalletAuth = () => { await login(); // Retry the request with the new token - response = await fetch(`${config.walletBaseURL}/panel-health`, { + response = await fetch(`${config.baseURL}/api/wallet-proxy/panel-health`, { method: 'GET', headers: { - 'Authorization': `Bearer ${token}`, + 'Authorization': `Bearer ${panelToken}`, }, }); diff --git a/src/types/nostr.ts b/src/types/nostr.ts index ff05e6e2..829ca937 100644 --- a/src/types/nostr.ts +++ b/src/types/nostr.ts @@ -13,5 +13,9 @@ export interface NostrProvider { }; } - // Global Window declaration removed to avoid conflict with NDK types - // NDK will provide the window.nostr types automatically \ No newline at end of file + // Global Window declaration for Nostr extension + declare global { + interface Window { + nostr?: NostrProvider; + } + } \ No newline at end of file diff --git a/src/utils/blossomUpload.ts b/src/utils/blossomUpload.ts deleted file mode 100644 index c6a303ca..00000000 --- a/src/utils/blossomUpload.ts +++ /dev/null @@ -1,205 +0,0 @@ -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 diff --git a/src/utils/defaultProfilePicture.ts b/src/utils/defaultProfilePicture.ts new file mode 100644 index 00000000..1e9adf41 --- /dev/null +++ b/src/utils/defaultProfilePicture.ts @@ -0,0 +1,18 @@ +import logo from '@app/assets/logo.png'; + +/** + * Get the default profile picture URL (bee logo) for users without profile pictures + * This returns a URL that works both in development and production + */ +export const getDefaultProfilePicture = (): string => { + // In production, this will be a relative URL that adapts to the deployment + // In development, this will work with the dev server + return logo; +}; + +/** + * Ensure a profile has a valid picture URL, using default if none provided + */ +export const ensureProfilePicture = (pictureUrl?: string): string => { + return pictureUrl && pictureUrl.trim() !== '' ? pictureUrl : getDefaultProfilePicture(); +}; \ No newline at end of file diff --git a/src/utils/profileCache.ts b/src/utils/profileCache.ts new file mode 100644 index 00000000..445c87d3 --- /dev/null +++ b/src/utils/profileCache.ts @@ -0,0 +1,138 @@ +import { SubscriberProfile } from '@app/hooks/usePaidSubscribers'; + +interface CachedProfile { + profile: SubscriberProfile; + timestamp: number; +} + +const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours in milliseconds +const CACHE_KEY_PREFIX = 'profile_cache_'; + +/** + * Get profile from cache if it exists and hasn't expired + */ +export const getProfileFromCache = (pubkey: string): SubscriberProfile | null => { + try { + const cacheKey = `${CACHE_KEY_PREFIX}${pubkey}`; + const cached = localStorage.getItem(cacheKey); + + if (!cached) return null; + + const { profile, timestamp }: CachedProfile = JSON.parse(cached); + + // Check if cache has expired + if (Date.now() - timestamp > CACHE_DURATION) { + localStorage.removeItem(cacheKey); + return null; + } + + return profile; + } catch (error) { + console.error('Error reading profile from cache:', error); + return null; + } +}; + +/** + * Cache a profile with current timestamp + */ +export const cacheProfile = (pubkey: string, profile: SubscriberProfile): void => { + try { + const cacheKey = `${CACHE_KEY_PREFIX}${pubkey}`; + const cached: CachedProfile = { + profile, + timestamp: Date.now() + }; + + localStorage.setItem(cacheKey, JSON.stringify(cached)); + } catch (error) { + console.error('Error caching profile:', error); + } +}; + +/** + * Check if a profile cache has expired (but don't remove it) + */ +export const isCacheExpired = (pubkey: string): boolean => { + try { + const cacheKey = `${CACHE_KEY_PREFIX}${pubkey}`; + const cached = localStorage.getItem(cacheKey); + + if (!cached) return true; + + const { timestamp }: CachedProfile = JSON.parse(cached); + return Date.now() - timestamp > CACHE_DURATION; + } catch (error) { + return true; + } +}; + +/** + * Get multiple profiles from cache, returns only non-expired ones + */ +export const getProfilesFromCache = (pubkeys: string[]): Record => { + const cachedProfiles: Record = {}; + + pubkeys.forEach(pubkey => { + const profile = getProfileFromCache(pubkey); + if (profile) { + cachedProfiles[pubkey] = profile; + } + }); + + return cachedProfiles; +}; + +/** + * Cache multiple profiles at once + */ +export const cacheProfiles = (profiles: SubscriberProfile[]): void => { + profiles.forEach(profile => { + cacheProfile(profile.pubkey, profile); + }); +}; + +/** + * Clear all profile cache (useful for testing or cache invalidation) + */ +export const clearProfileCache = (): void => { + try { + const keysToRemove: string[] = []; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(CACHE_KEY_PREFIX)) { + keysToRemove.push(key); + } + } + + keysToRemove.forEach(key => localStorage.removeItem(key)); + } catch (error) { + console.error('Error clearing profile cache:', error); + } +}; + +/** + * Get cache statistics for debugging + */ +export const getCacheStats = (): { totalCached: number; expired: number } => { + let totalCached = 0; + let expired = 0; + + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(CACHE_KEY_PREFIX)) { + totalCached++; + const pubkey = key.replace(CACHE_KEY_PREFIX, ''); + if (isCacheExpired(pubkey)) { + expired++; + } + } + } + } catch (error) { + console.error('Error getting cache stats:', error); + } + + return { totalCached, expired }; +}; \ No newline at end of file diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 477abbe6..730137a5 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -6,8 +6,6 @@ import maestro from '@app/assets/images/card-issuers/maestro.png'; import { CurrencyTypeEnum, Severity } from '@app/interfaces/interfaces'; import { BaseBadgeProps } from '@app/components/common/BaseBadge/BaseBadge'; import { currencies } from '@app/constants/config/currencies'; -import { NDKUserProfile } from '@nostr-dev-kit/ndk'; -import { SubscriberProfile } from '@app/hooks/usePaidSubscribers'; export const camelize = (string: string): string => { return string .split(' ') @@ -22,19 +20,6 @@ export const getSatsCurrency = (price: number | string, currency: CurrencyTypeEn // Handle potential negative sign placement return isIcon ? `${currencySymbol}${formattedPrice}` : `${formattedPrice} ${currency}`; }; - export const convertNDKUserProfileToSubscriberProfile = (pubkey: string, user: NDKUserProfile): SubscriberProfile => { - // Handle display_name from the profile data since NDK sometimes uses different field names - const displayName = user.name || - ('display_name' in user ? user.display_name : '') || - ('displayName' in user ? user.displayName : '') || ''; - - return { - pubkey, - name: typeof displayName === 'string' ? displayName : '', - picture: user.picture || '', - about: user.about || '', - }; - }; export const getCurrencyPrice = (price: number | string, currency: CurrencyTypeEnum, isIcon = true): string => { const currencySymbol = currencies[currency][isIcon ? 'icon' : 'text']; @@ -229,3 +214,27 @@ export const mapBadgeStatus = (status: BaseBadgeProps['status']): Severity => { return status; }; + +/** + * 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)); +};