Files
overleaf-cep/services/chat/app/js/Features/Messages/MessageHttpController.js
Domagoj Kriskovic c22e44438e Support for deleting and editing chat messages (#28204)
* Initial server-side delete of chat message plus dropdown

* Update chat pane after deleting message

* Chat message dropdown styling

* Add confirmation dialog for deleting a message

* Refactor chat message grouping to allow deletion of individual messages

* Delete other user's deleted message from chat pane

* Implement message editing

* Styling

* Make the dropdown appear overlap with the button slightly so that the menu stays visible when the user moves their cursor into the menu when the menu is positioned above the button

* Submit edit with Enter key

* Add edited indicator to edited chat messages

* Add animation to chat message deletion

* Tidying, edit chat message textarea improvements

* Add types to message-list-utils

* update dependencies

* edit/delete for ide-redesign

* fix type errors in tests

* filter deleted messages from group

* promisify ChatController

* fix tests and translations

* add new tests

* chat-context tests

* fix message-list-appender tests

* add new tests for message-list-utils

* Update services/web/test/frontend/features/chat/context/chat-context.test.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* preserve original content when canceling edits

* update delete message translation

* hide dropdown only if not already shown

* remove delete animation

* fix lint error

* fix chat.yaml

* hide under feature flag

---------

Co-authored-by: Tim Down <158919+timdown@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
GitOrigin-RevId: 12521886a1a59ccd564851df19e5d46c70d328f5
2025-10-02 08:05:58 +00:00

411 lines
12 KiB
JavaScript

import logger from '@overleaf/logger'
import * as MessageManager from './MessageManager.js'
import * as MessageFormatter from './MessageFormatter.js'
import * as ThreadManager from '../Threads/ThreadManager.js'
import { ObjectId } from '../../mongodb.js'
const DEFAULT_MESSAGE_LIMIT = 50
const MAX_MESSAGE_LENGTH = 10 * 1024 // 10kb, about 1,500 words
function readContext(context, req) {
req.body = context.requestBody
req.params = context.params.path
req.query = context.params.query
if (typeof req.params.projectId !== 'undefined') {
if (!ObjectId.isValid(req.params.projectId)) {
context.res.status(400).setBody('Invalid projectId')
}
}
if (typeof req.params.threadId !== 'undefined') {
if (!ObjectId.isValid(req.params.threadId)) {
context.res.status(400).setBody('Invalid threadId')
}
}
}
/**
* @param context
* @param {(req: unknown, res: unknown) => Promise<unknown>} ControllerMethod
* @returns {Promise<*>}
*/
export async function callMessageHttpController(context, ControllerMethod) {
const req = {}
readContext(context, req)
if (context.res.statusCode !== 400) {
return await ControllerMethod(req, context.res)
} else {
return context.res.body
}
}
export async function getGlobalMessages(context) {
return await callMessageHttpController(context, _getGlobalMessages)
}
export async function getGlobalMessage(context) {
return await callMessageHttpController(context, _getGlobalMessage)
}
export async function sendGlobalMessage(context) {
return await callMessageHttpController(context, _sendGlobalMessage)
}
export async function sendMessage(context) {
return await callMessageHttpController(context, _sendThreadMessage)
}
export async function getThreads(context) {
return await callMessageHttpController(context, _getAllThreads)
}
export async function getThread(context) {
return await callMessageHttpController(context, _getThread)
}
export async function resolveThread(context) {
return await callMessageHttpController(context, _resolveThread)
}
export async function reopenThread(context) {
return await callMessageHttpController(context, _reopenThread)
}
export async function deleteThread(context) {
return await callMessageHttpController(context, _deleteThread)
}
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)
}
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)
}
export async function destroyProject(context) {
return await callMessageHttpController(context, _destroyProject)
}
export async function duplicateCommentThreads(context) {
return await callMessageHttpController(context, _duplicateCommentThreads)
}
export async function generateThreadData(context) {
return await callMessageHttpController(context, _generateThreadData)
}
export async function getStatus(context) {
const message = 'chat is alive'
context.res.status(200).setBody(message)
return message
}
const _getGlobalMessages = async (req, res) => {
await _getMessages(ThreadManager.GLOBAL_THREAD, req, res)
}
const _getGlobalMessage = async (req, res) => {
const { projectId, messageId } = req.params
logger.debug({ projectId, messageId }, 'getting single global message')
try {
const room = await ThreadManager.findThread(
projectId,
ThreadManager.GLOBAL_THREAD
)
const message = await MessageManager.getMessage(room._id, messageId)
const formattedMsg = MessageFormatter.formatMessageForClientSide(message)
res.status(200).setBody(formattedMsg)
} catch (error) {
if (
error instanceof ThreadManager.MissingThreadError ||
error instanceof MessageManager.MissingMessageError
) {
res.status(404)
return
}
throw error
}
}
async function _sendGlobalMessage(req, res) {
const { user_id: userId, content } = req.body
const { projectId } = req.params
return await _sendMessage(
userId,
projectId,
content,
ThreadManager.GLOBAL_THREAD,
res
)
}
async function _sendThreadMessage(req, res) {
const { user_id: userId, content } = req.body
const { projectId, threadId } = req.params
return await _sendMessage(userId, projectId, content, threadId, res)
}
const _getAllThreads = async (req, res) => {
const { projectId } = req.params
logger.debug({ projectId }, 'getting all threads')
const rooms = await ThreadManager.findAllThreadRooms(projectId)
const roomIds = rooms.map(r => r._id)
const messages = await MessageManager.findAllMessagesInRooms(roomIds)
const threads = MessageFormatter.groupMessagesByThreads(rooms, messages)
res.json(threads)
}
const _generateThreadData = async (req, res) => {
const { projectId } = req.params
const { threads } = req.body
logger.debug({ projectId }, 'getting all threads')
const rooms = await ThreadManager.findThreadsById(projectId, threads)
const roomIds = rooms.map(r => r._id)
const messages = await MessageManager.findAllMessagesInRooms(roomIds)
logger.debug({ rooms, messages }, 'looked up messages in the rooms')
const threadData = MessageFormatter.groupMessagesByThreads(rooms, messages)
res.json(threadData)
}
const _getThread = async (req, res) => {
const { projectId, threadId } = req.params
logger.debug({ projectId, threadId }, 'getting specific thread')
try {
const room = await ThreadManager.findThread(projectId, threadId)
const messages = await MessageManager.findAllMessagesInRooms([room._id])
const threads = MessageFormatter.groupMessagesByThreads([room], messages)
const thread = threads[threadId] || null
if (!thread) {
res.status(404)
return
}
res.json(thread)
} catch (error) {
if (error instanceof ThreadManager.MissingThreadError) {
res.status(404)
return
}
throw error
}
}
const _resolveThread = async (req, res) => {
const { projectId, threadId } = req.params
const { user_id: userId } = req.body
logger.debug({ userId, projectId, threadId }, 'marking thread as resolved')
await ThreadManager.resolveThread(projectId, threadId, userId)
res.status(204)
}
const _reopenThread = async (req, res) => {
const { projectId, threadId } = req.params
logger.debug({ projectId, threadId }, 'reopening thread')
await ThreadManager.reopenThread(projectId, threadId)
res.status(204)
}
const _deleteThread = async (req, res) => {
const { projectId, threadId } = req.params
logger.debug({ projectId, threadId }, 'deleting thread')
const roomId = await ThreadManager.deleteThread(projectId, threadId)
await MessageManager.deleteAllMessagesInRoom(roomId)
res.status(204)
}
const _editMessage = async (req, res) => {
const { content, userId } = req.body
const { projectId, threadId, messageId } = req.params
logger.debug({ projectId, threadId, messageId, content }, 'editing message')
const room = await ThreadManager.findOrCreateThread(projectId, threadId)
const found = await MessageManager.updateMessage(
room._id,
messageId,
userId,
content,
Date.now()
)
if (!found) {
res.status(404)
return
}
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')
const room = await ThreadManager.findOrCreateThread(projectId, threadId)
await MessageManager.deleteMessage(room._id, messageId)
res.status(204)
}
const _deleteUserMessage = async (req, res) => {
const { projectId, threadId, userId, messageId } = req.params
const room = await ThreadManager.findOrCreateThread(projectId, threadId)
await MessageManager.deleteUserMessage(userId, room._id, messageId)
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)
res.json({ resolvedThreadIds })
}
const _destroyProject = async (req, res) => {
const { projectId } = req.params
logger.debug({ projectId }, 'destroying project')
const rooms = await ThreadManager.findAllThreadRoomsAndGlobalThread(projectId)
const roomIds = rooms.map(r => r._id)
logger.debug({ projectId, roomIds }, 'deleting all messages in rooms')
await MessageManager.deleteAllMessagesInRooms(roomIds)
logger.debug({ projectId }, 'deleting all threads in project')
await ThreadManager.deleteAllThreadsInProject(projectId)
res.status(204)
}
async function _sendMessage(userId, projectId, content, clientThreadId, res) {
if (!ObjectId.isValid(userId)) {
const message = 'Invalid userId'
res.status(400).setBody(message)
return message
}
if (!content) {
const message = 'No content provided'
res.status(400).setBody(message)
return message
}
if (content.length > MAX_MESSAGE_LENGTH) {
const message = `Content too long (> ${MAX_MESSAGE_LENGTH} bytes)`
res.status(400).setBody(message)
return message
}
logger.debug(
{ clientThreadId, projectId, userId, content },
'new message received'
)
const thread = await ThreadManager.findOrCreateThread(
projectId,
clientThreadId
)
let message = await MessageManager.createMessage(
thread._id,
userId,
content,
Date.now()
)
message = MessageFormatter.formatMessageForClientSide(message)
message.room_id = projectId
res.status(201).setBody(message)
}
async function _getMessages(clientThreadId, req, res) {
let before, limit
const { projectId } = req.params
if (req.query.before) {
before = parseInt(req.query.before, 10)
} else {
before = null
}
if (req.query.limit) {
limit = parseInt(req.query.limit, 10)
} else {
limit = DEFAULT_MESSAGE_LIMIT
}
logger.debug(
{ limit, before, projectId, clientThreadId },
'get message request received'
)
const thread = await ThreadManager.findOrCreateThread(
projectId,
clientThreadId
)
const threadObjectId = thread._id
logger.debug(
{ limit, before, projectId, clientThreadId, threadObjectId },
'found or created thread'
)
let messages = await MessageManager.getMessages(threadObjectId, limit, before)
messages = MessageFormatter.formatMessagesForClientSide(messages)
logger.debug({ projectId, messages }, 'got messages')
res.status(200).setBody(messages)
}
async function _duplicateCommentThreads(req, res) {
const { projectId } = req.params
const { threads } = req.body
const result = {}
for (const id of threads) {
logger.debug({ projectId, thread: id }, 'duplicating thread')
try {
const { oldRoom, newRoom } = await ThreadManager.duplicateThread(
projectId,
id
)
await MessageManager.duplicateRoomToOtherRoom(oldRoom._id, newRoom._id)
result[id] = { duplicateId: newRoom.thread_id }
} catch (error) {
if (error instanceof ThreadManager.MissingThreadError) {
// Expected error when the comment has been deleted prior to duplication
result[id] = { error: 'not found' }
} else {
logger.err({ error }, 'error duplicating thread')
result[id] = { error: 'unknown' }
}
}
}
res.json({ newThreads: result })
}