From 529d67d2bb82829cff29d486109900ac3b3f32ea Mon Sep 17 00:00:00 2001 From: Mihaela Dumitru Date: Fri, 3 Oct 2025 01:52:24 +0300 Subject: [PATCH] feat(file-sharing): show count badges for unread files and notify on uploads/removals (#16484) --- lang/main.json | 4 ++ .../features/chat/components/native/Chat.tsx | 34 +++++++++--- .../chat/components/native/ChatButton.ts | 6 +-- react/features/chat/components/web/Chat.tsx | 12 ++++- .../chat/components/web/ChatCounter.tsx | 4 +- react/features/chat/functions.ts | 10 ++++ react/features/chat/reducer.ts | 23 +++++++- react/features/file-sharing/actions.ts | 6 ++- react/features/file-sharing/middleware.web.ts | 53 +++++++++++++++++-- react/features/file-sharing/reducer.ts | 11 +++- 10 files changed, 141 insertions(+), 22 deletions(-) diff --git a/lang/main.json b/lang/main.json index 4644e436bd..19ee2a1ebc 100644 --- a/lang/main.json +++ b/lang/main.json @@ -561,8 +561,12 @@ "downloadFile": "Download", "dragAndDrop": "Drag and drop files here or anywhere on screen", "fileAlreadyUploaded": "File has already been uploaded to this meeting.", + "fileRemovedByOther": "Your file '{{ fileName }}' was removed", "fileTooLargeDescription": "Please make sure the file does not exceed {{ maxFileSize }}.", "fileTooLargeTitle": "The selected file is too large", + "fileUploadProgress": "File upload progress", + "fileUploadedSuccessfully": "File uploaded successfully", + "newFileNotification": "{{ participantName }} shared '{{ fileName }}'", "removeFile": "Remove", "uploadFailedDescription": "Please try again.", "uploadFailedTitle": "Upload failed", diff --git a/react/features/chat/components/native/Chat.tsx b/react/features/chat/components/native/Chat.tsx index 94cd4a0267..4e5b2a1ca3 100644 --- a/react/features/chat/components/native/Chat.tsx +++ b/react/features/chat/components/native/Chat.tsx @@ -7,7 +7,9 @@ import { IReduxState } from '../../../app/types'; import { translate } from '../../../base/i18n/functions'; import JitsiScreen from '../../../base/modal/components/JitsiScreen'; import { TabBarLabelCounter } from '../../../mobile/navigation/components/TabBarLabelCounter'; +import { getUnreadPollCount } from '../../../polls/functions'; import { closeChat, sendMessage } from '../../actions.native'; +import { getUnreadFilesCount } from '../../functions'; import { IChatProps as AbstractProps } from '../../types'; import ChatInputBar from './ChatInputBar'; @@ -17,6 +19,21 @@ import styles from './styles'; interface IProps extends AbstractProps { + /** + * The number of unread file messages. + */ + _nbUnreadFiles: number; + + /** + * The number of unread messages. + */ + _nbUnreadMessages: number; + + /** + * The number of unread polls. + */ + _nbUnreadPolls: number; + /** * Default prop for navigating between screen components(React Navigation). */ @@ -96,7 +113,9 @@ class Chat extends Component { * @private * @returns {{ * _messages: Array, - * _nbUnreadMessages: number + * _nbUnreadMessages: number, + * _nbUnreadPolls: number, + * _nbUnreadFiles: number * }} */ function _mapStateToProps(state: IReduxState, _ownProps: any) { @@ -104,13 +123,16 @@ function _mapStateToProps(state: IReduxState, _ownProps: any) { return { _messages: messages, - _nbUnreadMessages: nbUnreadMessages + _nbUnreadMessages: nbUnreadMessages, + _nbUnreadPolls: getUnreadPollCount(state), + _nbUnreadFiles: getUnreadFilesCount(state) }; } export default translate(connect(_mapStateToProps)((props: IProps) => { - const { _nbUnreadMessages, dispatch, navigation, t } = props; - const unreadMessagesNr = _nbUnreadMessages > 0; + const { _nbUnreadMessages, _nbUnreadPolls, _nbUnreadFiles, dispatch, navigation, t } = props; + const totalUnread = _nbUnreadMessages + _nbUnreadPolls + _nbUnreadFiles; + const unreadMessagesNr = totalUnread > 0; const isFocused = useIsFocused(); @@ -121,14 +143,14 @@ export default translate(connect(_mapStateToProps)((props: IProps) => { activeUnreadNr = { unreadMessagesNr } isFocused = { isFocused } label = { t('chat.tabs.chat') } - nbUnread = { _nbUnreadMessages } /> + nbUnread = { totalUnread } /> ) }); return () => { isFocused && dispatch(closeChat()); }; - }, [ isFocused, _nbUnreadMessages ]); + }, [ isFocused, _nbUnreadMessages, _nbUnreadPolls, _nbUnreadFiles ]); return ( diff --git a/react/features/chat/components/native/ChatButton.ts b/react/features/chat/components/native/ChatButton.ts index 76b70f86cb..5a5f67bea9 100644 --- a/react/features/chat/components/native/ChatButton.ts +++ b/react/features/chat/components/native/ChatButton.ts @@ -10,7 +10,7 @@ import { arePollsDisabled } from '../../../conference/functions.any'; import { navigate } from '../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef'; import { screen } from '../../../mobile/navigation/routes'; import { getUnreadPollCount } from '../../../polls/functions'; -import { getUnreadCount } from '../../functions'; +import { getUnreadCount, getUnreadFilesCount } from '../../functions'; interface IProps extends AbstractButtonProps { @@ -70,9 +70,7 @@ function _mapStateToProps(state: IReduxState, ownProps: any) { return { _isPollsDisabled: arePollsDisabled(state), - - // The toggled icon should also be available for new polls - _unreadMessageCount: getUnreadCount(state) || getUnreadPollCount(state), + _unreadMessageCount: getUnreadCount(state) || getUnreadPollCount(state) || getUnreadFilesCount(state), visible }; } diff --git a/react/features/chat/components/web/Chat.tsx b/react/features/chat/components/web/Chat.tsx index faf9610aac..7d3f06cb61 100644 --- a/react/features/chat/components/web/Chat.tsx +++ b/react/features/chat/components/web/Chat.tsx @@ -64,6 +64,11 @@ interface IProps extends AbstractProps { */ _isResizing: boolean; + /** + * Number of unread file sharing messages. + */ + _nbUnreadFiles: number; + /** * Number of unread poll messages. */ @@ -229,6 +234,7 @@ const Chat = ({ _focusedTab, _isResizing, _messages, + _nbUnreadFiles, _nbUnreadMessages, _nbUnreadPolls, _onSendMessage, @@ -481,7 +487,7 @@ const Chat = ({ if (_isFileSharingTabEnabled) { tabs.push({ accessibilityLabel: t('chat.tabs.fileSharing'), - countBadge: undefined, + countBadge: _focusedTab !== ChatTabs.FILE_SHARING && _nbUnreadFiles > 0 ? _nbUnreadFiles : undefined, id: ChatTabs.FILE_SHARING, controlsId: `${ChatTabs.FILE_SHARING}-panel`, icon: IconShareDoc @@ -554,13 +560,14 @@ const Chat = ({ * _messages: Array, * _nbUnreadMessages: number, * _nbUnreadPolls: number, + * _nbUnreadFiles: number, * _showNamePrompt: boolean, * _width: number, * _isResizing: boolean * }} */ function _mapStateToProps(state: IReduxState, _ownProps: any) { - const { isOpen, focusedTab, messages, nbUnreadMessages, width, isResizing } = state['features/chat']; + const { isOpen, focusedTab, messages, nbUnreadMessages, nbUnreadFiles, width, isResizing } = state['features/chat']; const { nbUnreadPolls } = state['features/polls']; const _localParticipant = getLocalParticipant(state); @@ -574,6 +581,7 @@ function _mapStateToProps(state: IReduxState, _ownProps: any) { _messages: messages, _nbUnreadMessages: nbUnreadMessages, _nbUnreadPolls: nbUnreadPolls, + _nbUnreadFiles: nbUnreadFiles, _showNamePrompt: !_localParticipant?.name, _width: width?.current || CHAT_SIZE, _isResizing: isResizing diff --git a/react/features/chat/components/web/ChatCounter.tsx b/react/features/chat/components/web/ChatCounter.tsx index 589d9ea81a..75e4c04a70 100644 --- a/react/features/chat/components/web/ChatCounter.tsx +++ b/react/features/chat/components/web/ChatCounter.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { IReduxState } from '../../../app/types'; import { getUnreadPollCount } from '../../../polls/functions'; -import { getUnreadCount } from '../../functions'; +import { getUnreadCount, getUnreadFilesCount } from '../../functions'; /** * The type of the React {@code Component} props of {@link ChatCounter}. @@ -65,7 +65,7 @@ function _mapStateToProps(state: IReduxState) { return { - _count: getUnreadCount(state) + getUnreadPollCount(state), + _count: getUnreadCount(state) + getUnreadPollCount(state) + getUnreadFilesCount(state), _isOpen: isOpen }; diff --git a/react/features/chat/functions.ts b/react/features/chat/functions.ts index 1d6432cac2..c0632baf49 100644 --- a/react/features/chat/functions.ts +++ b/react/features/chat/functions.ts @@ -131,6 +131,16 @@ export function getUnreadCount(state: IReduxState) { return messagesCount - (lastReadIndex + 1) - reactionMessages; } +/** + * Gets the unread files count. + * + * @param {IReduxState} state - The redux state. + * @returns {number} The number of unread files. + */ +export function getUnreadFilesCount(state: IReduxState): number { + return state['features/chat']?.nbUnreadFiles || 0; +} + /** * Get whether the chat smileys are disabled or not. * diff --git a/react/features/chat/reducer.ts b/react/features/chat/reducer.ts index 791f5905ae..8d3fa6f073 100644 --- a/react/features/chat/reducer.ts +++ b/react/features/chat/reducer.ts @@ -1,6 +1,7 @@ import { UPDATE_CONFERENCE_METADATA } from '../base/conference/actionTypes'; import { ILocalParticipant, IParticipant } from '../base/participants/types'; import ReducerRegistry from '../base/redux/ReducerRegistry'; +import { ADD_FILE, _FILE_LIST_RECEIVED } from '../file-sharing/actionTypes'; import { IVisitorChatParticipant } from '../visitors/types'; import { @@ -28,6 +29,7 @@ const DEFAULT_STATE = { messages: [], reactions: {}, nbUnreadMessages: 0, + nbUnreadFiles: 0, privateMessageRecipient: undefined, lobbyMessageRecipient: undefined, isLobbyChatActive: false, @@ -51,6 +53,7 @@ export interface IChatState { name: string; } | ILocalParticipant; messages: IMessage[]; + nbUnreadFiles: number; nbUnreadMessages: number; privateMessageRecipient?: IParticipant | IVisitorChatParticipant; width: { @@ -231,7 +234,8 @@ ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, ac return { ...state, focusedTab: action.tabId, - nbUnreadMessages: action.tabId === ChatTabs.CHAT ? 0 : state.nbUnreadMessages + nbUnreadMessages: action.tabId === ChatTabs.CHAT ? 0 : state.nbUnreadMessages, + nbUnreadFiles: action.tabId === ChatTabs.FILE_SHARING ? 0 : state.nbUnreadFiles }; case SET_CHAT_WIDTH: { @@ -262,6 +266,23 @@ ReducerRegistry.register('features/chat', (state = DEFAULT_STATE, ac isResizing: action.resizing }; } + + case ADD_FILE: + return { + ...state, + nbUnreadFiles: action.shouldIncrementUnread ? state.nbUnreadFiles + 1 : state.nbUnreadFiles + }; + + case _FILE_LIST_RECEIVED: { + const remoteFilesCount = Object.values(action.files).filter( + (file: any) => file.authorParticipantId !== action.localParticipantId + ).length; + + return { + ...state, + nbUnreadFiles: remoteFilesCount + }; + } } return state; diff --git a/react/features/file-sharing/actions.ts b/react/features/file-sharing/actions.ts index d2af3e75b9..3df5706bba 100644 --- a/react/features/file-sharing/actions.ts +++ b/react/features/file-sharing/actions.ts @@ -39,12 +39,14 @@ export function updateFileProgress(fileId: string, progress: number) { * Add a file. * * @param {IFileMetadata} file - The file to add to the state. + * @param {boolean} shouldIncrementUnread - Whether to increment the unread count. * @returns {Object} */ -export function addFile(file: IFileMetadata) { +export function addFile(file: IFileMetadata, shouldIncrementUnread = false) { return { type: ADD_FILE, - file + file, + shouldIncrementUnread }; } diff --git a/react/features/file-sharing/middleware.web.ts b/react/features/file-sharing/middleware.web.ts index 9732479bdb..a099ac2ce7 100644 --- a/react/features/file-sharing/middleware.web.ts +++ b/react/features/file-sharing/middleware.web.ts @@ -6,8 +6,10 @@ import { JitsiConferenceEvents } from '../base/lib-jitsi-meet'; import { getLocalParticipant, getParticipantDisplayName } from '../base/participants/functions'; import MiddlewareRegistry from '../base/redux/MiddlewareRegistry'; import StateListenerRegistry from '../base/redux/StateListenerRegistry'; -import { showErrorNotification } from '../notifications/actions'; +import { ChatTabs } from '../chat/constants'; +import { showErrorNotification, showNotification } from '../notifications/actions'; import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../notifications/constants'; +import { I_AM_VISITOR_MODE } from '../visitors/actionTypes'; import { DOWNLOAD_FILE, REMOVE_FILE, UPLOAD_FILES, _FILE_LIST_RECEIVED, _FILE_REMOVED } from './actionTypes'; import { addFile, removeFile, updateFileProgress } from './actions'; @@ -23,12 +25,40 @@ import { downloadFile } from './utils'; */ StateListenerRegistry.register( state => state['features/base/conference'].conference, - (conference, { dispatch }, previousConference) => { + (conference, { dispatch, getState }, previousConference) => { if (conference && !previousConference) { conference.on(JitsiConferenceEvents.FILE_SHARING_FILE_ADDED, (file: IFileMetadata) => { - dispatch(addFile(file)); + const state = getState(); + const localParticipant = getLocalParticipant(state); + const isRemoteFile = file.authorParticipantId !== localParticipant?.id; + const { isOpen, focusedTab } = state['features/chat']; + const isFileSharingTabVisible = isOpen && focusedTab === ChatTabs.FILE_SHARING; + + dispatch(addFile(file, isRemoteFile && !isFileSharingTabVisible)); + + if (isRemoteFile && !isFileSharingTabVisible) { + dispatch(showNotification({ + titleKey: 'fileSharing.newFileNotification', + titleArguments: { participantName: file.authorParticipantName, fileName: file.fileName } + }, NOTIFICATION_TIMEOUT_TYPE.SHORT)); + } }); conference.on(JitsiConferenceEvents.FILE_SHARING_FILE_REMOVED, (fileId: string) => { + const state = getState(); + const localParticipant = getLocalParticipant(state); + const { files } = state['features/file-sharing']; + const { isOpen, focusedTab } = state['features/chat']; + const removedFile = files.get(fileId); + const isFileSharingTabVisible = isOpen && focusedTab === ChatTabs.FILE_SHARING; + + if (removedFile && removedFile.authorParticipantId === localParticipant?.id && !isFileSharingTabVisible) { + dispatch(showNotification({ + titleKey: 'fileSharing.fileRemovedByOther', + titleArguments: { fileName: removedFile.fileName }, + appearance: NOTIFICATION_TYPE.WARNING + }, NOTIFICATION_TIMEOUT_TYPE.MEDIUM)); + } + dispatch({ type: _FILE_REMOVED, fileId @@ -36,9 +66,13 @@ StateListenerRegistry.register( }); conference.on(JitsiConferenceEvents.FILE_SHARING_FILES_RECEIVED, (files: object) => { + const state = getState(); + const localParticipant = getLocalParticipant(state); + dispatch({ type: _FILE_LIST_RECEIVED, - files + files, + localParticipantId: localParticipant?.id }); }); } @@ -52,6 +86,17 @@ StateListenerRegistry.register( */ MiddlewareRegistry.register(store => next => action => { switch (action.type) { + case I_AM_VISITOR_MODE: { + if (!action.iAmVisitor) { + const state = store.getState(); + const conference = getCurrentConference(state); + + conference?.getFileSharing()?.requestFileList?.(); + } + + return next(action); + } + case UPLOAD_FILES: { const state = store.getState(); const conference = getCurrentConference(state); diff --git a/react/features/file-sharing/reducer.ts b/react/features/file-sharing/reducer.ts index 6a2265685f..9b7e29e443 100644 --- a/react/features/file-sharing/reducer.ts +++ b/react/features/file-sharing/reducer.ts @@ -1,6 +1,11 @@ import ReducerRegistry from '../base/redux/ReducerRegistry'; -import { ADD_FILE, UPDATE_FILE_UPLOAD_PROGRESS, _FILE_LIST_RECEIVED, _FILE_REMOVED } from './actionTypes'; +import { + ADD_FILE, + UPDATE_FILE_UPLOAD_PROGRESS, + _FILE_LIST_RECEIVED, + _FILE_REMOVED +} from './actionTypes'; import { IFileMetadata } from './types'; export interface IFileSharingState { @@ -20,6 +25,7 @@ ReducerRegistry.register('features/file-sharing', newFiles.set(action.file.fileId, action.file); return { + ...state, files: newFiles }; } @@ -30,6 +36,7 @@ ReducerRegistry.register('features/file-sharing', newFiles.delete(action.fileId); return { + ...state, files: newFiles }; } @@ -43,12 +50,14 @@ ReducerRegistry.register('features/file-sharing', } return { + ...state, files: newFiles }; } case _FILE_LIST_RECEIVED: { return { + ...state, files: new Map(Object.entries(action.files)) }; }