diff --git a/src/components/settings/PushNotificationSettings.tsx b/src/components/settings/PushNotificationSettings.tsx new file mode 100644 index 0000000..0f3c803 --- /dev/null +++ b/src/components/settings/PushNotificationSettings.tsx @@ -0,0 +1,438 @@ +import React, { useEffect, useState } from 'react'; +import { Form, Input, InputNumber, Tooltip, Card } from 'antd'; +import { + QuestionCircleOutlined, + BellOutlined, + AppleOutlined, + AndroidOutlined, + SettingOutlined, + KeyOutlined, + FileTextOutlined +} from '@ant-design/icons'; +import { LiquidToggle } from '@app/components/common/LiquidToggle'; +import useGenericSettings from '@app/hooks/useGenericSettings'; +import { SettingsGroupType } from '@app/types/settings.types'; +import BaseSettingsForm from './BaseSettingsForm'; +import * as S from './Settings.styles'; + +const PushNotificationSettings: React.FC = () => { + const { + settings, + loading, + error, + fetchSettings, + updateSettings, + saveSettings, + } = useGenericSettings('push_notifications'); + + const [form] = Form.useForm(); + const [isUserEditing, setIsUserEditing] = useState(false); + + // Update form values when settings change, but only if user isn't actively editing + useEffect(() => { + if (settings && !isUserEditing) { + console.log('PushNotificationSettings - Received settings:', settings); + + // The useGenericSettings hook returns the settings data + const settingsObj = settings as Record; + + console.log('PushNotificationSettings - Setting form values directly:', settingsObj); + + // Set form values directly + form.setFieldsValue(settingsObj); + console.log('PushNotificationSettings - Form values after set:', form.getFieldsValue()); + } + }, [settings, form, isUserEditing]); + + // Handle form value changes + const handleValuesChange = (changedValues: Partial>) => { + setIsUserEditing(true); // Mark that user is currently editing + updateSettings(changedValues); + }; + + // Modified save function to reset the editing flag + const handleSave = async () => { + await saveSettings(); + setIsUserEditing(false); // Reset after saving + }; + + return ( + { + fetchSettings(); + setIsUserEditing(false); + }} + > +
{ + console.log('Form submitted with values:', values); + setIsUserEditing(false); + }} + > + {/* Main Configuration */} + + + General Configuration + + } + style={{ marginBottom: 16 }} + > + + Enable Push Notifications  + + + + + } + valuePropName="checked" + > + + + + + {/* Service Configuration */} + + + Service Configuration + + } + style={{ marginBottom: 16 }} + > + + Worker Count  + + + + + } + rules={[ + { required: true, message: 'Please enter worker count' }, + { type: 'number', min: 1, max: 100, message: 'Worker count must be between 1 and 100' } + ]} + > + + + + + Queue Size  + + + + + } + rules={[ + { required: true, message: 'Please enter queue size' }, + { type: 'number', min: 100, max: 10000, message: 'Queue size must be between 100 and 10000' } + ]} + > + + + + + Max Retry Attempts  + + + + + } + rules={[ + { required: true, message: 'Please enter max retry attempts' }, + { type: 'number', min: 1, max: 10, message: 'Max retry attempts must be between 1 and 10' } + ]} + > + + + + + Retry Base Delay  + + + + + } + rules={[ + { required: true, message: 'Please enter retry base delay' }, + { + pattern: /^\d+[a-zA-Z]+$/, + message: 'Invalid duration format. Use Go duration format (e.g., "1s", "500ms", "2m")' + } + ]} + > + } + /> + + + + Batch Size  + + + + + } + rules={[ + { required: true, message: 'Please enter batch size' }, + { type: 'number', min: 1, max: 1000, message: 'Batch size must be between 1 and 1000' } + ]} + > + + + + + {/* APNs Configuration */} + + + Apple Push Notification Service (APNs) + + } + style={{ marginBottom: 16 }} + > + + Enable APNs  + + + + + } + valuePropName="checked" + > + + + + + APNs Key File Path  + + + + + } + rules={[ + { + required: form.getFieldValue('apns_enabled'), + message: 'Please enter APNs key file path when APNs is enabled' + } + ]} + > + } + /> + + + + APNs Key ID  + + + + + } + rules={[ + { + required: form.getFieldValue('apns_enabled'), + message: 'Please enter APNs key ID when APNs is enabled' + }, + { len: 10, message: 'APNs Key ID must be exactly 10 characters' } + ]} + > + } + maxLength={10} + /> + + + + Team ID  + + + + + } + rules={[ + { + required: form.getFieldValue('apns_enabled'), + message: 'Please enter Team ID when APNs is enabled' + }, + { len: 10, message: 'Team ID must be exactly 10 characters' } + ]} + > + } + maxLength={10} + /> + + + + App Bundle Identifier  + + + + + } + rules={[ + { + required: form.getFieldValue('apns_enabled'), + message: 'Please enter app bundle identifier when APNs is enabled' + } + ]} + > + } + /> + + + + Production Mode  + + + + + } + valuePropName="checked" + > + + + + + {/* FCM Configuration */} + + + Firebase Cloud Messaging (FCM) + + } + style={{ marginBottom: 16 }} + > + + Enable FCM  + + + + + } + valuePropName="checked" + > + + + + + FCM Credentials File Path  + + + + + } + rules={[ + { + required: form.getFieldValue('fcm_enabled'), + message: 'Please enter FCM credentials file path when FCM is enabled' + } + ]} + > + } + /> + + + + +

