diff --git a/services/chat/app/js/Features/Messages/MessageHttpController.js b/services/chat/app/js/Features/Messages/MessageHttpController.js index 13e8f3ab61..1a14ae66a0 100644 --- a/services/chat/app/js/Features/Messages/MessageHttpController.js +++ b/services/chat/app/js/Features/Messages/MessageHttpController.js @@ -78,6 +78,10 @@ export async function editMessage(context) { return await callMessageHttpController(context, _editMessage) } +export async function editGlobalMessage(context) { + return await callMessageHttpController(context, _editGlobalMessage) +} + export async function deleteMessage(context) { return await callMessageHttpController(context, _deleteMessage) } @@ -86,6 +90,10 @@ export async function deleteUserMessage(context) { return await callMessageHttpController(context, _deleteUserMessage) } +export async function deleteGlobalMessage(context) { + return await callMessageHttpController(context, _deleteGlobalMessage) +} + export async function getResolvedThreadIds(context) { return await callMessageHttpController(context, _getResolvedThreadIds) } @@ -242,6 +250,28 @@ const _editMessage = async (req, res) => { res.status(204) } +const _editGlobalMessage = async (req, res) => { + const { content, userId } = req.body + const { projectId, messageId } = req.params + logger.debug({ projectId, messageId, content }, 'editing global message') + const room = await ThreadManager.findOrCreateThread( + projectId, + ThreadManager.GLOBAL_THREAD + ) + const found = await MessageManager.updateMessage( + room._id, + messageId, + userId, + content, + Date.now() + ) + if (!found) { + res.status(404) + return + } + res.status(204) +} + const _deleteMessage = async (req, res) => { const { projectId, threadId, messageId } = req.params logger.debug({ projectId, threadId, messageId }, 'deleting message') @@ -257,6 +287,16 @@ const _deleteUserMessage = async (req, res) => { res.status(204) } +const _deleteGlobalMessage = async (req, res) => { + const { projectId, messageId } = req.params + const room = await ThreadManager.findOrCreateThread( + projectId, + ThreadManager.GLOBAL_THREAD + ) + await MessageManager.deleteMessage(room._id, messageId) + res.status(204) +} + const _getResolvedThreadIds = async (req, res) => { const { projectId } = req.params const resolvedThreadIds = await ThreadManager.getResolvedThreadIds(projectId) @@ -307,6 +347,7 @@ async function _sendMessage(userId, projectId, content, clientThreadId, res) { ) message = MessageFormatter.formatMessageForClientSide(message) message.room_id = projectId + res.status(201).setBody(message) } diff --git a/services/chat/chat.yaml b/services/chat/chat.yaml index 455921a0b4..8e13031ff3 100644 --- a/services/chat/chat.yaml +++ b/services/chat/chat.yaml @@ -82,6 +82,13 @@ paths: description: Message not found operationId: getGlobalMessage description: Get a single global message by message ID for the project with Project ID provided + delete: + summary: Delete global message + operationId: deleteGlobalMessage + responses: + '204': + description: No Content + description: 'Delete global message' '/project/{projectId}/thread/{threadId}/messages': parameters: - schema: @@ -179,6 +186,46 @@ paths: - user_id: Id of the user (optional) description: | Update message with Message ID provided from the Thread ID and Project ID provided + '/project/{projectId}/messages/{messageId}/edit': + parameters: + - schema: + type: string + name: projectId + in: path + required: true + - schema: + type: string + name: messageId + in: path + required: true + post: + summary: Edit global message + operationId: editGlobalMessage + responses: + '204': + description: No Content + '404': + description: Not Found + requestBody: + content: + application/json: + schema: + type: object + properties: + content: + type: string + user_id: + type: string + readOnly: true + required: + - content + examples: {} + description: |- + JSON object with : + - content: Content of the message to edit + - user_id: Id of the user (optional) + description: | + Update global message with Message ID provided from the Project ID provided '/project/{projectId}/thread/{threadId}/messages/{messageId}': parameters: - schema: diff --git a/services/web/app/src/Features/Chat/ChatApiHandler.js b/services/web/app/src/Features/Chat/ChatApiHandler.js index 7a4f4df31c..c3be2746bc 100644 --- a/services/web/app/src/Features/Chat/ChatApiHandler.js +++ b/services/web/app/src/Features/Chat/ChatApiHandler.js @@ -91,6 +91,16 @@ async function editMessage(projectId, threadId, messageId, userId, content) { ) } +async function editGlobalMessage(projectId, messageId, userId, content) { + await fetchNothing( + chatApiUrl(`/project/${projectId}/messages/${messageId}/edit`), + { + method: 'POST', + json: { content, userId }, + } + ) +} + async function deleteMessage(projectId, threadId, messageId) { await fetchNothing( chatApiUrl( @@ -109,6 +119,13 @@ async function deleteUserMessage(projectId, threadId, userId, messageId) { ) } +async function deleteGlobalMessage(projectId, messageId) { + await fetchNothing( + chatApiUrl(`/project/${projectId}/messages/${messageId}`), + { method: 'DELETE' } + ) +} + async function getResolvedThreadIds(projectId) { const body = await fetchJson( chatApiUrl(`/project/${projectId}/resolved-thread-ids`) @@ -154,8 +171,10 @@ module.exports = { reopenThread: callbackify(reopenThread), deleteThread: callbackify(deleteThread), editMessage: callbackify(editMessage), + editGlobalMessage: callbackify(editGlobalMessage), deleteMessage: callbackify(deleteMessage), deleteUserMessage: callbackify(deleteUserMessage), + deleteGlobalMessage: callbackify(deleteGlobalMessage), getResolvedThreadIds: callbackify(getResolvedThreadIds), duplicateCommentThreads: callbackify(duplicateCommentThreads), generateThreadData: callbackify(generateThreadData), @@ -171,8 +190,10 @@ module.exports = { reopenThread, deleteThread, editMessage, + editGlobalMessage, deleteMessage, deleteUserMessage, + deleteGlobalMessage, getResolvedThreadIds, duplicateCommentThreads, generateThreadData, diff --git a/services/web/app/src/Features/Chat/ChatController.mjs b/services/web/app/src/Features/Chat/ChatController.mjs index 9470bf2b32..1db9cef89f 100644 --- a/services/web/app/src/Features/Chat/ChatController.mjs +++ b/services/web/app/src/Features/Chat/ChatController.mjs @@ -48,7 +48,48 @@ async function getMessages(req, res) { res.json(messages) } +async function deleteMessage(req, res) { + const { project_id: projectId, message_id: messageId } = req.params + const userId = SessionManager.getLoggedInUserId(req.session) + if (userId == null) { + throw new Error('no logged-in user') + } + + await ChatApiHandler.promises.deleteGlobalMessage(projectId, messageId) + + EditorRealTimeController.emitToRoom(projectId, 'delete-global-message', { + messageId, + userId, + }) + res.sendStatus(204) +} + +async function editMessage(req, res, next) { + const { project_id: projectId, message_id: messageId } = req.params + const { content } = req.body + const userId = SessionManager.getLoggedInUserId(req.session) + if (userId == null) { + throw new Error('no logged-in user') + } + + await ChatApiHandler.promises.editGlobalMessage( + projectId, + messageId, + userId, + content + ) + + EditorRealTimeController.emitToRoom(projectId, 'edit-global-message', { + messageId, + userId, + content, + }) + res.sendStatus(204) +} + export default { sendMessage: expressify(sendMessage), getMessages: expressify(getMessages), + deleteMessage: expressify(deleteMessage), + editMessage: expressify(editMessage), } diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index e17edd1512..4d9adf4151 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -401,6 +401,7 @@ const _ProjectController = { 'client-side-references', 'editor-redesign-new-users', 'writefull-frontend-migration', + 'chat-edit-delete', ].filter(Boolean) const getUserValues = async userId => diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 65cedfca79..a1b6f9abc5 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -979,6 +979,20 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { RateLimiterMiddleware.rateLimit(rateLimiters.sendChatMessage), ChatController.sendMessage ) + webRouter.delete( + '/project/:project_id/messages/:message_id', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + PermissionsController.requirePermission('chat'), + ChatController.deleteMessage + ) + webRouter.post( + '/project/:project_id/messages/:message_id/edit', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + PermissionsController.requirePermission('chat'), + ChatController.editMessage + ) } webRouter.post( diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 9e6b2ca6b9..6e773780f0 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -397,6 +397,8 @@ "delete_comment_thread": "", "delete_comment_thread_message": "", "delete_figure": "", + "delete_message": "", + "delete_message_confirmation": "", "delete_projects": "", "delete_row_or_column": "", "delete_sso_config": "", @@ -503,6 +505,7 @@ "edit_tag": "", "edit_tag_name": "", "edit_your_custom_dictionary": "", + "edited": "", "editing": "", "editing_captions": "", "editing_tools": "", diff --git a/services/web/frontend/js/features/chat/components/chat-pane.tsx b/services/web/frontend/js/features/chat/components/chat-pane.tsx index 48ae6b6f6e..3397e1a722 100644 --- a/services/web/frontend/js/features/chat/components/chat-pane.tsx +++ b/services/web/frontend/js/features/chat/components/chat-pane.tsx @@ -43,10 +43,7 @@ const ChatPane = React.memo(function ChatPane() { const shouldDisplayPlaceholder = status !== 'pending' && messages.length === 0 - const messageContentCount = messages.reduce( - (acc, { contents }) => acc + contents.length, - 0 - ) + const messageContentCount = messages.length // Keep the chat pane in the DOM to avoid resetting the form input and re-rendering MathJax content. const [chatOpenedOnce, setChatOpenedOnce] = useState(chatIsOpen) diff --git a/services/web/frontend/js/features/chat/components/message-and-dropdown.tsx b/services/web/frontend/js/features/chat/components/message-and-dropdown.tsx new file mode 100644 index 0000000000..889a207bb5 --- /dev/null +++ b/services/web/frontend/js/features/chat/components/message-and-dropdown.tsx @@ -0,0 +1,40 @@ +import { + Message as MessageType, + useChatContext, +} from '@/features/chat/context/chat-context' +import classNames from 'classnames' +import MessageDropdown from '@/features/chat/components/message-dropdown' +import MessageContent from '@/features/chat/components/message-content' +import { useFeatureFlag } from '@/shared/context/split-test-context' + +export function MessageAndDropdown({ + message, + fromSelf, +}: { + message: MessageType + fromSelf: boolean +}) { + const { idOfMessageBeingEdited } = useChatContext() + const hasChatEditDelete = useFeatureFlag('chat-edit-delete') + + const editing = idOfMessageBeingEdited === message.id + + return ( +