diff --git a/.env.development b/.env.development index 75abc6c..d5857b4 100644 --- a/.env.development +++ b/.env.development @@ -1,7 +1,7 @@ REACT_APP_BASE_URL=http://localhost:9002 REACT_APP_WALLET_BASE_URL=http://localhost:9003 REACT_APP_ASSETS_BUCKET=http://localhost -REACT_APP_DEMO_MODE=true +REACT_APP_DEMO_MODE=false # More info https://create-react-app.dev/docs/advanced-configuration ESLINT_NO_DEV_ERRORS=true diff --git a/.gitignore b/.gitignore index 99955a8..339bf99 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ yarn-error.log* # generated files /public/themes +/memory-bank \ No newline at end of file diff --git a/README.md b/README.md index 89201b8..a00fa03 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ We have a live demo that can be found at http://hornetstorage.net for anyone tha ## Key Features - Manage your hornet-storage relay config directly from the panel -- Switch between our new dumb and smart model for accepting nostr notes +- Switch between our new whitelist and blacklist model for accepting nostr notes - Decide from which of the supported nostr kinds to enable - Choose which supported transport protocols to enable such as libp2p and websockets - Enable / disable which media extensions are accepted by the relay such as png and mp4 diff --git a/src/api/moderationNotifications.api.ts b/src/api/moderationNotifications.api.ts deleted file mode 100644 index e735e73..0000000 --- a/src/api/moderationNotifications.api.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { httpApi } from './http.api'; - -export interface ModerationNotificationParams { - page?: number; - limit?: number; - filter?: 'all' | 'unread' | 'user'; - pubkey?: string; -} - -export interface ModerationNotification { - id: number; - pubkey: string; - event_id: string; - reason: string; - created_at: string; - is_read: boolean; - content_type: string; - media_url?: string; - thumbnail_url?: string; -} - -export interface PaginationData { - currentPage: number; - pageSize: number; - totalItems: number; - totalPages: number; - hasNext: boolean; - hasPrevious: boolean; -} - -export interface ModerationNotificationsResponse { - notifications: ModerationNotification[]; - pagination: PaginationData; -} - -export interface ModerationStats { - total_blocked: number; - total_blocked_today: number; - by_content_type: Array<{ type: string; count: number }>; - by_user: Array<{ pubkey: string; count: number }>; - recent_reasons: string[]; -} - -// Fetch moderation notifications with filtering and pagination -export const getModerationNotifications = async ( - params: ModerationNotificationParams = {}, -): Promise => { - const response = await httpApi.get('/api/moderation/notifications', { params }); - return response.data; -}; - -// Mark a specific notification as read -export const markNotificationAsRead = async (id: number): Promise => { - await httpApi.post('/api/moderation/notifications/read', { id: [id] }); -}; - -// Mark all notifications as read -export const markAllNotificationsAsRead = async (): Promise => { - await httpApi.post('/api/moderation/notifications/read-all'); -}; - -// Get moderation statistics -export const getModerationStats = async (): Promise => { - const response = await httpApi.get('/api/moderation/stats'); - return response.data; -}; diff --git a/src/api/notifications.api.ts b/src/api/notifications.api.ts index 05098a5..19db7f7 100644 --- a/src/api/notifications.api.ts +++ b/src/api/notifications.api.ts @@ -1,17 +1,6 @@ export interface Message { id: number; description: string; - moderationData?: { - id: number; - pubkey: string; - event_id: string; - reason: string; - created_at: string; - is_read: boolean; - content_type: string; - media_url?: string; - thumbnail_url?: string; - }; } export interface Mention extends Message { @@ -23,5 +12,5 @@ export interface Mention extends Message { export type Notification = Mention | Message; -// Export an empty array now that we're using real moderation notifications +// Export an empty array for notifications export const notifications: Notification[] = []; diff --git a/src/components/common/BaseModal/BaseModal.tsx b/src/components/common/BaseModal/BaseModal.tsx index 92130cb..b08e8dc 100644 --- a/src/components/common/BaseModal/BaseModal.tsx +++ b/src/components/common/BaseModal/BaseModal.tsx @@ -2,8 +2,11 @@ import React from 'react'; import { Modal, ModalProps } from 'antd'; import { modalSizes } from 'constants/modalSizes'; -interface BaseModalProps extends ModalProps { +// Extend ModalProps to include our custom props +interface BaseModalProps extends Omit { size?: 'small' | 'medium' | 'large'; + visible?: boolean; // Deprecated prop + open?: boolean; // New prop that will replace visible } interface BaseModalInterface extends React.FC { @@ -13,11 +16,33 @@ interface BaseModalInterface extends React.FC { error: typeof Modal.error; } -export const BaseModal: BaseModalInterface = ({ size = 'medium', children, ...props }) => { +export const BaseModal: BaseModalInterface = ({ + size = 'medium', + visible, + open, + children, + ...props +}) => { const modalSize = Object.entries(modalSizes).find((sz) => sz[0] === size)?.[1]; + + // If open is provided, use it. Otherwise, fall back to visible. + // This ensures backward compatibility while supporting the new prop. + const isOpen = open !== undefined ? open : visible; + + // Show deprecation warning in development mode + if (process.env.NODE_ENV === 'development' && visible !== undefined && open === undefined) { + console.warn( + '[antd: Modal] `visible` will be removed in next major version, please use `open` instead.' + ); + } return ( - + {children} ); diff --git a/src/components/common/BaseNotification/BaseNotification.tsx b/src/components/common/BaseNotification/BaseNotification.tsx index c98b609..36fa65e 100644 --- a/src/components/common/BaseNotification/BaseNotification.tsx +++ b/src/components/common/BaseNotification/BaseNotification.tsx @@ -8,11 +8,10 @@ interface Icons { success: React.ReactNode; warning: React.ReactNode; error: React.ReactNode; - moderation: React.ReactNode; mention: React.ReactNode; } -export type NotificationType = 'info' | 'mention' | 'success' | 'warning' | 'error' | 'moderation'; +export type NotificationType = 'info' | 'mention' | 'success' | 'warning' | 'error'; interface BaseNotificationProps { type: NotificationType; @@ -27,7 +26,6 @@ export const BaseNotification: React.FC = ({ type, mentio success: , warning: , error: , - moderation: , mention: mentionIconSrc, }; diff --git a/src/components/header/components/notificationsDropdown/ModerationNotificationsOverlay/ModerationNotificationsOverlay.tsx b/src/components/header/components/notificationsDropdown/ModerationNotificationsOverlay/ModerationNotificationsOverlay.tsx deleted file mode 100644 index 5a7aa4f..0000000 --- a/src/components/header/components/notificationsDropdown/ModerationNotificationsOverlay/ModerationNotificationsOverlay.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; -import { BaseSpace } from '@app/components/common/BaseSpace/BaseSpace'; -import { BaseRow } from '@app/components/common/BaseRow/BaseRow'; -import { BaseCol } from '@app/components/common/BaseCol/BaseCol'; -import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; -import { BaseNotification } from '@app/components/common/BaseNotification/BaseNotification'; -import { useModerationNotifications } from '@app/hooks/useModerationNotifications'; -import { notificationController } from '@app/controllers/notificationController'; -import * as S from '../NotificationsOverlay/NotificationsOverlay.styles'; -import { ContentTypeTag } from '@app/components/moderation/ModerationNotifications/ModerationNotifications.styles'; - -export const ModerationNotificationsOverlay: React.FC = () => { - const { t } = useTranslation(); - const { notifications, markAllAsRead, markAsRead, isLoading } = useModerationNotifications(); - - const handleMarkAsRead = useCallback((id: number) => { - markAsRead(id); - }, [markAsRead]); - - const formatDate = (dateString: string): string => { - const date = new Date(dateString); - return date.toLocaleString(); - }; - - return ( - - - {isLoading ? ( -
-
- - {t('moderation.notifications.loading', 'Loading notifications...')} - -
- ) : notifications.length > 0 ? ( - }> - {notifications.slice(0, 5).map((notification) => ( - - - {notification.content_type} - - {notification.reason} - - } - description={ -
-
- {formatDate(notification.created_at)} -
-
- User: - {notification.pubkey.substring(0, 10)}... - { - e.stopPropagation(); - navigator.clipboard.writeText(notification.pubkey); - notificationController.success({ - message: 'Pubkey copied to clipboard' - }); - }} - style={{ padding: '0 4px', height: 'auto' }} - > - 📋 - -
- {notification.thumbnail_url && ( -
- Content thumbnail -
- )} - {!notification.is_read && ( - handleMarkAsRead(notification.id)}> - {t('moderation.notifications.markAsRead', 'Mark as read')} - - )} -
- } - /> - ))} -
- ) : ( -
-
🔍
- - {t('moderation.notifications.noNotifications', 'No moderation notifications')} - - - {t('moderation.notifications.emptyDescription', 'Moderation alerts will appear here when content is flagged')} - -
- )} - - - {notifications.some(n => !n.is_read) && ( - - markAllAsRead()}> - {t('moderation.notifications.readAll', 'Mark all as read')} - - - )} - - - {t('moderation.notifications.viewAll', 'View all')} - - - -
-
- ); -}; diff --git a/src/components/header/components/notificationsDropdown/NotificationsDropdown.tsx b/src/components/header/components/notificationsDropdown/NotificationsDropdown.tsx index ce44e8c..2b38fbe 100644 --- a/src/components/header/components/notificationsDropdown/NotificationsDropdown.tsx +++ b/src/components/header/components/notificationsDropdown/NotificationsDropdown.tsx @@ -1,12 +1,9 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { BellOutlined } from '@ant-design/icons'; import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; import { BaseBadge } from '@app/components/common/BaseBadge/BaseBadge'; -import { NotificationsOverlay } from '@app/components/header/components/notificationsDropdown/NotificationsOverlay/NotificationsOverlay'; import { PaymentNotificationsOverlay } from '@app/components/header/components/notificationsDropdown/PaymentNotificationsOverlay'; import ReportNotificationsOverlay from '@app/components/header/components/notificationsDropdown/ReportNotificationsOverlay'; -import { notifications as fetchedNotifications, Notification, Message } from '@app/api/notifications.api'; -import { useModerationNotifications } from '@app/hooks/useModerationNotifications'; import { usePaymentNotifications } from '@app/hooks/usePaymentNotifications'; import { useReportNotifications } from '@app/hooks/useReportNotifications'; import { HeaderActionWrapper } from '@app/components/header/Header.styles'; @@ -14,34 +11,10 @@ import { BasePopover } from '@app/components/common/BasePopover/BasePopover'; import { useTranslation } from 'react-i18next'; import { Tabs } from 'antd'; -// Create a map to transform moderation notifications into regular notifications -const transformModerationToRegular = (modNotification: any): Message => ({ - id: 3, // moderation type (using same ID as error but will use moderation display type) - description: `moderation.notification.${modNotification.content_type}`, - moderationData: { - id: modNotification.id, - pubkey: modNotification.pubkey, - event_id: modNotification.event_id, - reason: modNotification.reason, - created_at: modNotification.created_at, - is_read: modNotification.is_read, - content_type: modNotification.content_type, - media_url: modNotification.media_url, - thumbnail_url: modNotification.thumbnail_url - } -}); - export const NotificationsDropdown: React.FC = () => { const { t } = useTranslation(); - const [allNotifications, setAllNotifications] = useState(fetchedNotifications); const [isOpened, setOpened] = useState(false); - const { - notifications: allModerationNotifications, - markAsRead: markModerationAsRead, - fetchNotifications: refreshModerationNotifications - } = useModerationNotifications(); - const { notifications: allPaymentNotifications, markAsRead: markPaymentAsRead, @@ -56,80 +29,33 @@ export const NotificationsDropdown: React.FC = () => { fetchNotifications: refreshReportNotifications } = useReportNotifications(); - // Filter to only show unread moderation notifications in the dropdown - const moderationNotifications = allModerationNotifications.filter(notification => !notification.is_read); - // Filter to only show unread notifications in the dropdown const paymentNotifications = allPaymentNotifications.filter(notification => !notification.is_read); // Filter to only show unread report notifications in the dropdown const reportNotifications = allReportNotifications.filter(notification => !notification.is_read); - // Use ref to track if we've processed these notifications before - const processedModerationIdsRef = useRef>(new Set()); - - // When moderation notifications change, update the combined notifications list - useEffect(() => { - // Check if we have new notifications - const currentIds = new Set(allModerationNotifications.map(n => n.id)); - const hasChanges = allModerationNotifications.some(n => !processedModerationIdsRef.current.has(n.id)) || - processedModerationIdsRef.current.size !== currentIds.size; - - // Only update if there are changes to avoid unnecessary rerenders - if (hasChanges) { - // Only transform and show unread notifications - const transformedModNotifications = allModerationNotifications - .filter(notification => !notification.is_read) - .map(transformModerationToRegular); - - setAllNotifications([ - ...fetchedNotifications, - ...transformedModNotifications - ]); - - // Update processed IDs - processedModerationIdsRef.current = currentIds; - } - }, [allModerationNotifications]); - // Initialize all notification types useEffect(() => { - refreshModerationNotifications({ filter: 'unread' }); refreshPaymentNotifications({ filter: 'unread' }); refreshReportNotifications({ filter: 'unread' }); - }, [refreshModerationNotifications, refreshPaymentNotifications, refreshReportNotifications]); + }, [refreshPaymentNotifications, refreshReportNotifications]); // Refresh all notifications, only showing unread ones const handleRefresh = useCallback(() => { - refreshModerationNotifications({ filter: 'unread' }); refreshPaymentNotifications({ filter: 'unread' }); refreshReportNotifications({ filter: 'unread' }); - }, [refreshModerationNotifications, refreshPaymentNotifications, refreshReportNotifications]); - - // Handle clearing all notifications, including moderation ones - const handleClearAll = useCallback(() => { - // For regular notifications - setAllNotifications([]); - - // Mark all moderation notifications as read - moderationNotifications.forEach(notification => { - if (!notification.is_read) { - markModerationAsRead(notification.id); - } - }); - }, [moderationNotifications, markModerationAsRead]); + }, [refreshPaymentNotifications, refreshReportNotifications]); // Check specifically for unread notifications - const hasUnreadModerationNotifications = moderationNotifications.some(notification => !notification.is_read); const hasUnreadPaymentNotifications = paymentNotifications.some(notification => !notification.is_read); const hasUnreadReportNotifications = reportNotifications.some(notification => !notification.is_read); - const hasUnreadNotifications = hasUnreadModerationNotifications || hasUnreadPaymentNotifications || hasUnreadReportNotifications; + const hasUnreadNotifications = hasUnreadPaymentNotifications || hasUnreadReportNotifications; // Count unread notifications - const unreadModerationCount = moderationNotifications.filter(notification => !notification.is_read).length; const unreadPaymentCount = paymentNotifications.filter(notification => !notification.is_read).length; const unreadReportCount = reportNotifications.filter(notification => !notification.is_read).length; - const totalUnreadCount = unreadModerationCount + unreadPaymentCount + unreadReportCount; + const totalUnreadCount = unreadPaymentCount + unreadReportCount; // Handle clearing all payment notifications const handleClearAllPayments = useCallback(() => { @@ -142,7 +68,6 @@ export const NotificationsDropdown: React.FC = () => { }, [markAllReportsAsRead]); // Get translated tab names - const moderationLabel = t('moderation.notifications.title', 'Moderation'); const paymentsLabel = t('payment.notifications.title', 'Payments'); const reportsLabel = t('report.notifications.title', 'Reports'); @@ -158,34 +83,6 @@ export const NotificationsDropdown: React.FC = () => { const tabItems = [ { key: '1', - label: ( - - {moderationLabel} - {unreadModerationCount > 0 && ( - - )} - - ), - children: ( -
- { - // This function wrapper allows us to ignore the parameter and call handleClearAll instead - handleClearAll(); - return Promise.resolve(); - }} - markModerationAsRead={markModerationAsRead} - onRefresh={() => { - refreshModerationNotifications({ filter: 'unread' }); - return Promise.resolve(); - }} - /> -
- ), - }, - { - key: '2', label: ( {paymentsLabel} @@ -197,19 +94,19 @@ export const NotificationsDropdown: React.FC = () => { children: (
{ - refreshPaymentNotifications({ filter: 'unread' }); - return Promise.resolve(); - }} - /> + notifications={paymentNotifications} + markAsRead={markPaymentAsRead} + markAllAsRead={handleClearAllPayments} + onRefresh={() => { + refreshPaymentNotifications({ filter: 'unread' }); + return Promise.resolve(); + }} + />
), }, { - key: '3', + key: '2', label: ( {reportsLabel} @@ -221,14 +118,14 @@ export const NotificationsDropdown: React.FC = () => { children: (
{ - refreshReportNotifications({ filter: 'unread' }); - return Promise.resolve(); - }} - /> + notifications={reportNotifications} + markAsRead={markReportAsRead} + markAllAsRead={handleClearAllReports} + onRefresh={() => { + refreshReportNotifications({ filter: 'unread' }); + return Promise.resolve(); + }} + />
), }, diff --git a/src/components/header/components/notificationsDropdown/NotificationsOverlay/NotificationsOverlay.tsx b/src/components/header/components/notificationsDropdown/NotificationsOverlay/NotificationsOverlay.tsx index 3cce630..d7f84f3 100644 --- a/src/components/header/components/notificationsDropdown/NotificationsOverlay/NotificationsOverlay.tsx +++ b/src/components/header/components/notificationsDropdown/NotificationsOverlay/NotificationsOverlay.tsx @@ -3,7 +3,6 @@ import { Trans } from 'react-i18next'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { BaseNotification } from '@app/components/common/BaseNotification/BaseNotification'; -import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; import { capitalize } from '@app/utils/utils'; import { Mention, Notification as NotificationType } from 'api/notifications.api'; import { notificationsSeverities } from 'constants/notificationsSeverities'; @@ -15,14 +14,12 @@ import { BaseSpace } from '@app/components/common/BaseSpace/BaseSpace'; interface NotificationsOverlayProps { notifications: NotificationType[]; setNotifications: (state: NotificationType[]) => void; - markModerationAsRead?: (id: number) => Promise; onRefresh?: () => void; } export const NotificationsOverlay: React.FC = ({ notifications, setNotifications, - markModerationAsRead, onRefresh, ...props }) => { @@ -32,58 +29,6 @@ export const NotificationsOverlay: React.FC = ({ () => notifications.map((notification, index) => { const type = notificationsSeverities.find((dbSeverity) => dbSeverity.id === notification.id)?.name; - const isModerationNotification = !!notification.moderationData; - - // Handle moderation notification specially - if (isModerationNotification) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const modData = notification.moderationData!; - return ( - - {capitalize('moderation')} - - {modData.content_type} - - - } - description={ -
-
{modData.reason}
-
- {new Date(modData.created_at).toLocaleString()} -
- {/* Thumbnail removed - content will be viewable in detail view */} - {!modData.is_read && markModerationAsRead && ( - markModerationAsRead(modData.id)} - style={{ padding: '4px 0', height: 'auto', marginTop: '4px' }} - > - {t('moderation.notifications.markAsRead', 'Mark as read')} - - )} -
- - {t('moderation.notifications.viewDetails', 'View details')} - -
-
- } - /> - ); - } // Regular notification return ( @@ -108,7 +53,7 @@ export const NotificationsOverlay: React.FC = ({ /> ); }), - [notifications, t, markModerationAsRead], + [notifications, t], ); return ( @@ -147,11 +92,6 @@ export const NotificationsOverlay: React.FC = ({ {t('header.notifications.refresh', 'Refresh')} - - - {t('header.notifications.viewAll', 'View all')} - - diff --git a/src/components/header/components/settingsDropdown/settingsOverlay/ServerPicker/SmartBaseModeSettings.tsx b/src/components/header/components/settingsDropdown/settingsOverlay/ServerPicker/SmartBaseModeSettings.tsx index 3d6bb66..5766af8 100644 --- a/src/components/header/components/settingsDropdown/settingsOverlay/ServerPicker/SmartBaseModeSettings.tsx +++ b/src/components/header/components/settingsDropdown/settingsOverlay/ServerPicker/SmartBaseModeSettings.tsx @@ -21,7 +21,7 @@ export const SmartBaseModeSettings: React.FC = () => { const kinds = useAppSelector((state) => state.mode.kinds); const handleModeChange = () => { - dispatch(setMode(mode === 'smart' ? 'unlimited' : 'smart')); + dispatch(setMode(mode === 'whitelist' ? 'blacklist' : 'whitelist')); }; // Example kinds options, adjust as necessary @@ -38,13 +38,13 @@ export const SmartBaseModeSettings: React.FC = () => { {t('common.serverSetting')} -

{mode === 'smart' ? t('common.supportedKindsAndMedia') : t('common.unsupportedKindsAndMedia')}

+

{mode === 'whitelist' ? t('common.supportedKindsAndMedia') : t('common.unsupportedKindsAndMedia')}

<> ` - padding: 0.75rem; - border-radius: ${BORDER_RADIUS}; - transition: background-color 0.3s ease; - - ${({ $isRead }) => - !$isRead && - css` - background-color: var(--background-color); - border-left: 4px solid var(--error-color); - `} - - &:hover { - background-color: var(--secondary-background-color); - } -`; - -export const NotificationContent = styled.div` - display: flex; - flex-direction: column; - gap: 0.5rem; -`; - -export const NotificationText = styled.div` - font-size: ${FONT_SIZE.md}; - color: var(--text-main-color); -`; - -export const NotificationMeta = styled.div` - display: flex; - flex-wrap: wrap; - gap: 1rem; - font-size: ${FONT_SIZE.xs}; - color: var(--text-light-color); - margin-bottom: 0.75rem; -`; - -export const MetaItem = styled.span` - display: inline-flex; - align-items: center; - gap: 0.25rem; -`; - -export const MetaLabel = styled.span` - font-weight: ${FONT_WEIGHT.semibold}; - margin-right: 0.5rem; -`; - -export const MetaValue = styled.span` - display: inline-flex; - align-items: center; -`; - -export const CopyButton = styled(Button)` - margin-left: 0.5rem; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: ${FONT_SIZE.xs}; - padding: 2px 6px; - height: 20px; - border-radius: ${BORDER_RADIUS}; - background-color: var(--background-color); - - &:hover { - background-color: var(--secondary-background-color); - } -`; - -export const ContentContainer = styled.div` - margin-top: 0.75rem; - max-width: 100%; - border-radius: ${BORDER_RADIUS}; - display: flex; - flex-direction: column; - align-items: flex-start; - position: relative; -`; - -export const ModerationBanner = styled.div` - padding: 4px 8px; - background-color: rgba(var(--error-rgb-color), 0.1); - color: var(--error-color); - font-size: ${FONT_SIZE.xs}; - font-weight: ${FONT_WEIGHT.medium}; - border-radius: 4px; - margin-bottom: 0.5rem; - display: flex; - align-items: center; - gap: 6px; -`; - -export const MediaWrapper = styled.div` - position: relative; - width: 100%; - max-width: 300px; - border-radius: ${BORDER_RADIUS}; - overflow: hidden; - border: 1px solid var(--border-color); -`; - -export const StyledImage = styled.img` - max-width: 100%; - max-height: 200px; - object-fit: contain; - display: block; -`; - -export const StyledVideo = styled.video` - max-width: 100%; - max-height: 200px; - display: block; -`; - -export const StyledAudio = styled.audio` - width: 100%; - margin-top: 8px; -`; - -export const MediaError = styled.div` - padding: 1rem; - background-color: var(--secondary-background-color); - color: var(--text-light-color); - font-size: ${FONT_SIZE.xs}; - border-radius: ${BORDER_RADIUS}; - text-align: center; -`; - -export const MarkReadButton = styled(Button)` - align-self: flex-start; - margin-top: 0.5rem; -`; - -export const UserInput = styled(Input)` - width: 100%; -`; - -export const FooterWrapper = styled.div` - margin-top: 1.5rem; - padding-top: 1rem; - border-top: 1px solid var(--border-color); -`; - -export const Text = styled.span` - font-size: ${FONT_SIZE.md}; - font-weight: ${FONT_WEIGHT.regular}; - color: var(--text-main-color); -`; - -export const RedDot = styled.span` - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--error-color); - margin-left: 5px; -`; - -export const ContentTypeTag = styled.span<{ $type: string }>` - padding: 2px 8px; - border-radius: 12px; - font-size: ${FONT_SIZE.xs}; - font-weight: ${FONT_WEIGHT.semibold}; - text-transform: uppercase; - margin-right: 8px; - - ${({ $type }) => { - switch ($type) { - case 'image': - return css` - background-color: rgba(var(--success-rgb-color), 0.1); - color: var(--success-color); - `; - case 'video': - return css` - background-color: rgba(var(--warning-rgb-color), 0.1); - color: var(--warning-color); - `; - default: - return css` - background-color: rgba(var(--primary-rgb-color), 0.1); - color: var(--primary-color); - `; - } - }} -`; diff --git a/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx b/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx deleted file mode 100644 index 5c74dbf..0000000 --- a/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { BaseCard } from '@app/components/common/BaseCard/BaseCard'; -import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; -import { BaseSelect } from '@app/components/common/selects/BaseSelect/BaseSelect'; -import { BaseSpace } from '@app/components/common/BaseSpace/BaseSpace'; -import { BaseRow } from '@app/components/common/BaseRow/BaseRow'; -import { BaseCol } from '@app/components/common/BaseCol/BaseCol'; -import { BasePagination } from '@app/components/common/BasePagination/BasePagination'; -import { BaseNotification } from '@app/components/common/BaseNotification/BaseNotification'; -import { BaseInput } from '@app/components/common/inputs/BaseInput/BaseInput'; -import { EyeOutlined } from '@ant-design/icons'; -import { useModerationNotifications } from '@app/hooks/useModerationNotifications'; -import { ModerationNotification, ModerationNotificationParams } from '@app/hooks/useModerationNotifications'; -import { notificationController } from '@app/controllers/notificationController'; -import * as S from './ModerationNotifications.styles'; - -interface ModerationNotificationsProps { - className?: string; -} - -export const ModerationNotifications: React.FC = ({ className }) => { - const { t } = useTranslation(); - const [filter, setFilter] = useState<'all' | 'unread' | 'user'>('unread'); - const [userPubkey, setUserPubkey] = useState(''); - - const { - notifications, - pagination, - isLoading, - fetchNotifications, - markAsRead, - markAllAsRead - } = useModerationNotifications(); - - // Fetch unread notifications on component mount - useEffect(() => { - fetchNotifications({ - page: 1, - limit: pagination?.pageSize || 10, - filter: 'unread' - }); - }, [fetchNotifications, pagination?.pageSize]); - - const handleFilterChange = (value: unknown) => { - const filterValue = value as 'all' | 'unread' | 'user'; - setFilter(filterValue); - - // Only fetch immediately for "all" and "unread" filters - // For "user" filter, wait for the user to click the Filter button - if (filterValue !== 'user') { - fetchNotifications({ - page: 1, - limit: pagination?.pageSize || 10, - filter: filterValue - }); - } - }; - - const handlePageChange = (page: number) => { - const params: ModerationNotificationParams = { - page, - limit: pagination?.pageSize || 10, - filter - }; - - if (filter === 'user' && userPubkey) { - params.pubkey = userPubkey; - } - - fetchNotifications(params); - }; - - const handleUserPubkeyChange = (e: React.ChangeEvent) => { - setUserPubkey(e.target.value); - }; - - const handleUserPubkeyFilter = () => { - if (userPubkey && filter === 'user') { - fetchNotifications({ - page: 1, - limit: pagination?.pageSize || 10, - filter: 'user', - pubkey: userPubkey - }); - } - }; - - const formatDate = (dateString: string): string => { - const date = new Date(dateString); - return date.toLocaleString(); - }; - - return ( - - - - - - - - {filter === 'user' && ( - <> - - - - - - {t('moderation.notifications.filter', 'Filter')} - - - - )} - - - - {isLoading ? ( -
-
- - {t('common.loading', 'Loading...')} - -
- ) : notifications.length > 0 ? ( - <> - }> - {notifications.map((notification) => ( - - - - {notification.content_type} - - {notification.reason} - - } - description={ - - - - Date: - {formatDate(notification.created_at)} - - - - User: - - {notification.pubkey.substring(0, 10)}... - { - navigator.clipboard.writeText(notification.pubkey); - notificationController.success({ - message: 'User pubkey copied to clipboard' - }); - }} - > - Copy Pubkey - - - - - - Event ID: - - {notification.event_id.substring(0, 10)}... - { - navigator.clipboard.writeText(notification.event_id); - notificationController.success({ - message: 'Event ID copied to clipboard' - }); - }} - > - Copy Event ID - - - - - - {notification.thumbnail_url || notification.media_url ? ( - - - - {t('moderation.notifications.sensitiveContent', 'Sensitive content')} - - - {(() => { - // Get the media URL - const mediaUrl = notification.media_url || notification.thumbnail_url; - - if (!mediaUrl) { - return ( - - {t('moderation.notifications.noMedia', 'No media available')} - - ); - } - - // Format the full URL - const fullMediaUrl = mediaUrl.startsWith('http') - ? mediaUrl - : `${window.location.origin}${mediaUrl.startsWith('/') ? '' : '/'}${mediaUrl}`; - - // Mark notification as read when media is loaded - const handleMediaLoad = () => { - if (!notification.is_read) { - markAsRead(notification.id); - } - }; - - const handleMediaError = () => { - console.error(`Failed to load media: ${fullMediaUrl}`); - }; - - // Render the appropriate media element based on content type - return ( - - {notification.content_type.includes('image') ? ( - - ) : notification.content_type.includes('video') ? ( - - ) : notification.content_type.includes('audio') ? ( - - ) : ( - - {t('moderation.notifications.unsupportedType', 'Unsupported content type')}: {notification.content_type} - - )} - - ); - })()} - - ) : null} - - {!notification.is_read && ( - markAsRead(notification.id)} - size="small" - > - {t('moderation.notifications.markAsRead', 'Mark as read')} - - )} - - } - /> - - ))} - - - - - - {notifications.some(n => !n.is_read) && ( - - {t('moderation.notifications.readAll', 'Mark all as read')} - - )} - - - {pagination && ( - - )} - - - - - ) : ( -
-
🔍
- - {t('moderation.notifications.noNotifications', 'No moderation notifications')} - - - {t('moderation.notifications.emptyDescription', 'Moderation alerts will appear here when content is flagged by the system')} - -
- )} -
- ); -}; diff --git a/src/components/relay-settings/layouts/DesktopLayout.tsx b/src/components/relay-settings/layouts/DesktopLayout.tsx index 3f2bc8b..c192c42 100644 --- a/src/components/relay-settings/layouts/DesktopLayout.tsx +++ b/src/components/relay-settings/layouts/DesktopLayout.tsx @@ -13,6 +13,7 @@ import { AppBucketsSection } from '@app/components/relay-settings/sections/AppBu import { SubscriptionSection } from '@app/components/relay-settings/sections/SubscriptionSection'; import { KindsSection } from '@app/components/relay-settings/sections/KindsSection'; import { MediaSection } from '@app/components/relay-settings/sections/MediaSection'; +import { ModerationSection } from '@app/components/relay-settings/sections/ModerationSection'; import { useTranslation } from 'react-i18next'; import { SubscriptionTier } from '@app/constants/relaySettings'; @@ -68,6 +69,9 @@ interface DesktopLayoutProps { onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; }; + // Moderation section props + moderationMode: string; + onModerationModeChange: (mode: string) => void; } export const DesktopLayout: React.FC = ({ @@ -93,7 +97,6 @@ export const DesktopLayout: React.FC = ({ freeTierEnabled, freeTierLimit, onFreeTierChange, - // Kinds props isKindsActive, selectedKinds, @@ -108,6 +111,9 @@ export const DesktopLayout: React.FC = ({ photos, videos, audio, + // Moderation props + moderationMode, + onModerationModeChange, }) => { const { t } = useTranslation(); @@ -143,6 +149,11 @@ export const DesktopLayout: React.FC = ({ freeTierLimit={freeTierLimit} onFreeTierChange={onFreeTierChange} /> + + @@ -160,9 +171,9 @@ export const DesktopLayout: React.FC = ({ {t('common.serverSetting')} @@ -215,4 +226,4 @@ export const DesktopLayout: React.FC = ({ ); }; -export default DesktopLayout; \ No newline at end of file +export default DesktopLayout; diff --git a/src/components/relay-settings/layouts/MobileLayout.tsx b/src/components/relay-settings/layouts/MobileLayout.tsx index 5313fab..b4f7d0b 100644 --- a/src/components/relay-settings/layouts/MobileLayout.tsx +++ b/src/components/relay-settings/layouts/MobileLayout.tsx @@ -10,6 +10,7 @@ import { AppBucketsSection } from '@app/components/relay-settings/sections/AppBu import { SubscriptionSection } from '@app/components/relay-settings/sections/SubscriptionSection'; import { KindsSection } from '@app/components/relay-settings/sections/KindsSection'; import { MediaSection } from '@app/components/relay-settings/sections/MediaSection'; +import { ModerationSection } from '@app/components/relay-settings/sections/ModerationSection'; import { useTranslation } from 'react-i18next'; import { SubscriptionTier } from '@app/constants/relaySettings'; @@ -65,6 +66,9 @@ interface MobileLayoutProps { onChange: (values: string[]) => void; onToggle: (checked: boolean) => void; }; + // Moderation section props + moderationMode: string; + onModerationModeChange: (mode: string) => void; } export const MobileLayout: React.FC = ({ @@ -104,6 +108,9 @@ export const MobileLayout: React.FC = ({ photos, videos, audio, + // Moderation props + moderationMode, + onModerationModeChange, }) => { const { t } = useTranslation(); @@ -138,6 +145,11 @@ export const MobileLayout: React.FC = ({ onFreeTierChange={onFreeTierChange} /> + + = ({ }} > {t('common.serverSetting')} - + = ({ ); }; -export default MobileLayout; \ No newline at end of file +export default MobileLayout; diff --git a/src/components/relay-settings/sections/KindsSection/KindsSection.tsx b/src/components/relay-settings/sections/KindsSection/KindsSection.tsx index 2b6fda6..7c7bfa0 100644 --- a/src/components/relay-settings/sections/KindsSection/KindsSection.tsx +++ b/src/components/relay-settings/sections/KindsSection/KindsSection.tsx @@ -33,13 +33,13 @@ export const KindsSection: React.FC = ({ onAddKind, onRemoveKind, }) => { - const header = mode !== 'smart' ? 'Blacklisted Kind Numbers' : 'Kind Numbers'; + const header = mode !== 'whitelist' ? 'Blacklisted Kind Numbers' : 'Kind Numbers'; return (
- {mode !== 'unlimited' && mode !== '' && ( + {mode !== 'blacklist' && mode !== '' && (
= ({ ); }; -export default KindsSection; \ No newline at end of file +export default KindsSection; diff --git a/src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx b/src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx index ee4880d..6a0b332 100644 --- a/src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx +++ b/src/components/relay-settings/sections/KindsSection/components/AddKindForm.tsx @@ -19,7 +19,7 @@ export const AddKindForm: React.FC = ({ onAddKind, mode }) => } }; - if (mode === 'smart') { + if (mode === 'whitelist') { return null; } @@ -40,4 +40,4 @@ export const AddKindForm: React.FC = ({ onAddKind, mode }) => ); }; -export default AddKindForm; \ No newline at end of file +export default AddKindForm; diff --git a/src/components/relay-settings/sections/KindsSection/components/DynamicKindsList.tsx b/src/components/relay-settings/sections/KindsSection/components/DynamicKindsList.tsx index 620d494..07c52a4 100644 --- a/src/components/relay-settings/sections/KindsSection/components/DynamicKindsList.tsx +++ b/src/components/relay-settings/sections/KindsSection/components/DynamicKindsList.tsx @@ -21,7 +21,7 @@ export const DynamicKindsList: React.FC = ({ onRemoveKind, mode, }) => { - if (!dynamicKinds.length || mode === 'smart') { + if (!dynamicKinds.length || mode === 'whitelist') { return null; } @@ -32,7 +32,7 @@ export const DynamicKindsList: React.FC = ({ return ( @@ -43,7 +43,7 @@ export const DynamicKindsList: React.FC = ({ >
= ({ ); }; -export default DynamicKindsList; \ No newline at end of file +export default DynamicKindsList; diff --git a/src/components/relay-settings/sections/KindsSection/components/KindsList.tsx b/src/components/relay-settings/sections/KindsSection/components/KindsList.tsx index aacdc41..ae7951c 100644 --- a/src/components/relay-settings/sections/KindsSection/components/KindsList.tsx +++ b/src/components/relay-settings/sections/KindsSection/components/KindsList.tsx @@ -34,7 +34,7 @@ export const KindsList: React.FC = ({ className="large-label" value={selectedKinds} onChange={(checkedValues) => onKindsChange(checkedValues as string[])} - disabled={mode !== 'smart' ? false : !isKindsActive} + disabled={mode !== 'whitelist' ? false : !isKindsActive} > {groupedNoteOptions.map((group) => (
@@ -44,11 +44,11 @@ export const KindsList: React.FC = ({
= ({ ); }; -export default KindsList; \ No newline at end of file +export default KindsList; diff --git a/src/components/relay-settings/sections/MediaSection/MediaSection.tsx b/src/components/relay-settings/sections/MediaSection/MediaSection.tsx index b68f8da..0b93df2 100644 --- a/src/components/relay-settings/sections/MediaSection/MediaSection.tsx +++ b/src/components/relay-settings/sections/MediaSection/MediaSection.tsx @@ -76,7 +76,7 @@ export const MediaSection: React.FC = ({ audio, }) => { const getHeader = (type: string) => - mode !== 'smart' ? `Blacklisted ${type} Extensions` : `${type} Extensions`; + mode !== 'whitelist' ? `Blacklisted ${type} Extensions` : `${type} Extensions`; return ( <> @@ -134,4 +134,4 @@ export const MediaSection: React.FC = ({ ); }; -export default MediaSection; \ No newline at end of file +export default MediaSection; diff --git a/src/components/relay-settings/sections/MediaSection/components/MediaToggle.tsx b/src/components/relay-settings/sections/MediaSection/components/MediaToggle.tsx index 647589a..edd670d 100644 --- a/src/components/relay-settings/sections/MediaSection/components/MediaToggle.tsx +++ b/src/components/relay-settings/sections/MediaSection/components/MediaToggle.tsx @@ -14,7 +14,7 @@ export const MediaToggle: React.FC = ({ onChange, mode, }) => { - if (mode === 'unlimited') { + if (mode === 'blacklist') { return null; } @@ -30,4 +30,4 @@ export const MediaToggle: React.FC = ({ ); }; -export default MediaToggle; \ No newline at end of file +export default MediaToggle; diff --git a/src/components/relay-settings/sections/MediaSection/components/MediaTypeList.tsx b/src/components/relay-settings/sections/MediaSection/components/MediaTypeList.tsx index da6c096..c03ef5e 100644 --- a/src/components/relay-settings/sections/MediaSection/components/MediaTypeList.tsx +++ b/src/components/relay-settings/sections/MediaSection/components/MediaTypeList.tsx @@ -44,13 +44,13 @@ export const MediaTypeList: React.FC = ({ return ( onChange(checkedValues as string[])} - disabled={mode !== 'smart' ? false : !isActive} + disabled={mode !== 'whitelist' ? false : !isActive} /> ); }; -export default MediaTypeList; \ No newline at end of file +export default MediaTypeList; diff --git a/src/components/relay-settings/sections/ModerationSection/ModerationSection.tsx b/src/components/relay-settings/sections/ModerationSection/ModerationSection.tsx new file mode 100644 index 0000000..22c2013 --- /dev/null +++ b/src/components/relay-settings/sections/ModerationSection/ModerationSection.tsx @@ -0,0 +1,43 @@ +// src/components/relay-settings/sections/ModerationSection/ModerationSection.tsx + +import React from 'react'; +import { BaseCol } from '@app/components/common/BaseCol/BaseCol'; +import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; +import { CollapsibleSection } from '../../shared/CollapsibleSection/CollapsibleSection'; +import { ModerationModeSelect } from './components/ModerationModeSelect'; + +export interface ModerationSectionProps { + moderationMode: string; + onModerationModeChange: (mode: string) => void; +} + +export const ModerationSection: React.FC = ({ + moderationMode, + onModerationModeChange, +}) => { + return ( + + + + + +
+

+ Moderation mode determines how events with media are handled while pending moderation. +
+
+ Strict Mode: Events with media are not queryable while pending moderation, except by their authors. +
+ Passive Mode: Events with media are queryable by everyone while pending moderation. +

+
+
+
+
+ ); +}; + +export default ModerationSection; diff --git a/src/components/relay-settings/sections/ModerationSection/components/ModerationModeSelect.tsx b/src/components/relay-settings/sections/ModerationSection/components/ModerationModeSelect.tsx new file mode 100644 index 0000000..c53eab3 --- /dev/null +++ b/src/components/relay-settings/sections/ModerationSection/components/ModerationModeSelect.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Radio, Space, Typography } from 'antd'; +import * as S from '@app/pages/uiComponentsPages/UIComponentsPage.styles'; +import { useTranslation } from 'react-i18next'; + +const { Text } = Typography; + +interface ModerationModeSelectProps { + moderationMode: string; + onChange: (mode: string) => void; +} + +export const ModerationModeSelect: React.FC = ({ + moderationMode, + onChange, +}) => { + const { t } = useTranslation(); + + return ( +
+ + {t('Moderation Mode')} + + + onChange(e.target.value)} + style={{ marginBottom: '1rem' }} + > + + + Strict Mode +
+ + Events with media are not queryable while pending moderation, except by their authors. + +
+
+ + + Passive Mode +
+ + Events with media are queryable by everyone while pending moderation. + +
+
+
+
+
+ ); +}; + +export default ModerationModeSelect; diff --git a/src/components/relay-settings/sections/ModerationSection/index.ts b/src/components/relay-settings/sections/ModerationSection/index.ts new file mode 100644 index 0000000..de14a18 --- /dev/null +++ b/src/components/relay-settings/sections/ModerationSection/index.ts @@ -0,0 +1,4 @@ +// src/components/relay-settings/sections/ModerationSection/index.ts + +export * from './ModerationSection'; +export { default } from './ModerationSection'; diff --git a/src/components/router/AppRouter.tsx b/src/components/router/AppRouter.tsx index 60f0a33..71fa9f1 100644 --- a/src/components/router/AppRouter.tsx +++ b/src/components/router/AppRouter.tsx @@ -35,7 +35,6 @@ const AdvancedFormsPage = React.lazy(() => import('@app/pages/AdvancedFormsPage' const PersonalInfoPage = React.lazy(() => import('@app/pages/PersonalInfoPage')); const SecuritySettingsPage = React.lazy(() => import('@app/pages/SecuritySettingsPage')); const NotificationsPage = React.lazy(() => import('@app/pages/NotificationsPage')); -const ModerationNotificationsPage = React.lazy(() => import('@app/pages/ModerationNotificationsPage')); const PaymentNotificationsPage = React.lazy(() => import('@app/pages/PaymentNotificationsPage')); const ReportNotificationsPage = React.lazy(() => import('@app/pages/ReportNotificationsPage')); const PaymentsPage = React.lazy(() => import('@app/pages/PaymentsPage')); @@ -130,7 +129,6 @@ const Error404 = withLoading(Error404Page); const PersonalInfo = withLoading(PersonalInfoPage); const SecuritySettings = withLoading(SecuritySettingsPage); const Notifications = withLoading(NotificationsPage); -const ModerationNotifications = withLoading(ModerationNotificationsPage); const PaymentNotifications = withLoading(PaymentNotificationsPage); const ReportNotifications = withLoading(ReportNotificationsPage); const Payments = withLoading(PaymentsPage); @@ -180,7 +178,6 @@ export const AppRouter: React.FC = () => { } /> } /> - } /> } /> } /> diff --git a/src/constants/notificationsSeverities.ts b/src/constants/notificationsSeverities.ts index d35ecf4..a0b4a77 100644 --- a/src/constants/notificationsSeverities.ts +++ b/src/constants/notificationsSeverities.ts @@ -20,7 +20,7 @@ export const notificationsSeverities: NotificationSeverity[] = [ }, { id: 3, - name: 'moderation', // Changed from 'error' to 'moderation' + name: 'error', }, { id: 4, diff --git a/src/constants/relaySettings.ts b/src/constants/relaySettings.ts index 201d4af..ffb96fb 100644 --- a/src/constants/relaySettings.ts +++ b/src/constants/relaySettings.ts @@ -18,6 +18,7 @@ export type Settings = { subscription_tiers: SubscriptionTier[]; freeTierEnabled: boolean; // New field freeTierLimit: string; // New field - e.g. "100 MB per month + moderationMode: string; // "strict" or "passive" } export type SubscriptionTier = { diff --git a/src/hooks/useModerationNotifications.ts b/src/hooks/useModerationNotifications.ts deleted file mode 100644 index ee758e9..0000000 --- a/src/hooks/useModerationNotifications.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { notificationController } from '@app/controllers/notificationController'; -import config from '@app/config/config'; -import { readToken } from '@app/services/localStorage.service'; -import { useHandleLogout } from './authUtils'; - -// Static variables outside the hook to ensure true singleton pattern -let isInitialized = false; -let activePollingInterval: NodeJS.Timeout | null = null; -let globalNotifications: ModerationNotification[] = []; -let globalPagination: PaginationData | null = null; -let globalLastFetchTime = 0; -let globalPreviousIds = new Set(); - -// Types moved from the API file to here -export interface ModerationNotificationParams { - page?: number; - limit?: number; - filter?: 'all' | 'unread' | 'user'; - pubkey?: string; -} - -export interface ModerationNotification { - id: number; - pubkey: string; - event_id: string; - reason: string; - created_at: string; - is_read: boolean; - content_type: string; - media_url?: string; - thumbnail_url?: string; -} - -export interface PaginationData { - currentPage: number; - pageSize: number; - totalItems: number; - totalPages: number; - hasNext: boolean; - hasPrevious: boolean; -} - -interface UseModerationNotificationsResult { - notifications: ModerationNotification[]; - pagination: PaginationData | null; - isLoading: boolean; - error: string | null; - fetchNotifications: (params?: ModerationNotificationParams) => Promise; - markAsRead: (id: number) => Promise; - markAllAsRead: () => Promise; -} - -/** - * Shared fetchNotifications function to be used by all hook instances - */ -const fetchModerationNotifications = async (params: ModerationNotificationParams = {}) => { - try { - const token = readToken(); - // Construct query parameters - const queryParams = new URLSearchParams(); - if (params.page) queryParams.append('page', params.page.toString()); - if (params.limit) queryParams.append('limit', params.limit.toString()); - if (params.filter) queryParams.append('filter', params.filter); - if (params.pubkey) queryParams.append('pubkey', params.pubkey); - - const response = await fetch(`${config.baseURL}/api/moderation/notifications?${queryParams}`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - }); - - if (response.status === 204) { - // 204 No Content means no notifications, return empty arrays - return { notifications: [], pagination: { currentPage: 1, pageSize: params.limit || 10, totalItems: 0, totalPages: 0, hasNext: false, hasPrevious: false } }; - } else if (!response.ok) { - throw new Error(`Request failed: ${response.status}`); - } - - // Only parse JSON if there's content - const data = response.status !== 204 ? await response.json() : { notifications: [], pagination: null }; - - // Check if there are new notifications - if (globalLastFetchTime > 0) { - const newNotifications = data.notifications.filter((n: ModerationNotification) => !globalPreviousIds.has(n.id)); - - // Notify user if there are new notifications and this isn't the first fetch - if (newNotifications.length > 0) { - notificationController.info({ - message: 'New moderation notifications', - description: `${newNotifications.length} new notification(s) received` - }); - } - } - - // Track manually marked as read notifications to prevent them from reappearing - const manuallyMarkedAsRead = new Set(); - - // If we have existing notifications that are marked as read but server has them as unread, - // we'll trust our local state (they were manually marked as read) - globalNotifications.forEach(notification => { - if (notification.is_read) { - manuallyMarkedAsRead.add(notification.id); - } - }); - - // Merge server data with our local knowledge of read status - const mergedNotifications = data.notifications.map((notification: ModerationNotification) => { - // If we previously marked this as read manually, keep it marked as read - if (manuallyMarkedAsRead.has(notification.id)) { - return { ...notification, is_read: true }; - } - return notification; - }); - - // Update global state - globalNotifications = mergedNotifications; - globalPagination = data.pagination; - globalLastFetchTime = Date.now(); - globalPreviousIds = new Set(data.notifications.map((n: ModerationNotification) => n.id)); - - return { notifications: data.notifications, pagination: data.pagination }; - } catch (error) { - throw error; - } -}; - -/** - * Initialize the singleton polling mechanism - */ -const initializePolling = (initialParams?: ModerationNotificationParams) => { - if (isInitialized) return; - - isInitialized = true; - - // Set up polling - if (activePollingInterval) { - clearInterval(activePollingInterval); - } - - // Initial fetch - fetchModerationNotifications(initialParams).catch(error => - console.error('Failed to fetch moderation notifications:', error) - ); - - // Set up recurring polling - activePollingInterval = setInterval(() => { - fetchModerationNotifications(initialParams).catch(error => - console.error('Failed to fetch moderation notifications:', error) - ); - }, config.notifications.pollingInterval); - - // Set up visibility change handler - const handleVisibilityChange = () => { - // Clear existing interval - if (activePollingInterval) { - clearInterval(activePollingInterval); - } - - // Use different interval based on visibility - const interval = document.hidden - ? config.notifications.backgroundPollingInterval - : config.notifications.pollingInterval; - - // Set up new interval - activePollingInterval = setInterval(() => { - fetchModerationNotifications(initialParams).catch(error => - console.error('Failed to fetch moderation notifications:', error) - ); - }, interval); - }; - - // Set up visibility change listener - document.addEventListener('visibilitychange', handleVisibilityChange); -}; - -export const useModerationNotifications = (initialParams?: ModerationNotificationParams): UseModerationNotificationsResult => { - const [notifications, setNotifications] = useState(globalNotifications); - const [pagination, setPagination] = useState(globalPagination); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const handleLogout = useHandleLogout(); - const token = readToken(); - - // Initialize polling on first mount of any instance - useEffect(() => { - // Initialize the singleton poller - initializePolling(initialParams); - - // Subscribe to changes in the global state - const checkForUpdates = setInterval(() => { - setNotifications(globalNotifications); - setPagination(globalPagination); - }, 1000); // Check every second - - return () => { - clearInterval(checkForUpdates); - }; - }, [initialParams]); - - const fetchNotifications = useCallback(async (params: ModerationNotificationParams = {}) => { - setIsLoading(true); - setError(null); - - try { - const { notifications: newNotifications, pagination: newPagination } = await fetchModerationNotifications(params); - - // Local state update - setNotifications(newNotifications); - setPagination(newPagination); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to fetch moderation notifications'; - setError(errorMessage); - notificationController.error({ message: errorMessage }); - } finally { - setIsLoading(false); - } - }, []); - - // Mark a specific notification as read - const markAsRead = useCallback(async (id: number) => { - try { - console.log(`[useModerationNotifications] Marking notification ${id} as read`); - - const response = await fetch(`${config.baseURL}/api/moderation/notifications/read`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ id: id }), // Changed from array to single value to match backend expectation - }); - - if (!response.ok) { - if (response.status === 401) { - handleLogout(); - return; - } - throw new Error(`Request failed: ${response.status}`); - } - - // Update local state - setNotifications(prev => - prev.map(notification => - notification.id === id - ? { ...notification, is_read: true } - : notification - ) - ); - - // Update global state as well to ensure all components see the change - globalNotifications = globalNotifications.map(notification => - notification.id === id - ? { ...notification, is_read: true } - : notification - ); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to mark notification as read'; - notificationController.error({ message: errorMessage }); - } - }, [token, handleLogout]); - - // Mark all notifications as read - const markAllAsRead = useCallback(async () => { - try { - console.log(`[useModerationNotifications] Marking all notifications as read`); - - const response = await fetch(`${config.baseURL}/api/moderation/notifications/read-all`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - }); - - if (!response.ok) { - if (response.status === 401) { - handleLogout(); - return; - } - throw new Error(`Request failed: ${response.status}`); - } - - // Update local state as well - setNotifications(prev => - prev.map(notification => ({ ...notification, is_read: true })) - ); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to mark all notifications as read'; - notificationController.error({ message: errorMessage }); - } - }, [token, handleLogout]); - - - return { - notifications, - pagination, - isLoading, - error, - fetchNotifications, - markAsRead, - markAllAsRead - }; -}; diff --git a/src/hooks/useRelaySettings.ts b/src/hooks/useRelaySettings.ts index 9a3337c..4aa6d01 100644 --- a/src/hooks/useRelaySettings.ts +++ b/src/hooks/useRelaySettings.ts @@ -20,6 +20,7 @@ interface BackendRelaySettings { subscription_tiers: BackendSubscriptionTier[]; freeTierEnabled: boolean; // New field freeTierLimit: string; // New field - e.g. "100 MB per month" + moderationMode: string; // "strict" or "passive" MimeTypeGroups: { images: string[]; videos: string[]; @@ -38,7 +39,7 @@ const defaultTiers: SubscriptionTier[] = [ ]; const getInitialSettings = (): Settings => ({ - mode: 'smart', + mode: 'whitelist', protocol: ['WebSocket'], kinds: [], dynamicKinds: [], @@ -56,7 +57,8 @@ const getInitialSettings = (): Settings => ({ isFileStorageActive: false, subscription_tiers: defaultTiers, freeTierEnabled: false, - freeTierLimit: '100 MB per month' + freeTierLimit: '100 MB per month', + moderationMode: 'strict' // Default to strict mode }); const useRelaySettings = () => { @@ -83,7 +85,7 @@ const useRelaySettings = () => { lastMode.current = relaySettings.mode; - if (relaySettings.mode === 'unlimited') { + if (relaySettings.mode === 'blacklist') { // Store current settings before clearing setPreviousSmartSettings({ kinds: relaySettings.kinds, @@ -99,8 +101,8 @@ const useRelaySettings = () => { videos: [], audio: [], })); - } else if (relaySettings.mode === 'smart' && previousSmartSettings) { - // Restore previous smart mode settings + } else if (relaySettings.mode === 'whitelist' && previousSmartSettings) { + // Restore previous whitelist mode settings setRelaySettings(prev => ({ ...prev, kinds: previousSmartSettings.kinds, @@ -139,14 +141,15 @@ const useRelaySettings = () => { })), freeTierEnabled: settings.freeTierEnabled, freeTierLimit: settings.freeTierLimit, + moderationMode: settings.moderationMode, MimeTypeGroups: mimeGroups, isFileStorageActive: settings.isFileStorageActive, - MimeTypeWhitelist: settings.mode === 'smart' + MimeTypeWhitelist: settings.mode === 'whitelist' ? selectedMimeTypes : mimeTypeOptions .map(m => m.value) .filter(mimeType => !selectedMimeTypes.includes(mimeType)), - KindWhitelist: settings.mode === 'smart' + KindWhitelist: settings.mode === 'whitelist' ? settings.kinds : noteOptions .map(note => note.kindString) @@ -161,6 +164,7 @@ const useRelaySettings = () => { settings.protocol = backendSettings.protocol as string[]; settings.freeTierEnabled = backendSettings.freeTierEnabled ?? false; settings.freeTierLimit = backendSettings.freeTierLimit ?? '100 MB per month'; + settings.moderationMode = backendSettings.moderationMode ?? 'strict'; // Handle subscription tiers if (Array.isArray(backendSettings.subscription_tiers)) { @@ -179,20 +183,20 @@ const useRelaySettings = () => { settings.subscription_tiers = defaultTiers; } - if (backendSettings.mode === 'unlimited') { - // In unlimited mode, start with empty selections + if (backendSettings.mode === 'blacklist') { + // In blacklist mode, start with empty selections settings.photos = []; settings.videos = []; settings.audio = []; settings.kinds = []; } else { - // In smart mode, use the MimeTypeGroups directly + // In whitelist mode, use the MimeTypeGroups directly settings.photos = backendSettings.MimeTypeGroups?.images || []; settings.videos = backendSettings.MimeTypeGroups?.videos || []; settings.audio = backendSettings.MimeTypeGroups?.audio || []; settings.kinds = backendSettings.KindWhitelist || []; - // Store these as the previous smart settings + // Store these as the previous whitelist settings setPreviousSmartSettings({ kinds: settings.kinds, photos: settings.photos, @@ -250,8 +254,8 @@ const useRelaySettings = () => { if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - // Update previous smart settings after successful save - if (relaySettings.mode === 'smart') { + // Update previous whitelist settings after successful save + if (relaySettings.mode === 'whitelist') { setPreviousSmartSettings({ kinds: relaySettings.kinds, photos: relaySettings.photos, diff --git a/src/pages/ModerationNotificationsPage.tsx b/src/pages/ModerationNotificationsPage.tsx deleted file mode 100644 index 17c8624..0000000 --- a/src/pages/ModerationNotificationsPage.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { PageTitle } from '@app/components/common/PageTitle/PageTitle'; -import { ModerationNotifications } from '@app/components/moderation/ModerationNotifications/ModerationNotifications'; -import { BaseRow } from '@app/components/common/BaseRow/BaseRow'; -import { BaseCol } from '@app/components/common/BaseCol/BaseCol'; - -const ModerationNotificationsPage: React.FC = () => { - const { t } = useTranslation(); - - return ( - <> - {t('moderation.notifications.pageTitle', 'Moderation Notifications')} - - - ); -}; - -export default ModerationNotificationsPage; diff --git a/src/pages/RelaySettingsPage.tsx b/src/pages/RelaySettingsPage.tsx index 8acff2d..767e29f 100644 --- a/src/pages/RelaySettingsPage.tsx +++ b/src/pages/RelaySettingsPage.tsx @@ -25,7 +25,7 @@ const RelaySettingsPage: React.FC = () => { // Local state for settings const [settings, setSettings] = useState({ - mode: JSON.parse(localStorage.getItem('relaySettings') || '{}').mode || relaymode || 'unlimited', + mode: JSON.parse(localStorage.getItem('relaySettings') || '{}').mode || relaymode || 'blacklist', protocol: ['WebSocket'], kinds: [], dynamicKinds: [], @@ -42,8 +42,9 @@ const RelaySettingsPage: React.FC = () => { isAudioActive: true, isFileStorageActive: false, subscription_tiers: [], - freeTierEnabled: false, // Add this - freeTierLimit: '100 MB per month' // Add this + freeTierEnabled: false, + freeTierLimit: '100 MB per month', + moderationMode: 'strict' // Default to strict mode }); // Initialize stored dynamic items @@ -92,7 +93,7 @@ const RelaySettingsPage: React.FC = () => { // Reset blacklist when mode changes useEffect(() => { - if (settings.mode === 'unlimited') return; + if (settings.mode === 'blacklist') return; setBlacklist({ kinds: [], photos: [], @@ -103,14 +104,14 @@ const RelaySettingsPage: React.FC = () => { }, [settings.mode]); const handleModeChange = (checked: boolean) => { - const newMode = checked ? 'smart' : 'unlimited'; + const newMode = checked ? 'whitelist' : 'blacklist'; setSettings(prev => ({ ...prev, mode: newMode, - kinds: newMode === 'unlimited' ? [] : prev.kinds, - photos: newMode === 'unlimited' ? [] : prev.photos, - videos: newMode === 'unlimited' ? [] : prev.videos, - audio: newMode === 'unlimited' ? [] : prev.audio, + kinds: newMode === 'blacklist' ? [] : prev.kinds, + photos: newMode === 'blacklist' ? [] : prev.photos, + videos: newMode === 'blacklist' ? [] : prev.videos, + audio: newMode === 'blacklist' ? [] : prev.audio, })); updateSettings('mode', newMode); dispatch(setMode(newMode)); @@ -138,6 +139,7 @@ const RelaySettingsPage: React.FC = () => { updateSettings('freeTierEnabled', settings.freeTierEnabled), updateSettings('freeTierLimit', settings.freeTierLimit), updateSettings('subscription_tiers', settings.subscription_tiers), + updateSettings('moderationMode', settings.moderationMode), ]); await saveSettings(); @@ -232,6 +234,12 @@ const RelaySettingsPage: React.FC = () => { updateSettings(type, checked); }; + // Moderation mode handler + const handleModerationModeChange = (mode: string) => { + setSettings(prev => ({ ...prev, moderationMode: mode })); + updateSettings('moderationMode', mode); + }; + const layoutProps = { mode: settings.mode, onModeChange: handleModeChange, @@ -269,7 +277,6 @@ const RelaySettingsPage: React.FC = () => { updateSettings('freeTierEnabled', enabled); updateSettings('freeTierLimit', limit); }, - // Kinds props isKindsActive: settings.isKindsActive, selectedKinds: settings.kinds, @@ -299,6 +306,9 @@ const RelaySettingsPage: React.FC = () => { onChange: (values: string[]) => handleMediaChange('audio', values), onToggle: (checked: boolean) => handleMediaToggle('isAudioActive', checked), }, + // Moderation props + moderationMode: settings.moderationMode, + onModerationModeChange: handleModerationModeChange, }; return ( diff --git a/src/services/localStorage.service.ts b/src/services/localStorage.service.ts index 0635d62..9d9d53e 100644 --- a/src/services/localStorage.service.ts +++ b/src/services/localStorage.service.ts @@ -33,12 +33,12 @@ export const readUser = (): UserModel | null => { } }; -export const persistRelayMode = (relayMode: 'unlimited' | 'smart'): void => { +export const persistRelayMode = (relayMode: 'blacklist' | 'whitelist'): void => { localStorage.setItem('relayMode', relayMode); }; -export const readRelayMode = (): 'unlimited' | 'smart' => { - return (localStorage.getItem('relayMode') as 'unlimited' | 'smart') || 'unlimited'; // default to 'unlimited' if nothing is stored +export const readRelayMode = (): 'blacklist' | 'whitelist' => { + return (localStorage.getItem('relayMode') as 'blacklist' | 'whitelist') || 'blacklist'; // default to 'blacklist' if nothing is stored }; export const deleteToken = (): void => localStorage.removeItem('accessToken'); @@ -55,4 +55,4 @@ export const readWalletToken = (): string => { export const deleteWalletToken = (): void => { localStorage.removeItem('walletToken'); // Remove the wallet token when logging out -}; \ No newline at end of file +}; diff --git a/src/store/slices/modeSlice.ts b/src/store/slices/modeSlice.ts index f2ab1a6..60bd27d 100644 --- a/src/store/slices/modeSlice.ts +++ b/src/store/slices/modeSlice.ts @@ -2,7 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { ModeState } from '@app/types/modeTypes'; const initialState: ModeState = { - relayMode: 'unlimited', + relayMode: 'blacklist', kinds: [], mediaTypes: [], }; @@ -11,24 +11,24 @@ export const modeSlice = createSlice({ name: 'relaymode', initialState, reducers: { - setMode: (state, action: PayloadAction<'unlimited' | 'smart'>) => { + setMode: (state, action: PayloadAction<'blacklist' | 'whitelist'>) => { console.log(`Before update: ${state.relayMode}`); state.relayMode = action.payload; console.log(`After update: ${state.relayMode}`); // state.relayMode = action.payload; - // // Automatically reset kinds and media types if mode is switched to 'base' - // if (action.payload === 'unlimited') { + // // Automatically reset kinds and media types if mode is switched to 'blacklist' + // if (action.payload === 'blacklist') { // state.kinds = []; // state.mediaTypes = []; // } }, setKinds: (state, action: PayloadAction) => { - if (state.relayMode === 'smart') { + if (state.relayMode === 'whitelist') { state.kinds = action.payload; } }, setMediaTypes: (state, action: PayloadAction) => { - if (state.relayMode === 'smart') { + if (state.relayMode === 'whitelist') { state.mediaTypes = action.payload; } }, diff --git a/src/types/modeTypes.ts b/src/types/modeTypes.ts index 5eaf6a4..6b73568 100644 --- a/src/types/modeTypes.ts +++ b/src/types/modeTypes.ts @@ -1,6 +1,6 @@ // Define the state and action types for mode settings export interface ModeState { - relayMode: 'unlimited' | 'smart'; + relayMode: 'blacklist' | 'whitelist'; kinds: number[]; mediaTypes: string[]; }