+ Note: Push notification settings are saved to the configuration file + and the push notification service automatically reloads with the new configuration. + At least one service (APNs or FCM) should be enabled if push notifications are enabled. +

+
+
+
+ ); +}; + +export default PushNotificationSettings; \ No newline at end of file diff --git a/src/components/settings/SettingsNavigation.tsx b/src/components/settings/SettingsNavigation.tsx index 82ff0c7..c116b35 100644 --- a/src/components/settings/SettingsNavigation.tsx +++ b/src/components/settings/SettingsNavigation.tsx @@ -11,6 +11,7 @@ import { InfoCircleOutlined, WalletOutlined, GlobalOutlined, + BellOutlined, } from '@ant-design/icons'; const { Panel } = Collapse; @@ -86,6 +87,12 @@ const settingsTabs: SettingsTab[] = [ icon: , path: '/settings/ollama' }, + { + key: 'push_notifications', + label: 'Push Notifications', + icon: , + path: '/settings/push-notifications' + }, { key: 'relay_info', label: 'Relay Info', diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 2dbbda8..c715a2c 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { PageTitle } from '@app/components/common/PageTitle/PageTitle'; import { BaseRow } from '@app/components/common/BaseRow/BaseRow'; -import { BaseCol } from '@app/components/common/BaseCol/BaseCol'; import ImageModerationSettings from './ImageModerationSettings'; import ContentFilterSettings from './ContentFilterSettings'; import OllamaSettings from './OllamaSettings'; import WalletSettings from './WalletSettings'; import GeneralSettings from './GeneralSettings'; import RelayInfoSettings from './RelayInfoSettings'; +import PushNotificationSettings from './PushNotificationSettings'; import * as S from '@app/pages/DashboardPages/DashboardPage.styles'; import * as PageStyles from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; import { CollapsibleSection } from '@app/components/relay-settings/shared/CollapsibleSection/CollapsibleSection'; @@ -48,6 +48,10 @@ const SettingsPage: React.FC = () => { + + + + diff --git a/src/components/settings/layouts/AdvancedSettingsLayout.tsx b/src/components/settings/layouts/AdvancedSettingsLayout.tsx index d17a265..2dc450c 100644 --- a/src/components/settings/layouts/AdvancedSettingsLayout.tsx +++ b/src/components/settings/layouts/AdvancedSettingsLayout.tsx @@ -2,12 +2,12 @@ import React, { useState } from 'react'; import { Button, Space, Spin } from 'antd'; import { LiquidBlueButton } from '@app/components/common/LiquidBlueButton'; import { BaseRow } from '@app/components/common/BaseRow/BaseRow'; -import { BaseCol } from '@app/components/common/BaseCol/BaseCol'; import GeneralSettingsPanel from '../panels/GeneralSettingsPanel'; import ImageModerationPanel from '../panels/ImageModerationPanel'; import ContentFilterPanel from '../panels/ContentFilterPanel'; import OllamaPanel from '../panels/OllamaPanel'; import WalletPanel from '../panels/WalletPanel'; +import PushNotificationPanel from '../panels/PushNotificationPanel'; import useGenericSettings from '@app/hooks/useGenericSettings'; import { CollapsibleSection } from '@app/components/relay-settings/shared/CollapsibleSection/CollapsibleSection'; import { Balance } from '@app/components/relay-dashboard/Balance/Balance'; @@ -118,6 +118,10 @@ const AdvancedSettingsLayout: React.FC = ({ + + + +
diff --git a/src/components/settings/panels/PushNotificationPanel.tsx b/src/components/settings/panels/PushNotificationPanel.tsx new file mode 100644 index 0000000..52ef10e --- /dev/null +++ b/src/components/settings/panels/PushNotificationPanel.tsx @@ -0,0 +1,363 @@ +import React, { useEffect, useState } from 'react'; +import { Form, Input, InputNumber, Switch, Tooltip, Card } from 'antd'; +import { + QuestionCircleOutlined, + BellOutlined, + AppleOutlined, + AndroidOutlined, + SettingOutlined, + KeyOutlined, + FileTextOutlined +} from '@ant-design/icons'; +import useGenericSettings from '@app/hooks/useGenericSettings'; +import { SettingsGroupType } from '@app/types/settings.types'; +import BaseSettingsPanel from '../BaseSettingsPanel'; + +const PushNotificationPanel: React.FC = () => { + console.log('PushNotificationPanel - Component rendering'); + + const { + settings, + loading, + error, + updateSettings, + // saveSettings is not used in panels - they use the global save + } = useGenericSettings('push_notifications'); + + const [form] = Form.useForm(); + const [isUserEditing, setIsUserEditing] = useState(false); + + console.log('PushNotificationPanel - Hook result:', { settings, loading, error }); + + // Listen for global save event + useEffect(() => { + const handleGlobalSave = () => { + setTimeout(() => { + setIsUserEditing(false); + }, 200); + }; + + document.addEventListener('settings-saved', handleGlobalSave); + + return () => { + document.removeEventListener('settings-saved', handleGlobalSave); + }; + }, []); + + // Update form values when settings change, but only if user isn't actively editing + useEffect(() => { + if (settings && !isUserEditing) { + console.log('PushNotificationPanel - Received settings:', settings); + + // The useGenericSettings hook returns the settings data + const settingsObj = settings as Record; + + console.log('PushNotificationPanel - Setting form values directly:', settingsObj); + + // Set form values directly + form.setFieldsValue(settingsObj); + console.log('PushNotificationPanel - Form values after set:', form.getFieldsValue()); + } + }, [settings, form, isUserEditing]); + + // Handle form value changes + const handleValuesChange = (changedValues: Partial>) => { + setIsUserEditing(true); // Mark that user is currently editing + console.log('PushNotificationPanel - changedValues:', changedValues); + console.log('PushNotificationPanel - current form values:', form.getFieldsValue()); + updateSettings(changedValues); + }; + + return ( + +
{ + console.log('Form submitted with values:', values); + setIsUserEditing(false); + }} + > + {/* Main Configuration */} + + + General Configuration + + } + style={{ marginBottom: 16 }} + size="small" + > + + Enable Push Notifications  + + + + + } + valuePropName="checked" + > + + + + + {/* Service Configuration */} + + + Service Configuration + + } + style={{ marginBottom: 16 }} + size="small" + > + + Worker Count  + + + + + } + rules={[ + { required: true, message: 'Please enter worker count' }, + { type: 'number', min: 1, max: 100, message: 'Worker count must be between 1 and 100' } + ]} + > + + + + + Queue Size  + + + + + } + rules={[ + { required: true, message: 'Please enter queue size' }, + { type: 'number', min: 100, max: 10000, message: 'Queue size must be between 100 and 10000' } + ]} + > + + + + + Max Retry Attempts  + + + + + } + rules={[ + { required: true, message: 'Please enter max retry attempts' }, + { type: 'number', min: 1, max: 10, message: 'Max retry attempts must be between 1 and 10' } + ]} + > + + + + + Retry Base Delay  + + + + + } + rules={[ + { required: true, message: 'Please enter retry base delay' }, + { + pattern: /^\d+[a-zA-Z]+$/, + message: 'Invalid duration format. Use Go duration format (e.g., "1s", "500ms", "2m")' + } + ]} + > + } + /> + + + + Batch Size  + + + + + } + rules={[ + { required: true, message: 'Please enter batch size' }, + { type: 'number', min: 1, max: 1000, message: 'Batch size must be between 1 and 1000' } + ]} + > + + + + + {/* APNs Configuration */} + + + Apple Push Notification Service (APNs) + + } + style={{ marginBottom: 16 }} + size="small" + > + + Enable APNs  + + + + + } + valuePropName="checked" + > + + + + + APNs Key File Path  + + + + + } + rules={[ + { required: false, message: 'Please enter the APNs key file path' } + ]} + > + } + /> + + + + Bundle ID  + + + + + } + rules={[ + { required: false, message: 'Please enter the bundle ID' }, + { + pattern: /^[a-zA-Z0-9.-]+$/, + message: 'Invalid bundle ID format' + } + ]} + > + } + /> + + + + {/* FCM Configuration */} + + + Firebase Cloud Messaging (FCM) + + } + style={{ marginBottom: 16 }} + size="small" + > + + Enable FCM  + + + + + } + valuePropName="checked" + > + + + + + FCM Service Account Key Path  + + + + + } + rules={[ + { required: false, message: 'Please enter the FCM credentials file path' } + ]} + > + } + /> + + +
+
+ ); +}; + +export default PushNotificationPanel; \ No newline at end of file diff --git a/src/constants/relaySettings.ts b/src/constants/relaySettings.ts index 3370236..26778ff 100644 --- a/src/constants/relaySettings.ts +++ b/src/constants/relaySettings.ts @@ -39,6 +39,7 @@ export const noteOptions = [ { kind: 1060, kindString: 'kind1060', description: 'Double Ratchet DM', category: 1 }, { kind: 1063, kindString: 'kind1063', description: 'File Metadata', category: 1 }, { kind: 1808, kindString: 'kind1808', description: 'Audio Transcription', category: 1 }, + { kind: 1809, kindString: 'kind1809', description: 'Audio Transcription Repost', category: 1 }, { kind: 1984, kindString: 'kind1984', description: 'Reporting', category: 1 }, { kind: 30000, kindString: 'kind30000', description: 'Custom Follow List', category: 1 }, { kind: 30008, kindString: 'kind30008', description: 'Profile Badge', category: 2 }, diff --git a/src/hooks/useGenericSettings.ts b/src/hooks/useGenericSettings.ts index 46b86f7..a7a24f0 100644 --- a/src/hooks/useGenericSettings.ts +++ b/src/hooks/useGenericSettings.ts @@ -36,6 +36,10 @@ const extractSettingsForGroup = (settings: any, groupName: string) => { rawData = settings?.server || {}; break; + case 'push_notifications': + rawData = settings?.push_notifications || {}; + break; + default: console.warn(`Unknown settings group: ${groupName}`); return {}; @@ -200,6 +204,76 @@ const extractSettingsForGroup = (settings: any, groupName: string) => { return processedData; } + // Handle push notifications field name mapping + if (groupName === 'push_notifications' && rawData) { + const processedData: any = {}; + + // Copy top-level fields directly + if (rawData.enabled !== undefined) { + processedData.enabled = rawData.enabled; + } + + // Flatten service fields + if (rawData.service && typeof rawData.service === 'object') { + const serviceData = rawData.service; + if (serviceData.worker_count !== undefined) { + processedData.service_worker_count = serviceData.worker_count; + } + if (serviceData.queue_size !== undefined) { + processedData.service_queue_size = serviceData.queue_size; + } + if (serviceData.retry_attempts !== undefined) { + processedData.service_retry_attempts = serviceData.retry_attempts; + } + if (serviceData.retry_delay !== undefined) { + processedData.service_retry_delay = serviceData.retry_delay; + } + if (serviceData.batch_size !== undefined) { + processedData.service_batch_size = serviceData.batch_size; + } + } + + // Flatten APNs fields + if (rawData.apns && typeof rawData.apns === 'object') { + const apnsData = rawData.apns; + if (apnsData.enabled !== undefined) { + processedData.apns_enabled = apnsData.enabled; + } + if (apnsData.key_path !== undefined) { + processedData.apns_key_path = apnsData.key_path; + } + if (apnsData.bundle_id !== undefined) { + processedData.apns_bundle_id = apnsData.bundle_id; + } + if (apnsData.key_id !== undefined) { + processedData.apns_key_id = apnsData.key_id; + } + if (apnsData.team_id !== undefined) { + processedData.apns_team_id = apnsData.team_id; + } + if (apnsData.production !== undefined) { + processedData.apns_production = apnsData.production; + } + } + + // Flatten FCM fields + if (rawData.fcm && typeof rawData.fcm === 'object') { + const fcmData = rawData.fcm; + if (fcmData.enabled !== undefined) { + processedData.fcm_enabled = fcmData.enabled; + } + if (fcmData.credentials_path !== undefined) { + processedData.fcm_credentials_path = fcmData.credentials_path; + } + if (fcmData.project_id !== undefined) { + processedData.fcm_project_id = fcmData.project_id; + } + } + + console.log(`Processed ${groupName} data:`, processedData); + return processedData; + } + return rawData; }; @@ -331,6 +405,74 @@ const buildNestedUpdate = (groupName: string, data: any) => { return result; + case 'push_notifications': + // Transform flat field names back to nested structure for backend + const backendPushData: any = {}; + + // Handle top-level fields + if (data.enabled !== undefined) { + backendPushData.enabled = data.enabled; + } + + // Handle service fields - group them under service object + const serviceFields = ['service_worker_count', 'service_queue_size', 'service_retry_attempts', 'service_retry_delay', 'service_batch_size']; + const serviceData: any = {}; + let hasServiceFields = false; + + serviceFields.forEach(flatFieldName => { + if (data[flatFieldName] !== undefined) { + const backendFieldName = flatFieldName.replace('service_', ''); + serviceData[backendFieldName] = data[flatFieldName]; + hasServiceFields = true; + } + }); + + if (hasServiceFields) { + backendPushData.service = serviceData; + } + + // Handle APNs fields - group them under apns object + const apnsFields = ['apns_enabled', 'apns_key_path', 'apns_bundle_id', 'apns_key_id', 'apns_team_id', 'apns_production']; + const apnsData: any = {}; + let hasApnsFields = false; + + apnsFields.forEach(flatFieldName => { + if (data[flatFieldName] !== undefined) { + const backendFieldName = flatFieldName.replace('apns_', ''); + apnsData[backendFieldName] = data[flatFieldName]; + hasApnsFields = true; + } + }); + + if (hasApnsFields) { + backendPushData.apns = apnsData; + } + + // Handle FCM fields - group them under fcm object + const fcmFields = ['fcm_enabled', 'fcm_credentials_path', 'fcm_project_id']; + const fcmData: any = {}; + let hasFcmFields = false; + + fcmFields.forEach(flatFieldName => { + if (data[flatFieldName] !== undefined) { + const backendFieldName = flatFieldName.replace('fcm_', ''); + fcmData[backendFieldName] = data[flatFieldName]; + hasFcmFields = true; + } + }); + + if (hasFcmFields) { + backendPushData.fcm = fcmData; + } + + console.log('Push notifications: transforming flat data to nested for backend:', { flatData: data, nestedData: backendPushData }); + + return { + settings: { + push_notifications: backendPushData + } + }; + default: console.warn(`Unknown settings group for save: ${groupName}`); return { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 8046574..a913069 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -140,6 +140,7 @@ "kind1": "Kind 1", "kind10000": "Kind 10000", "kind1808": "Kind 1808", + "kind1809": "Kind 1809", "kind1984": "Kind 1984", "kind3": "Kind 3", "kind30000": "Kind 30000", diff --git a/src/types/settings.types.ts b/src/types/settings.types.ts index be49f40..d02faae 100644 --- a/src/types/settings.types.ts +++ b/src/types/settings.types.ts @@ -87,25 +87,49 @@ export interface GeneralSettings { relay_stats_db: string; } +export interface PushNotificationSettings { + enabled: boolean; + service: { + worker_count: number; + queue_size: number; + retry_attempts: number; + retry_delay: string; + }; + apns: { + enabled: boolean; + key_path: string; + key_id: string; + team_id: string; + bundle_id: string; + production: boolean; + }; + fcm: { + enabled: boolean; + credentials_path: string; + }; +} + export interface QueryCacheSettings { [key: string]: any; } -export type SettingsGroupName = +export type SettingsGroupName = | 'image_moderation' | 'content_filter' | 'ollama' | 'relay_info' | 'wallet' | 'general' + | 'push_notifications' | 'relay_settings'; -export type SettingsGroupType = +export type SettingsGroupType = T extends 'image_moderation' ? ImageModerationSettings : T extends 'content_filter' ? ContentFilterSettings : T extends 'ollama' ? OllamaSettings : T extends 'relay_info' ? RelayInfoSettings : T extends 'wallet' ? WalletSettings : T extends 'general' ? GeneralSettings : + T extends 'push_notifications' ? PushNotificationSettings : T extends 'relay_settings' ? any : // Using any for relay_settings as it's already defined elsewhere never;