From db1776f9146b2c4fe5ddf43c72e873156dd304ad Mon Sep 17 00:00:00 2001 From: Maphikza Date: Sat, 19 Apr 2025 16:27:45 +0200 Subject: [PATCH 1/4] Implement image moderation unblocking functionality and scroll reset This commit includes: - New API endpoints for viewing and unblocking moderated events - Enhanced ModerationNotifications component with event details modal - Unblock button for false positives in moderation system - Scroll position reset after event deletion - Updated BaseModal to use 'open' prop instead of deprecated 'visible' prop --- src/api/moderationNotifications.api.ts | 146 +++- src/components/common/BaseModal/BaseModal.tsx | 31 +- .../ModerationNotifications.styles.ts | 91 ++- .../ModerationNotifications.tsx | 716 ++++++++++++------ src/hooks/useModerationNotifications.ts | 45 +- 5 files changed, 801 insertions(+), 228 deletions(-) diff --git a/src/api/moderationNotifications.api.ts b/src/api/moderationNotifications.api.ts index e735e73..e930917 100644 --- a/src/api/moderationNotifications.api.ts +++ b/src/api/moderationNotifications.api.ts @@ -1,4 +1,10 @@ -import { httpApi } from './http.api'; +import config from '@app/config/config'; +import { readToken } from '@app/services/localStorage.service'; + +export interface BlockedEventResponse { + event: any; // Full Nostr event details + moderation_details: ModerationNotification; +} export interface ModerationNotificationParams { page?: number; @@ -45,22 +51,148 @@ export interface ModerationStats { export const getModerationNotifications = async ( params: ModerationNotificationParams = {}, ): Promise => { - const response = await httpApi.get('/api/moderation/notifications', { params }); - return response.data; + 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}`); + } + + return await response.json(); }; // Mark a specific notification as read export const markNotificationAsRead = async (id: number): Promise => { - await httpApi.post('/api/moderation/notifications/read', { id: [id] }); + const token = readToken(); + + 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] }), + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } }; // Mark all notifications as read export const markAllNotificationsAsRead = async (): Promise => { - await httpApi.post('/api/moderation/notifications/read-all'); + const token = readToken(); + + const response = await fetch(`${config.baseURL}/api/moderation/notifications/read-all`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } }; // Get moderation statistics export const getModerationStats = async (): Promise => { - const response = await httpApi.get('/api/moderation/stats'); - return response.data; + const token = readToken(); + + const response = await fetch(`${config.baseURL}/api/moderation/stats`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + return await response.json(); +}; + +// Get details of a blocked event +export const getBlockedEvent = async (eventId: string): Promise => { + const token = readToken(); + + const response = await fetch(`${config.baseURL}/api/moderation/blocked-event/${eventId}`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + return await response.json(); +}; + +// Unblock an incorrectly flagged event +export const unblockEvent = async (eventId: string): Promise<{ success: boolean; message: string; event_id: string }> => { + const token = readToken(); + + const response = await fetch(`${config.baseURL}/api/moderation/unblock`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ event_id: eventId }), + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + return await response.json(); +}; + +// Permanently delete a moderated event +export const deleteModeratedEvent = async (eventId: string): Promise<{ success: boolean; message: string; event_id: string }> => { + const token = readToken(); + + const response = await fetch(`${config.baseURL}/api/moderation/event/${eventId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + return await response.json(); }; 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/moderation/ModerationNotifications/ModerationNotifications.styles.ts b/src/components/moderation/ModerationNotifications/ModerationNotifications.styles.ts index b44803f..b0f3376 100644 --- a/src/components/moderation/ModerationNotifications/ModerationNotifications.styles.ts +++ b/src/components/moderation/ModerationNotifications/ModerationNotifications.styles.ts @@ -105,23 +105,27 @@ export const ModerationBanner = styled.div` export const MediaWrapper = styled.div` position: relative; width: 100%; - max-width: 300px; + max-width: 400px; border-radius: ${BORDER_RADIUS}; overflow: hidden; border: 1px solid var(--border-color); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin-bottom: 1rem; `; export const StyledImage = styled.img` max-width: 100%; - max-height: 200px; + max-height: 300px; object-fit: contain; display: block; + background-color: #000; `; export const StyledVideo = styled.video` max-width: 100%; - max-height: 200px; + max-height: 300px; display: block; + background-color: #000; `; export const StyledAudio = styled.audio` @@ -196,3 +200,84 @@ export const ContentTypeTag = styled.span<{ $type: string }>` } }} `; + +// Action buttons container +export const ActionButtons = styled.div` + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +`; + +// View event button +export const ViewEventButton = styled(Button)` + display: flex; + align-items: center; +`; + +// Event details modal styles +export const EventDetailsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 1.5rem; + padding-bottom: 1rem; +`; + +export const EventSection = styled.div` + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1.25rem; + background-color: var(--background-color); + border-radius: ${BORDER_RADIUS}; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +`; + +export const SectionTitle = styled.h3` + font-size: ${FONT_SIZE.lg}; + font-weight: ${FONT_WEIGHT.semibold}; + margin-bottom: 0.5rem; + color: var(--heading-color); +`; + +export const DetailItem = styled.div` + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 0.5rem; +`; + +export const DetailLabel = styled.span` + font-weight: ${FONT_WEIGHT.semibold}; + color: var(--text-light-color); + font-size: ${FONT_SIZE.xs}; +`; + +export const DetailValue = styled.span` + color: var(--text-main-color); + font-size: ${FONT_SIZE.md}; + word-break: break-all; +`; + +export const EventContent = styled.pre` + background-color: var(--secondary-background-color); + padding: 1rem; + border-radius: ${BORDER_RADIUS}; + overflow-x: auto; + font-family: monospace; + font-size: ${FONT_SIZE.xs}; + white-space: pre-wrap; + word-break: break-word; + max-height: 250px; + overflow-y: auto; + margin-bottom: 0.5rem; + border: 1px solid var(--border-color); +`; + +export const MediaContainer = styled.div` + margin-top: 1rem; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + padding-bottom: 0.5rem; +`; diff --git a/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx b/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx index 5c74dbf..1f74878 100644 --- a/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx +++ b/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { BaseCard } from '@app/components/common/BaseCard/BaseCard'; import { BaseButton } from '@app/components/common/BaseButton/BaseButton'; @@ -9,9 +9,12 @@ 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 { BaseModal } from '@app/components/common/BaseModal/BaseModal'; +import { EyeOutlined, DeleteOutlined } from '@ant-design/icons'; +import { Modal } from 'antd'; import { useModerationNotifications } from '@app/hooks/useModerationNotifications'; import { ModerationNotification, ModerationNotificationParams } from '@app/hooks/useModerationNotifications'; +import { BlockedEventResponse } from '@app/api/moderationNotifications.api'; import { notificationController } from '@app/controllers/notificationController'; import * as S from './ModerationNotifications.styles'; @@ -24,13 +27,28 @@ export const ModerationNotifications: React.FC = ( const [filter, setFilter] = useState<'all' | 'unread' | 'user'>('unread'); const [userPubkey, setUserPubkey] = useState(''); + // State for event details modal + const [isModalVisible, setIsModalVisible] = useState(false); + const [selectedEventId, setSelectedEventId] = useState(null); + const [blockedEventDetails, setBlockedEventDetails] = useState(null); + const [isEventLoading, setIsEventLoading] = useState(false); + + // Ref for the container to control scroll position + const containerRef = useRef(null); + + // Flag to track if we need to reset scroll position + const [shouldResetScroll, setShouldResetScroll] = useState(false); + const { notifications, pagination, isLoading, fetchNotifications, markAsRead, - markAllAsRead + markAllAsRead, + getBlockedEvent, + unblockEvent, + deleteEvent } = useModerationNotifications(); // Fetch unread notifications on component mount @@ -91,225 +109,495 @@ export const ModerationNotifications: React.FC = ( return date.toLocaleString(); }; + // Function to handle viewing event details + const handleViewEvent = async (eventId: string) => { + setSelectedEventId(eventId); + setIsEventLoading(true); + + try { + const details = await getBlockedEvent(eventId); + setBlockedEventDetails(details); + setIsModalVisible(true); + } catch (error) { + console.error('Failed to fetch event details:', error); + notificationController.error({ + message: 'Failed to fetch event details', + description: error instanceof Error ? error.message : 'Unknown error' + }); + } finally { + setIsEventLoading(false); + } + }; + + // Function to handle unblocking an event + const handleUnblock = async () => { + if (!selectedEventId) return; + + setIsEventLoading(true); + + try { + const result = await unblockEvent(selectedEventId); + if (result.success) { + notificationController.success({ + message: 'Event unblocked successfully', + description: 'The event has been unblocked and will now be visible to users' + }); + setIsModalVisible(false); + + // Refresh the notifications list + fetchNotifications({ + page: pagination?.currentPage || 1, + limit: pagination?.pageSize || 10, + filter + }); + } + } catch (error) { + console.error('Failed to unblock event:', error); + notificationController.error({ + message: 'Failed to unblock event', + description: error instanceof Error ? error.message : 'Unknown error' + }); + } finally { + setIsEventLoading(false); + } + }; + + // Function to handle permanent deletion of an event + const handleDelete = async (eventId: string) => { + setIsEventLoading(true); + + try { + const result = await deleteEvent(eventId); + if (result.success) { + notificationController.success({ + message: 'Event deleted permanently', + description: 'The event has been permanently removed from the system' + }); + + // Close modal if it was open + if (isModalVisible) { + setIsModalVisible(false); + } + + // Set flag to reset scroll position after data is fetched + setShouldResetScroll(true); + + // Refresh the notifications list + fetchNotifications({ + page: pagination?.currentPage || 1, + limit: pagination?.pageSize || 10, + filter + }); + } + } catch (error) { + console.error('Failed to delete event:', error); + notificationController.error({ + message: 'Failed to delete event', + description: error instanceof Error ? error.message : 'Unknown error' + }); + } finally { + setIsEventLoading(false); + } + }; + + // Effect to reset scroll position when notifications change or after deletion + useEffect(() => { + // Reset scroll position when notifications change and shouldResetScroll is true + if (shouldResetScroll && containerRef.current) { + // Reset scroll position to top + containerRef.current.scrollTop = 0; + window.scrollTo(0, 0); + + // Reset the flag + setShouldResetScroll(false); + } + }, [notifications, shouldResetScroll]); + + // Confirmation dialog before deletion + const confirmDelete = (eventId: string) => { + Modal.confirm({ + title: t('moderation.notifications.confirmDelete', 'Permanently Delete Event'), + content: t('moderation.notifications.confirmDeleteMessage', 'This action cannot be undone. The event will be permanently removed from the system.'), + okText: t('moderation.notifications.delete', 'Delete'), + okType: 'danger', + cancelText: t('common.cancel', 'Cancel'), + onOk: () => handleDelete(eventId) + }); + }; + return ( - - - - - - - - {filter === 'user' && ( - <> - - - - - - {t('moderation.notifications.filter', 'Filter')} - - - - )} - - +
+ + + + + + + + {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)} - + {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 + + + + - - User: - - {notification.pubkey.substring(0, 10)}... - { - navigator.clipboard.writeText(notification.pubkey); - notificationController.success({ - message: 'User pubkey copied to clipboard' - }); - }} - > - Copy Pubkey - - - + {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} - - Event ID: - - {notification.event_id.substring(0, 10)}... - { - navigator.clipboard.writeText(notification.event_id); - notificationController.success({ - message: 'Event ID copied to clipboard' - }); - }} + + {!notification.is_read && ( + markAsRead(notification.id)} + size="small" > - Copy Event ID - - - -
- - {notification.thumbnail_url || notification.media_url ? ( - - - - {t('moderation.notifications.sensitiveContent', 'Sensitive content')} - + {t('moderation.notifications.markAsRead', 'Mark as read')} + + )} + handleViewEvent(notification.event_id)} + size="small" + icon={} + > + {t('moderation.notifications.viewEvent', 'View Full Event')} + + } + onClick={() => confirmDelete(notification.event_id)} + > + {t('moderation.notifications.delete', 'Delete Permanently')} + + +
+ } + /> +
+ ))} +
+ + + + + {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')} + +
+ )} + + {/* Event Details Modal */} + setIsModalVisible(false)} + footer={ +
+
+ } + loading={isEventLoading} + onClick={() => selectedEventId && confirmDelete(selectedEventId)} + > + {t('moderation.notifications.delete', 'Delete Permanently')} + +
+
+ setIsModalVisible(false)}> + {t('common.close', 'Close')} + + + {t('moderation.notifications.unblock', 'Unblock Event')} + +
+
+ } + size="large" + bodyStyle={{ padding: '16px', maxHeight: '70vh', overflow: 'auto' }} + > + {blockedEventDetails ? ( + + + {t('moderation.notifications.moderationDetails', 'Moderation Details')} + + {t('moderation.notifications.reason', 'Reason')}: + {blockedEventDetails.moderation_details.reason} + + + {t('moderation.notifications.contentType', 'Content Type')}: + {blockedEventDetails.moderation_details.content_type} + + + {t('moderation.notifications.date', 'Date')}: + {formatDate(blockedEventDetails.moderation_details.created_at)} + + + + + {t('moderation.notifications.eventContent', 'Event Content')} + {blockedEventDetails.event && ( + <> + + {t('moderation.notifications.pubkey', 'Pubkey')}: + {blockedEventDetails.event.pubkey} + + + {t('moderation.notifications.kind', 'Kind')}: + {blockedEventDetails.event.kind} + + + {t('moderation.notifications.content', 'Content')}: + + {blockedEventDetails.event.content} + + + + {blockedEventDetails.moderation_details.media_url && ( + + + + {t('moderation.notifications.sensitiveContent', 'Sensitive content')} + + + {(() => { + const mediaUrl = blockedEventDetails.moderation_details.media_url; + const fullMediaUrl = mediaUrl.startsWith('http') + ? mediaUrl + : `${window.location.origin}${mediaUrl.startsWith('/') ? '' : '/'}${mediaUrl}`; - {(() => { - // Get the media URL - const mediaUrl = notification.media_url || notification.thumbnail_url; - - if (!mediaUrl) { - return ( + const contentType = blockedEventDetails.moderation_details.content_type; + + return ( + + {contentType.includes('image') ? ( + + ) : contentType.includes('video') ? ( + + ) : contentType.includes('audio') ? ( + + ) : ( - {t('moderation.notifications.noMedia', 'No media available')} + {t('moderation.notifications.unsupportedType', 'Unsupported content type')}: {contentType} - ); - } - - // 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')} - -
- )} -
+ + + ) : ( +
+
+ + {t('common.loading', 'Loading...')} + +
+ )} + + +
); }; diff --git a/src/hooks/useModerationNotifications.ts b/src/hooks/useModerationNotifications.ts index ee758e9..6ad5e70 100644 --- a/src/hooks/useModerationNotifications.ts +++ b/src/hooks/useModerationNotifications.ts @@ -3,6 +3,8 @@ import { notificationController } from '@app/controllers/notificationController' import config from '@app/config/config'; import { readToken } from '@app/services/localStorage.service'; import { useHandleLogout } from './authUtils'; +import * as moderationNotificationsApi from '@app/api/moderationNotifications.api'; +import { BlockedEventResponse } from '@app/api/moderationNotifications.api'; // Static variables outside the hook to ensure true singleton pattern let isInitialized = false; @@ -49,6 +51,9 @@ interface UseModerationNotificationsResult { fetchNotifications: (params?: ModerationNotificationParams) => Promise; markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; + getBlockedEvent: (eventId: string) => Promise; + unblockEvent: (eventId: string) => Promise<{ success: boolean; message: string; event_id: string }>; + deleteEvent: (eventId: string) => Promise<{ success: boolean; message: string; event_id: string }>; } /** @@ -293,6 +298,41 @@ export const useModerationNotifications = (initialParams?: ModerationNotificatio } }, [token, handleLogout]); + // Get details of a blocked event + const getBlockedEvent = useCallback(async (eventId: string) => { + try { + console.log(`[useModerationNotifications] Getting details for blocked event ${eventId}`); + return await moderationNotificationsApi.getBlockedEvent(eventId); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch blocked event details'; + notificationController.error({ message: errorMessage }); + throw err; + } + }, []); + + // Unblock an incorrectly flagged event + const unblockEvent = useCallback(async (eventId: string) => { + try { + console.log(`[useModerationNotifications] Unblocking event ${eventId}`); + return await moderationNotificationsApi.unblockEvent(eventId); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to unblock event'; + notificationController.error({ message: errorMessage }); + throw err; + } + }, []); + + // Permanently delete a moderated event + const deleteEvent = useCallback(async (eventId: string) => { + try { + console.log(`[useModerationNotifications] Permanently deleting event ${eventId}`); + return await moderationNotificationsApi.deleteModeratedEvent(eventId); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to delete event'; + notificationController.error({ message: errorMessage }); + throw err; + } + }, []); return { notifications, @@ -301,6 +341,9 @@ export const useModerationNotifications = (initialParams?: ModerationNotificatio error, fetchNotifications, markAsRead, - markAllAsRead + markAllAsRead, + getBlockedEvent, + unblockEvent, + deleteEvent }; }; From 6bea3852900a46c904f5825ab7538547b3c867b4 Mon Sep 17 00:00:00 2001 From: Maphikza Date: Sat, 19 Apr 2025 21:49:05 +0200 Subject: [PATCH 2/4] Add moderation mode to relay settings with improved text contrast This commit includes: - Implementation of moderation mode feature in relay settings - Addition of strict and passive mode options - Improved text color contrast for better readability on dark backgrounds - Integration with existing relay settings UI --- .env.development | 2 +- .../relay-settings/layouts/DesktopLayout.tsx | 15 +++++- .../relay-settings/layouts/MobileLayout.tsx | 14 ++++- .../ModerationSection/ModerationSection.tsx | 43 +++++++++++++++ .../components/ModerationModeSelect.tsx | 54 +++++++++++++++++++ .../sections/ModerationSection/index.ts | 4 ++ src/constants/relaySettings.ts | 1 + src/hooks/useRelaySettings.ts | 6 ++- src/pages/RelaySettingsPage.tsx | 16 ++++-- 9 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 src/components/relay-settings/sections/ModerationSection/ModerationSection.tsx create mode 100644 src/components/relay-settings/sections/ModerationSection/components/ModerationModeSelect.tsx create mode 100644 src/components/relay-settings/sections/ModerationSection/index.ts 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/src/components/relay-settings/layouts/DesktopLayout.tsx b/src/components/relay-settings/layouts/DesktopLayout.tsx index 3f2bc8b..1ecf59b 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} /> + + @@ -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..d998049 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} /> + + = ({ ); }; -export default MobileLayout; \ No newline at end of file +export default MobileLayout; 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/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/useRelaySettings.ts b/src/hooks/useRelaySettings.ts index 9a3337c..8eb9440 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[]; @@ -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 = () => { @@ -139,6 +141,7 @@ const useRelaySettings = () => { })), freeTierEnabled: settings.freeTierEnabled, freeTierLimit: settings.freeTierLimit, + moderationMode: settings.moderationMode, MimeTypeGroups: mimeGroups, isFileStorageActive: settings.isFileStorageActive, MimeTypeWhitelist: settings.mode === 'smart' @@ -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)) { diff --git a/src/pages/RelaySettingsPage.tsx b/src/pages/RelaySettingsPage.tsx index 8acff2d..fb9dd2d 100644 --- a/src/pages/RelaySettingsPage.tsx +++ b/src/pages/RelaySettingsPage.tsx @@ -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 @@ -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 ( From dfb3d88fe58c4df6b1dd9850c9d3c84ac9c75b2b Mon Sep 17 00:00:00 2001 From: Maphikza Date: Thu, 8 May 2025 11:33:17 +0200 Subject: [PATCH 3/4] refactor: update mode terminology from smart/unlimited to whitelist/blacklist --- README.md | 2 +- .../ServerPicker/SmartBaseModeSettings.tsx | 10 ++--- .../ModerationNotifications.tsx | 39 +++++++++++++++++-- .../relay-settings/layouts/DesktopLayout.tsx | 6 +-- .../relay-settings/layouts/MobileLayout.tsx | 14 +++---- .../sections/KindsSection/KindsSection.tsx | 6 +-- .../KindsSection/components/AddKindForm.tsx | 4 +- .../components/DynamicKindsList.tsx | 8 ++-- .../KindsSection/components/KindsList.tsx | 10 ++--- .../sections/MediaSection/MediaSection.tsx | 4 +- .../MediaSection/components/MediaToggle.tsx | 4 +- .../MediaSection/components/MediaTypeList.tsx | 6 +-- src/hooks/useModerationNotifications.ts | 16 ++++++-- src/hooks/useRelaySettings.ts | 24 ++++++------ src/pages/RelaySettingsPage.tsx | 14 +++---- src/services/localStorage.service.ts | 8 ++-- src/store/slices/modeSlice.ts | 12 +++--- src/types/modeTypes.ts | 2 +- 18 files changed, 115 insertions(+), 74 deletions(-) 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/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')}

<> = ( setIsModalVisible(true); } catch (error) { console.error('Failed to fetch event details:', error); - notificationController.error({ - message: 'Failed to fetch event details', - description: error instanceof Error ? error.message : 'Unknown error' - }); + + // Check if this is a 400 error (event not found) + if (error instanceof Error && error.message.includes('400')) { + // Add this event ID to the list of non-existent events + // This is done by accessing the module-level variable directly + (window as any).nonExistentEventIds = (window as any).nonExistentEventIds || new Set(); + (window as any).nonExistentEventIds.add(eventId); + + // Show success message to the user + notificationController.success({ + message: 'Notification removed', + description: 'The notification was automatically removed because the referenced event no longer exists' + }); + + // Try to delete on backend, but don't worry if it fails + try { + await deleteEvent(eventId); + } catch (deleteError) { + // Silently ignore the error since we're handling it locally + console.log('Backend delete failed, handling locally:', deleteError); + } + + // Refresh the notifications list to ensure our changes take effect + fetchNotifications({ + page: pagination?.currentPage || 1, + limit: pagination?.pageSize || 10, + filter + }); + } else { + // For other errors, show the standard error message + notificationController.error({ + message: 'Failed to fetch event details', + description: error instanceof Error ? error.message : 'Unknown error' + }); + } } finally { setIsEventLoading(false); } diff --git a/src/components/relay-settings/layouts/DesktopLayout.tsx b/src/components/relay-settings/layouts/DesktopLayout.tsx index 1ecf59b..c192c42 100644 --- a/src/components/relay-settings/layouts/DesktopLayout.tsx +++ b/src/components/relay-settings/layouts/DesktopLayout.tsx @@ -171,9 +171,9 @@ export const DesktopLayout: React.FC = ({ {t('common.serverSetting')}
diff --git a/src/components/relay-settings/layouts/MobileLayout.tsx b/src/components/relay-settings/layouts/MobileLayout.tsx index d998049..b4f7d0b 100644 --- a/src/components/relay-settings/layouts/MobileLayout.tsx +++ b/src/components/relay-settings/layouts/MobileLayout.tsx @@ -160,13 +160,13 @@ export const MobileLayout: React.FC = ({ }} > {t('common.serverSetting')} - + = ({ 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/hooks/useModerationNotifications.ts b/src/hooks/useModerationNotifications.ts index 6ad5e70..4015611 100644 --- a/src/hooks/useModerationNotifications.ts +++ b/src/hooks/useModerationNotifications.ts @@ -56,6 +56,11 @@ interface UseModerationNotificationsResult { deleteEvent: (eventId: string) => Promise<{ success: boolean; message: string; event_id: string }>; } +// Set of event IDs that we know don't exist in the backend +// Store it in the window object so it can be accessed from other components +(window as any).nonExistentEventIds = (window as any).nonExistentEventIds || new Set(); +const nonExistentEventIds = (window as any).nonExistentEventIds; + /** * Shared fetchNotifications function to be used by all hook instances */ @@ -110,8 +115,13 @@ const fetchModerationNotifications = async (params: ModerationNotificationParams } }); + // Filter out notifications for events that we know don't exist + const filteredNotifications = data.notifications.filter( + (notification: ModerationNotification) => !nonExistentEventIds.has(notification.event_id) + ); + // Merge server data with our local knowledge of read status - const mergedNotifications = data.notifications.map((notification: ModerationNotification) => { + const mergedNotifications = filteredNotifications.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 }; @@ -123,9 +133,9 @@ const fetchModerationNotifications = async (params: ModerationNotificationParams globalNotifications = mergedNotifications; globalPagination = data.pagination; globalLastFetchTime = Date.now(); - globalPreviousIds = new Set(data.notifications.map((n: ModerationNotification) => n.id)); + globalPreviousIds = new Set(filteredNotifications.map((n: ModerationNotification) => n.id)); - return { notifications: data.notifications, pagination: data.pagination }; + return { notifications: mergedNotifications, pagination: data.pagination }; } catch (error) { throw error; } diff --git a/src/hooks/useRelaySettings.ts b/src/hooks/useRelaySettings.ts index 8eb9440..4aa6d01 100644 --- a/src/hooks/useRelaySettings.ts +++ b/src/hooks/useRelaySettings.ts @@ -39,7 +39,7 @@ const defaultTiers: SubscriptionTier[] = [ ]; const getInitialSettings = (): Settings => ({ - mode: 'smart', + mode: 'whitelist', protocol: ['WebSocket'], kinds: [], dynamicKinds: [], @@ -85,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, @@ -101,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, @@ -144,12 +144,12 @@ const useRelaySettings = () => { 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) @@ -183,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, @@ -254,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/RelaySettingsPage.tsx b/src/pages/RelaySettingsPage.tsx index fb9dd2d..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: [], @@ -93,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: [], @@ -104,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)); 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[]; } From 8426b9d05d6fa260800838dd732d1d4a0443778e Mon Sep 17 00:00:00 2001 From: Maphikza Date: Thu, 8 May 2025 13:27:37 +0200 Subject: [PATCH 4/4] Remove moderation notifications as moderation is now automated --- .gitignore | 1 + src/api/moderationNotifications.api.ts | 198 ------ src/api/notifications.api.ts | 13 +- .../BaseNotification/BaseNotification.tsx | 4 +- .../ModerationNotificationsOverlay.tsx | 127 ---- .../NotificationsDropdown.tsx | 147 +--- .../NotificationsOverlay.tsx | 62 +- .../ModerationNotifications.styles.ts | 283 -------- .../ModerationNotifications.tsx | 634 ------------------ src/components/router/AppRouter.tsx | 3 - src/constants/notificationsSeverities.ts | 2 +- src/hooks/useModerationNotifications.ts | 359 ---------- src/pages/ModerationNotificationsPage.tsx | 19 - 13 files changed, 27 insertions(+), 1825 deletions(-) delete mode 100644 src/api/moderationNotifications.api.ts delete mode 100644 src/components/header/components/notificationsDropdown/ModerationNotificationsOverlay/ModerationNotificationsOverlay.tsx delete mode 100644 src/components/moderation/ModerationNotifications/ModerationNotifications.styles.ts delete mode 100644 src/components/moderation/ModerationNotifications/ModerationNotifications.tsx delete mode 100644 src/hooks/useModerationNotifications.ts delete mode 100644 src/pages/ModerationNotificationsPage.tsx 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/src/api/moderationNotifications.api.ts b/src/api/moderationNotifications.api.ts deleted file mode 100644 index e930917..0000000 --- a/src/api/moderationNotifications.api.ts +++ /dev/null @@ -1,198 +0,0 @@ -import config from '@app/config/config'; -import { readToken } from '@app/services/localStorage.service'; - -export interface BlockedEventResponse { - event: any; // Full Nostr event details - moderation_details: ModerationNotification; -} - -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 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}`); - } - - return await response.json(); -}; - -// Mark a specific notification as read -export const markNotificationAsRead = async (id: number): Promise => { - const token = readToken(); - - 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] }), - }); - - if (!response.ok) { - throw new Error(`Request failed: ${response.status}`); - } -}; - -// Mark all notifications as read -export const markAllNotificationsAsRead = async (): Promise => { - const token = readToken(); - - const response = await fetch(`${config.baseURL}/api/moderation/notifications/read-all`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - }); - - if (!response.ok) { - throw new Error(`Request failed: ${response.status}`); - } -}; - -// Get moderation statistics -export const getModerationStats = async (): Promise => { - const token = readToken(); - - const response = await fetch(`${config.baseURL}/api/moderation/stats`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - }); - - if (!response.ok) { - throw new Error(`Request failed: ${response.status}`); - } - - return await response.json(); -}; - -// Get details of a blocked event -export const getBlockedEvent = async (eventId: string): Promise => { - const token = readToken(); - - const response = await fetch(`${config.baseURL}/api/moderation/blocked-event/${eventId}`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - }); - - if (!response.ok) { - throw new Error(`Request failed: ${response.status}`); - } - - return await response.json(); -}; - -// Unblock an incorrectly flagged event -export const unblockEvent = async (eventId: string): Promise<{ success: boolean; message: string; event_id: string }> => { - const token = readToken(); - - const response = await fetch(`${config.baseURL}/api/moderation/unblock`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ event_id: eventId }), - }); - - if (!response.ok) { - throw new Error(`Request failed: ${response.status}`); - } - - return await response.json(); -}; - -// Permanently delete a moderated event -export const deleteModeratedEvent = async (eventId: string): Promise<{ success: boolean; message: string; event_id: string }> => { - const token = readToken(); - - const response = await fetch(`${config.baseURL}/api/moderation/event/${eventId}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - }); - - if (!response.ok) { - throw new Error(`Request failed: ${response.status}`); - } - - return await response.json(); -}; 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/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/moderation/ModerationNotifications/ModerationNotifications.styles.ts b/src/components/moderation/ModerationNotifications/ModerationNotifications.styles.ts deleted file mode 100644 index b0f3376..0000000 --- a/src/components/moderation/ModerationNotifications/ModerationNotifications.styles.ts +++ /dev/null @@ -1,283 +0,0 @@ -import styled, { css } from 'styled-components'; -import { Input, Button, Divider } from 'antd'; -import { FONT_SIZE, BORDER_RADIUS, FONT_WEIGHT } from '@app/styles/themes/constants'; - -export const FiltersWrapper = styled.div` - margin-bottom: 1.5rem; -`; - -export const SplitDivider = styled(Divider)` - margin: 0.5rem 0; -`; - -export const NotificationItem = styled.div<{ $isRead: boolean }>` - 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: 400px; - border-radius: ${BORDER_RADIUS}; - overflow: hidden; - border: 1px solid var(--border-color); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - margin-bottom: 1rem; -`; - -export const StyledImage = styled.img` - max-width: 100%; - max-height: 300px; - object-fit: contain; - display: block; - background-color: #000; -`; - -export const StyledVideo = styled.video` - max-width: 100%; - max-height: 300px; - display: block; - background-color: #000; -`; - -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); - `; - } - }} -`; - -// Action buttons container -export const ActionButtons = styled.div` - display: flex; - gap: 0.5rem; - margin-top: 0.5rem; -`; - -// View event button -export const ViewEventButton = styled(Button)` - display: flex; - align-items: center; -`; - -// Event details modal styles -export const EventDetailsContainer = styled.div` - display: flex; - flex-direction: column; - gap: 1.5rem; - padding-bottom: 1rem; -`; - -export const EventSection = styled.div` - display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 1.25rem; - background-color: var(--background-color); - border-radius: ${BORDER_RADIUS}; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); -`; - -export const SectionTitle = styled.h3` - font-size: ${FONT_SIZE.lg}; - font-weight: ${FONT_WEIGHT.semibold}; - margin-bottom: 0.5rem; - color: var(--heading-color); -`; - -export const DetailItem = styled.div` - display: flex; - flex-direction: column; - gap: 0.25rem; - margin-bottom: 0.5rem; -`; - -export const DetailLabel = styled.span` - font-weight: ${FONT_WEIGHT.semibold}; - color: var(--text-light-color); - font-size: ${FONT_SIZE.xs}; -`; - -export const DetailValue = styled.span` - color: var(--text-main-color); - font-size: ${FONT_SIZE.md}; - word-break: break-all; -`; - -export const EventContent = styled.pre` - background-color: var(--secondary-background-color); - padding: 1rem; - border-radius: ${BORDER_RADIUS}; - overflow-x: auto; - font-family: monospace; - font-size: ${FONT_SIZE.xs}; - white-space: pre-wrap; - word-break: break-word; - max-height: 250px; - overflow-y: auto; - margin-bottom: 0.5rem; - border: 1px solid var(--border-color); -`; - -export const MediaContainer = styled.div` - margin-top: 1rem; - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - padding-bottom: 0.5rem; -`; diff --git a/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx b/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx deleted file mode 100644 index 75cbaa9..0000000 --- a/src/components/moderation/ModerationNotifications/ModerationNotifications.tsx +++ /dev/null @@ -1,634 +0,0 @@ -import React, { useState, useEffect, useRef } 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 { BaseModal } from '@app/components/common/BaseModal/BaseModal'; -import { EyeOutlined, DeleteOutlined } from '@ant-design/icons'; -import { Modal } from 'antd'; -import { useModerationNotifications } from '@app/hooks/useModerationNotifications'; -import { ModerationNotification, ModerationNotificationParams } from '@app/hooks/useModerationNotifications'; -import { BlockedEventResponse } from '@app/api/moderationNotifications.api'; -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(''); - - // State for event details modal - const [isModalVisible, setIsModalVisible] = useState(false); - const [selectedEventId, setSelectedEventId] = useState(null); - const [blockedEventDetails, setBlockedEventDetails] = useState(null); - const [isEventLoading, setIsEventLoading] = useState(false); - - // Ref for the container to control scroll position - const containerRef = useRef(null); - - // Flag to track if we need to reset scroll position - const [shouldResetScroll, setShouldResetScroll] = useState(false); - - const { - notifications, - pagination, - isLoading, - fetchNotifications, - markAsRead, - markAllAsRead, - getBlockedEvent, - unblockEvent, - deleteEvent - } = 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(); - }; - - // Function to handle viewing event details - const handleViewEvent = async (eventId: string) => { - setSelectedEventId(eventId); - setIsEventLoading(true); - - try { - const details = await getBlockedEvent(eventId); - setBlockedEventDetails(details); - setIsModalVisible(true); - } catch (error) { - console.error('Failed to fetch event details:', error); - - // Check if this is a 400 error (event not found) - if (error instanceof Error && error.message.includes('400')) { - // Add this event ID to the list of non-existent events - // This is done by accessing the module-level variable directly - (window as any).nonExistentEventIds = (window as any).nonExistentEventIds || new Set(); - (window as any).nonExistentEventIds.add(eventId); - - // Show success message to the user - notificationController.success({ - message: 'Notification removed', - description: 'The notification was automatically removed because the referenced event no longer exists' - }); - - // Try to delete on backend, but don't worry if it fails - try { - await deleteEvent(eventId); - } catch (deleteError) { - // Silently ignore the error since we're handling it locally - console.log('Backend delete failed, handling locally:', deleteError); - } - - // Refresh the notifications list to ensure our changes take effect - fetchNotifications({ - page: pagination?.currentPage || 1, - limit: pagination?.pageSize || 10, - filter - }); - } else { - // For other errors, show the standard error message - notificationController.error({ - message: 'Failed to fetch event details', - description: error instanceof Error ? error.message : 'Unknown error' - }); - } - } finally { - setIsEventLoading(false); - } - }; - - // Function to handle unblocking an event - const handleUnblock = async () => { - if (!selectedEventId) return; - - setIsEventLoading(true); - - try { - const result = await unblockEvent(selectedEventId); - if (result.success) { - notificationController.success({ - message: 'Event unblocked successfully', - description: 'The event has been unblocked and will now be visible to users' - }); - setIsModalVisible(false); - - // Refresh the notifications list - fetchNotifications({ - page: pagination?.currentPage || 1, - limit: pagination?.pageSize || 10, - filter - }); - } - } catch (error) { - console.error('Failed to unblock event:', error); - notificationController.error({ - message: 'Failed to unblock event', - description: error instanceof Error ? error.message : 'Unknown error' - }); - } finally { - setIsEventLoading(false); - } - }; - - // Function to handle permanent deletion of an event - const handleDelete = async (eventId: string) => { - setIsEventLoading(true); - - try { - const result = await deleteEvent(eventId); - if (result.success) { - notificationController.success({ - message: 'Event deleted permanently', - description: 'The event has been permanently removed from the system' - }); - - // Close modal if it was open - if (isModalVisible) { - setIsModalVisible(false); - } - - // Set flag to reset scroll position after data is fetched - setShouldResetScroll(true); - - // Refresh the notifications list - fetchNotifications({ - page: pagination?.currentPage || 1, - limit: pagination?.pageSize || 10, - filter - }); - } - } catch (error) { - console.error('Failed to delete event:', error); - notificationController.error({ - message: 'Failed to delete event', - description: error instanceof Error ? error.message : 'Unknown error' - }); - } finally { - setIsEventLoading(false); - } - }; - - // Effect to reset scroll position when notifications change or after deletion - useEffect(() => { - // Reset scroll position when notifications change and shouldResetScroll is true - if (shouldResetScroll && containerRef.current) { - // Reset scroll position to top - containerRef.current.scrollTop = 0; - window.scrollTo(0, 0); - - // Reset the flag - setShouldResetScroll(false); - } - }, [notifications, shouldResetScroll]); - - // Confirmation dialog before deletion - const confirmDelete = (eventId: string) => { - Modal.confirm({ - title: t('moderation.notifications.confirmDelete', 'Permanently Delete Event'), - content: t('moderation.notifications.confirmDeleteMessage', 'This action cannot be undone. The event will be permanently removed from the system.'), - okText: t('moderation.notifications.delete', 'Delete'), - okType: 'danger', - cancelText: t('common.cancel', 'Cancel'), - onOk: () => handleDelete(eventId) - }); - }; - - 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')} - - )} - handleViewEvent(notification.event_id)} - size="small" - icon={} - > - {t('moderation.notifications.viewEvent', 'View Full Event')} - - } - onClick={() => confirmDelete(notification.event_id)} - > - {t('moderation.notifications.delete', 'Delete Permanently')} - - - - } - /> - - ))} - - - - - - {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')} - -
- )} - - {/* Event Details Modal */} - setIsModalVisible(false)} - footer={ -
-
- } - loading={isEventLoading} - onClick={() => selectedEventId && confirmDelete(selectedEventId)} - > - {t('moderation.notifications.delete', 'Delete Permanently')} - -
-
- setIsModalVisible(false)}> - {t('common.close', 'Close')} - - - {t('moderation.notifications.unblock', 'Unblock Event')} - -
-
- } - size="large" - bodyStyle={{ padding: '16px', maxHeight: '70vh', overflow: 'auto' }} - > - {blockedEventDetails ? ( - - - {t('moderation.notifications.moderationDetails', 'Moderation Details')} - - {t('moderation.notifications.reason', 'Reason')}: - {blockedEventDetails.moderation_details.reason} - - - {t('moderation.notifications.contentType', 'Content Type')}: - {blockedEventDetails.moderation_details.content_type} - - - {t('moderation.notifications.date', 'Date')}: - {formatDate(blockedEventDetails.moderation_details.created_at)} - - - - - {t('moderation.notifications.eventContent', 'Event Content')} - {blockedEventDetails.event && ( - <> - - {t('moderation.notifications.pubkey', 'Pubkey')}: - {blockedEventDetails.event.pubkey} - - - {t('moderation.notifications.kind', 'Kind')}: - {blockedEventDetails.event.kind} - - - {t('moderation.notifications.content', 'Content')}: - - {blockedEventDetails.event.content} - - - - {blockedEventDetails.moderation_details.media_url && ( - - - - {t('moderation.notifications.sensitiveContent', 'Sensitive content')} - - - {(() => { - const mediaUrl = blockedEventDetails.moderation_details.media_url; - const fullMediaUrl = mediaUrl.startsWith('http') - ? mediaUrl - : `${window.location.origin}${mediaUrl.startsWith('/') ? '' : '/'}${mediaUrl}`; - - const contentType = blockedEventDetails.moderation_details.content_type; - - return ( - - {contentType.includes('image') ? ( - - ) : contentType.includes('video') ? ( - - ) : contentType.includes('audio') ? ( - - ) : ( - - {t('moderation.notifications.unsupportedType', 'Unsupported content type')}: {contentType} - - )} - - ); - })()} - - )} - - )} - - - ) : ( -
-
- - {t('common.loading', 'Loading...')} - -
- )} -
-
-
- ); -}; 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/hooks/useModerationNotifications.ts b/src/hooks/useModerationNotifications.ts deleted file mode 100644 index 4015611..0000000 --- a/src/hooks/useModerationNotifications.ts +++ /dev/null @@ -1,359 +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'; -import * as moderationNotificationsApi from '@app/api/moderationNotifications.api'; -import { BlockedEventResponse } from '@app/api/moderationNotifications.api'; - -// 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; - getBlockedEvent: (eventId: string) => Promise; - unblockEvent: (eventId: string) => Promise<{ success: boolean; message: string; event_id: string }>; - deleteEvent: (eventId: string) => Promise<{ success: boolean; message: string; event_id: string }>; -} - -// Set of event IDs that we know don't exist in the backend -// Store it in the window object so it can be accessed from other components -(window as any).nonExistentEventIds = (window as any).nonExistentEventIds || new Set(); -const nonExistentEventIds = (window as any).nonExistentEventIds; - -/** - * 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); - } - }); - - // Filter out notifications for events that we know don't exist - const filteredNotifications = data.notifications.filter( - (notification: ModerationNotification) => !nonExistentEventIds.has(notification.event_id) - ); - - // Merge server data with our local knowledge of read status - const mergedNotifications = filteredNotifications.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(filteredNotifications.map((n: ModerationNotification) => n.id)); - - return { notifications: mergedNotifications, 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]); - - // Get details of a blocked event - const getBlockedEvent = useCallback(async (eventId: string) => { - try { - console.log(`[useModerationNotifications] Getting details for blocked event ${eventId}`); - return await moderationNotificationsApi.getBlockedEvent(eventId); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to fetch blocked event details'; - notificationController.error({ message: errorMessage }); - throw err; - } - }, []); - - // Unblock an incorrectly flagged event - const unblockEvent = useCallback(async (eventId: string) => { - try { - console.log(`[useModerationNotifications] Unblocking event ${eventId}`); - return await moderationNotificationsApi.unblockEvent(eventId); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to unblock event'; - notificationController.error({ message: errorMessage }); - throw err; - } - }, []); - - // Permanently delete a moderated event - const deleteEvent = useCallback(async (eventId: string) => { - try { - console.log(`[useModerationNotifications] Permanently deleting event ${eventId}`); - return await moderationNotificationsApi.deleteModeratedEvent(eventId); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to delete event'; - notificationController.error({ message: errorMessage }); - throw err; - } - }, []); - - return { - notifications, - pagination, - isLoading, - error, - fetchNotifications, - markAsRead, - markAllAsRead, - getBlockedEvent, - unblockEvent, - deleteEvent - }; -}; 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;