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
This commit is contained in:
committed by
Copybot
parent
f0f7899de4
commit
c22e44438e
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={classNames('message-and-dropdown', {
|
||||
'pending-message': message.pending,
|
||||
})}
|
||||
>
|
||||
{hasChatEditDelete && fromSelf && !message.pending && !editing ? (
|
||||
<MessageDropdown message={message} />
|
||||
) : null}
|
||||
<div className="message-content">
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
messageId={message.id}
|
||||
edited={message.edited}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
import { useRef, useEffect, type FC } from 'react'
|
||||
import { useRef, useEffect, type FC, useCallback, useState } from 'react'
|
||||
import Linkify from 'react-linkify'
|
||||
import useIsMounted from '../../../shared/hooks/use-is-mounted'
|
||||
import { loadMathJax } from '../../mathjax/load-mathjax'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { Message, useChatContext } from '@/features/chat/context/chat-context'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
||||
|
||||
const MessageContent: FC<{ content: string }> = ({ content }) => {
|
||||
const MessageContent: FC<{
|
||||
content: Message['content']
|
||||
messageId: Message['id']
|
||||
edited: Message['edited']
|
||||
}> = ({ content, messageId, edited }) => {
|
||||
const { t } = useTranslation()
|
||||
const root = useRef<HTMLDivElement | null>(null)
|
||||
const mounted = useIsMounted()
|
||||
const { idOfMessageBeingEdited, cancelMessageEdit, editMessage } =
|
||||
useChatContext()
|
||||
const [editedContent, setEditedContent] = useState(content)
|
||||
|
||||
const editing = idOfMessageBeingEdited === messageId
|
||||
|
||||
useEffect(() => {
|
||||
if (root.current) {
|
||||
@@ -33,9 +47,70 @@ const MessageContent: FC<{ content: string }> = ({ content }) => {
|
||||
}
|
||||
}, [content, mounted])
|
||||
|
||||
return (
|
||||
const completeEdit = useCallback(() => {
|
||||
editMessage(messageId, editedContent)
|
||||
}, [editMessage, editedContent, messageId])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
completeEdit()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelMessageEdit()
|
||||
setEditedContent(content)
|
||||
}
|
||||
},
|
||||
[cancelMessageEdit, completeEdit, content]
|
||||
)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEditedContent(e.target.value)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleAutoFocus = useCallback(
|
||||
(textarea: HTMLTextAreaElement) => textarea.select(),
|
||||
[]
|
||||
)
|
||||
|
||||
return editing ? (
|
||||
<>
|
||||
<AutoExpandingTextArea
|
||||
value={editedContent}
|
||||
style={{ width: '100%' }}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
|
||||
onAutoFocus={handleAutoFocus}
|
||||
/>
|
||||
<br />
|
||||
<OLButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
cancelMessageEdit()
|
||||
setEditedContent(content)
|
||||
}}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton size="sm" variant="secondary" onClick={() => completeEdit()}>
|
||||
{t('save')}
|
||||
</OLButton>
|
||||
</>
|
||||
) : (
|
||||
<p ref={root} translate="no">
|
||||
<Linkify>{content}</Linkify>
|
||||
{edited ? (
|
||||
<>
|
||||
{' '}
|
||||
<span className="message-edited">({t('edited')})</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from '@/shared/components/dropdown/dropdown-menu'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DropdownListItem from '@/shared/components/dropdown/dropdown-list-item'
|
||||
import { Message, useChatContext } from '@/features/chat/context/chat-context'
|
||||
import { useModalsContext } from '@/features/ide-react/context/modals-context'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export default function MessageDropdown({ message }: { message: Message }) {
|
||||
const { t } = useTranslation()
|
||||
const { deleteMessage, startedEditingMessage } = useChatContext()
|
||||
|
||||
const { showGenericConfirmModal } = useModalsContext()
|
||||
|
||||
const deleteButtonHandler = useCallback(() => {
|
||||
showGenericConfirmModal({
|
||||
title: t('delete_message'),
|
||||
message: t('delete_message_confirmation'),
|
||||
onConfirm: () => {
|
||||
deleteMessage(message.id)
|
||||
},
|
||||
})
|
||||
}, [deleteMessage, message.id, showGenericConfirmModal, t])
|
||||
|
||||
const editButtonHandler = useCallback(() => {
|
||||
startedEditingMessage(message.id)
|
||||
}, [message.id, startedEditingMessage])
|
||||
|
||||
return (
|
||||
<Dropdown align="end" className="message-dropdown float-end">
|
||||
<DropdownToggle bsPrefix="message-dropdown-menu-btn">
|
||||
<MaterialIcon type="more_vert" accessibilityLabel={t('actions')} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu
|
||||
className="message-dropdown-menu"
|
||||
// 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
|
||||
popperConfig={{
|
||||
modifiers: [{ name: 'offset', options: { offset: [0, -3] } }],
|
||||
}}
|
||||
>
|
||||
<DropdownListItem>
|
||||
<DropdownItem as="button" onClick={editButtonHandler}>
|
||||
{t('edit')}
|
||||
</DropdownItem>
|
||||
<DropdownItem as="button" onClick={deleteButtonHandler}>
|
||||
{t('delete')}
|
||||
</DropdownItem>
|
||||
</DropdownListItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getHueForUserId } from '@/shared/utils/colors'
|
||||
import type { Message as MessageType } from '@/features/chat/context/chat-context'
|
||||
import { User } from '../../../../../types/user'
|
||||
import classNames from 'classnames'
|
||||
import { MessageAndDropdown } from '@/features/chat/components/message-and-dropdown'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface MessageGroupProps {
|
||||
messages: MessageType[]
|
||||
user?: User
|
||||
fromSelf: boolean
|
||||
}
|
||||
|
||||
function hue(user?: User) {
|
||||
return user ? getHueForUserId(user.id) : 0
|
||||
}
|
||||
|
||||
function getMessageStyle(user?: User) {
|
||||
return {
|
||||
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
|
||||
backgroundColor: `hsl(${hue(user)}, 85%, 40%`,
|
||||
}
|
||||
}
|
||||
|
||||
function getArrowStyle(user?: User) {
|
||||
return {
|
||||
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
|
||||
}
|
||||
}
|
||||
|
||||
function MessageGroup({ messages, user, fromSelf }: MessageGroupProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('message-wrapper', {
|
||||
'own-message-wrapper': fromSelf,
|
||||
})}
|
||||
>
|
||||
{!fromSelf && (
|
||||
<div className="name" translate="no">
|
||||
<span>
|
||||
{user ? user.first_name || user.email : t('deleted_user')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="message" style={getMessageStyle(user)}>
|
||||
{!fromSelf && <div className="arrow" style={getArrowStyle(user)} />}
|
||||
|
||||
{messages.map(message => (
|
||||
<MessageAndDropdown
|
||||
key={message.id}
|
||||
message={message}
|
||||
fromSelf={fromSelf}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageGroup
|
||||
@@ -1,10 +1,12 @@
|
||||
import moment from 'moment'
|
||||
import Message from './message'
|
||||
import type { Message as MessageType } from '@/features/chat/context/chat-context'
|
||||
import MessageRedesign from '@/features/ide-redesign/components/chat/message'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import { User } from '../../../../../types/user'
|
||||
import MessageGroup from '@/features/chat/components/message-group'
|
||||
import MessageGroupRedesign from '@/features/ide-redesign/components/chat/message-group'
|
||||
|
||||
const FIVE_MINUTES = 5 * 60 * 1000
|
||||
const TIMESTAMP_GROUP_SIZE = FIVE_MINUTES
|
||||
|
||||
function formatTimestamp(date: moment.MomentInput) {
|
||||
if (!date) {
|
||||
@@ -20,13 +22,56 @@ interface MessageListProps {
|
||||
newDesign?: boolean
|
||||
}
|
||||
|
||||
type MessageGroupType = {
|
||||
messages: MessageType[]
|
||||
id: string
|
||||
user?: User
|
||||
}
|
||||
|
||||
// Group messages by the same author that were sent within 5 minutes of each
|
||||
// other
|
||||
function groupMessages(messages: MessageType[]) {
|
||||
const groups: MessageGroupType[] = []
|
||||
let currentGroup: MessageGroupType | null = null
|
||||
let previousMessage: MessageType | null = null
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.deleted) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
currentGroup &&
|
||||
previousMessage &&
|
||||
!message.pending &&
|
||||
message.user &&
|
||||
message.user.id &&
|
||||
message.user.id === previousMessage.user?.id &&
|
||||
message.timestamp - previousMessage.timestamp < TIMESTAMP_GROUP_SIZE
|
||||
) {
|
||||
currentGroup.messages.push(message)
|
||||
} else {
|
||||
currentGroup = {
|
||||
messages: [message],
|
||||
id: String(message.timestamp),
|
||||
user: message.user,
|
||||
}
|
||||
groups.push(currentGroup)
|
||||
}
|
||||
previousMessage = message
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function MessageList({
|
||||
messages,
|
||||
resetUnreadMessages,
|
||||
newDesign,
|
||||
}: MessageListProps) {
|
||||
const user = useUserContext()
|
||||
const MessageComponent = newDesign ? MessageRedesign : Message
|
||||
|
||||
const MessageGroupComponent = newDesign ? MessageGroupRedesign : MessageGroup
|
||||
|
||||
function shouldRenderDate(messageIndex: number) {
|
||||
if (messageIndex === 0) {
|
||||
return true
|
||||
@@ -41,6 +86,8 @@ function MessageList({
|
||||
}
|
||||
}
|
||||
|
||||
const messageGroups = groupMessages(messages)
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||
<ul
|
||||
@@ -48,25 +95,25 @@ function MessageList({
|
||||
onClick={resetUnreadMessages}
|
||||
onKeyDown={resetUnreadMessages}
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
// new messages are added to the beginning of the list, so we use a reversed index
|
||||
<li key={message.id} className="message">
|
||||
{messageGroups.map((group, index) => (
|
||||
<li key={group.id} className="message">
|
||||
{shouldRenderDate(index) && (
|
||||
<div className="date">
|
||||
<time
|
||||
dateTime={
|
||||
message.timestamp
|
||||
? moment(message.timestamp).format()
|
||||
group.messages[0].timestamp
|
||||
? moment(group.messages[0].timestamp).format()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
{formatTimestamp(group.messages[0].timestamp)}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
<MessageComponent
|
||||
message={message}
|
||||
fromSelf={message.user ? message.user.id === user.id : false}
|
||||
<MessageGroupComponent
|
||||
messages={group.messages}
|
||||
user={group.user}
|
||||
fromSelf={user ? group.user?.id === user.id : false}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { getHueForUserId } from '@/shared/utils/colors'
|
||||
import MessageContent from './message-content'
|
||||
import type { Message as MessageType } from '@/features/chat/context/chat-context'
|
||||
import { User } from '../../../../../types/user'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface MessageProps {
|
||||
message: MessageType
|
||||
fromSelf: boolean
|
||||
}
|
||||
|
||||
function hue(user?: User) {
|
||||
return user ? getHueForUserId(user.id) : 0
|
||||
}
|
||||
|
||||
function getMessageStyle(user?: User) {
|
||||
return {
|
||||
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
|
||||
backgroundColor: `hsl(${hue(user)}, 85%, 40%`,
|
||||
}
|
||||
}
|
||||
|
||||
function getArrowStyle(user?: User) {
|
||||
return {
|
||||
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
|
||||
}
|
||||
}
|
||||
|
||||
function Message({ message, fromSelf }: MessageProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="message-wrapper">
|
||||
{!fromSelf && (
|
||||
<div className="name" translate="no">
|
||||
<span>
|
||||
{message.user
|
||||
? message.user.first_name || message.user.email
|
||||
: t('deleted_user')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="message" style={getMessageStyle(message.user)}>
|
||||
{!fromSelf && (
|
||||
<div className="arrow" style={getArrowStyle(message.user)} />
|
||||
)}
|
||||
<div className="message-content">
|
||||
{message.contents.map((content, index) => (
|
||||
<MessageContent key={index} content={content} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Message
|
||||
@@ -11,8 +11,18 @@ import {
|
||||
import clientIdGenerator from '@/utils/client-id'
|
||||
import { useUserContext } from '../../../shared/context/user-context'
|
||||
import { useProjectContext } from '../../../shared/context/project-context'
|
||||
import { getJSON, postJSON } from '../../../infrastructure/fetch-json'
|
||||
import { appendMessage, prependMessages } from '../utils/message-list-appender'
|
||||
import {
|
||||
deleteJSON,
|
||||
getJSON,
|
||||
postJSON,
|
||||
} from '../../../infrastructure/fetch-json'
|
||||
import {
|
||||
appendMessage,
|
||||
confirmMessage,
|
||||
deleteMessage,
|
||||
editMessage,
|
||||
prependMessages,
|
||||
} from '../utils/message-list-utils'
|
||||
import useBrowserWindow from '../../../shared/hooks/use-browser-window'
|
||||
import { useLayoutContext } from '../../../shared/context/layout-context'
|
||||
import { useIdeContext } from '@/shared/context/ide-context'
|
||||
@@ -27,12 +37,16 @@ const PAGE_SIZE = 50
|
||||
export type Message = {
|
||||
id: string
|
||||
timestamp: number
|
||||
contents: string[]
|
||||
content: string
|
||||
user?: User
|
||||
edited?: boolean
|
||||
deleted?: boolean
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
export type ServerMessageEntry = Omit<Message, 'contents'> & {
|
||||
export type ServerMessageEntry = Message & {
|
||||
content: string
|
||||
edited_at?: number
|
||||
}
|
||||
|
||||
type State = {
|
||||
@@ -44,6 +58,7 @@ type State = {
|
||||
unreadMessageCount: number
|
||||
error?: Error | null
|
||||
uniqueMessageIds: string[]
|
||||
idOfMessageBeingEdited: Message['id'] | null
|
||||
}
|
||||
|
||||
type Action =
|
||||
@@ -66,9 +81,29 @@ type Action =
|
||||
type: 'RECEIVE_MESSAGE'
|
||||
message: ServerMessageEntry
|
||||
}
|
||||
| {
|
||||
type: 'RECEIVE_OWN_MESSAGE'
|
||||
message: any
|
||||
}
|
||||
| {
|
||||
type: 'MARK_MESSAGES_AS_READ'
|
||||
}
|
||||
| {
|
||||
type: 'DELETE_MESSAGE'
|
||||
messageId: Message['id']
|
||||
}
|
||||
| {
|
||||
type: 'START_EDITING_MESSAGE'
|
||||
messageId: Message['id']
|
||||
}
|
||||
| {
|
||||
type: 'CANCEL_MESSAGE_EDIT'
|
||||
}
|
||||
| {
|
||||
type: 'RECEIVE_MESSAGE_EDIT'
|
||||
messageId: Message['id']
|
||||
content: string
|
||||
}
|
||||
| {
|
||||
type: 'CLEAR'
|
||||
}
|
||||
@@ -125,6 +160,7 @@ function chatReducer(state: State, action: Action): State {
|
||||
user: action.user,
|
||||
content: action.content,
|
||||
timestamp: Date.now(),
|
||||
pending: true,
|
||||
},
|
||||
state.uniqueMessageIds
|
||||
),
|
||||
@@ -141,6 +177,36 @@ function chatReducer(state: State, action: Action): State {
|
||||
unreadMessageCount: state.unreadMessageCount + 1,
|
||||
}
|
||||
|
||||
case 'RECEIVE_OWN_MESSAGE':
|
||||
return {
|
||||
...state,
|
||||
...confirmMessage(action.message, state.messages),
|
||||
}
|
||||
|
||||
case 'DELETE_MESSAGE':
|
||||
return {
|
||||
...state,
|
||||
...deleteMessage(action.messageId, state.messages),
|
||||
}
|
||||
|
||||
case 'START_EDITING_MESSAGE':
|
||||
return {
|
||||
...state,
|
||||
idOfMessageBeingEdited: action.messageId,
|
||||
}
|
||||
|
||||
case 'CANCEL_MESSAGE_EDIT':
|
||||
return {
|
||||
...state,
|
||||
idOfMessageBeingEdited: null,
|
||||
}
|
||||
|
||||
case 'RECEIVE_MESSAGE_EDIT':
|
||||
return {
|
||||
...state,
|
||||
...editMessage(action.messageId, action.content, state.messages),
|
||||
}
|
||||
|
||||
case 'MARK_MESSAGES_AS_READ':
|
||||
return {
|
||||
...state,
|
||||
@@ -171,6 +237,7 @@ const initialState: State = {
|
||||
unreadMessageCount: 0,
|
||||
error: null,
|
||||
uniqueMessageIds: [],
|
||||
idOfMessageBeingEdited: null,
|
||||
}
|
||||
|
||||
export const ChatContext = createContext<
|
||||
@@ -180,10 +247,15 @@ export const ChatContext = createContext<
|
||||
initialMessagesLoaded: boolean
|
||||
atEnd: boolean
|
||||
unreadMessageCount: number
|
||||
idOfMessageBeingEdited: State['idOfMessageBeingEdited']
|
||||
loadInitialMessages: () => void
|
||||
loadMoreMessages: () => void
|
||||
sendMessage: (message: any) => void
|
||||
markMessagesAsRead: () => void
|
||||
deleteMessage: (messageId: Message['id']) => void
|
||||
startedEditingMessage: (messageId: Message['id']) => void
|
||||
cancelMessageEdit: () => void
|
||||
editMessage: (messageId: Message['id'], content: string) => void
|
||||
reset: () => void
|
||||
error?: Error | null
|
||||
}
|
||||
@@ -314,6 +386,32 @@ export const ChatProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
[chatEnabled, projectId, user]
|
||||
)
|
||||
|
||||
const startedEditingMessage = useCallback(
|
||||
(messageId: Message['id']) => {
|
||||
if (!chatEnabled) {
|
||||
debugConsole.warn(`chat is disabled, won't send message`)
|
||||
return
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'START_EDITING_MESSAGE',
|
||||
messageId,
|
||||
})
|
||||
},
|
||||
[chatEnabled]
|
||||
)
|
||||
|
||||
const cancelMessageEdit = useCallback(() => {
|
||||
if (!chatEnabled) {
|
||||
debugConsole.warn(`chat is disabled, won't cancel message edit`)
|
||||
return
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'CANCEL_MESSAGE_EDIT',
|
||||
})
|
||||
}, [chatEnabled])
|
||||
|
||||
const markMessagesAsRead = useCallback(() => {
|
||||
if (!chatEnabled) {
|
||||
debugConsole.warn(`chat is disabled, won't mark messages as read`)
|
||||
@@ -322,26 +420,114 @@ export const ChatProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
dispatch({ type: 'MARK_MESSAGES_AS_READ' })
|
||||
}, [chatEnabled])
|
||||
|
||||
// Handling receiving messages over the socket
|
||||
const deleteMessage = useCallback(
|
||||
(messageId: Message['id']) => {
|
||||
if (!chatEnabled) {
|
||||
debugConsole.warn(`chat is disabled, won't delete message`)
|
||||
return
|
||||
}
|
||||
if (!messageId) return
|
||||
|
||||
dispatch({
|
||||
type: 'DELETE_MESSAGE',
|
||||
messageId,
|
||||
})
|
||||
|
||||
deleteJSON(`/project/${projectId}/messages/${messageId}`).catch(error => {
|
||||
dispatch({
|
||||
type: 'ERROR',
|
||||
error,
|
||||
})
|
||||
})
|
||||
},
|
||||
[chatEnabled, projectId]
|
||||
)
|
||||
|
||||
const editMessage = useCallback(
|
||||
(messageId: Message['id'], content: string) => {
|
||||
if (!chatEnabled) {
|
||||
debugConsole.warn(`chat is disabled, won't edit message`)
|
||||
return
|
||||
}
|
||||
if (!messageId || !content) return
|
||||
|
||||
dispatch({
|
||||
type: 'RECEIVE_MESSAGE_EDIT',
|
||||
messageId,
|
||||
content,
|
||||
})
|
||||
|
||||
dispatch({
|
||||
type: 'CANCEL_MESSAGE_EDIT',
|
||||
})
|
||||
|
||||
postJSON(`/project/${projectId}/messages/${messageId}/edit`, {
|
||||
body: { content },
|
||||
}).catch(error => {
|
||||
dispatch({
|
||||
type: 'ERROR',
|
||||
error,
|
||||
})
|
||||
})
|
||||
},
|
||||
[chatEnabled, projectId]
|
||||
)
|
||||
|
||||
// Handling receiving and deleting messages over the socket
|
||||
const { socket } = useIdeContext()
|
||||
useEffect(() => {
|
||||
if (!chatEnabled || !socket) return
|
||||
|
||||
function receivedMessage(message: any) {
|
||||
// If the message is from the current client id, then we are receiving the sent message back from the socket.
|
||||
// If the message is from the current client id, then we are receiving the
|
||||
// sent message back from the socket. In this case, we want to update the
|
||||
// message in our local state with the ID of the message on the server.
|
||||
// Ignore it to prevent double message.
|
||||
if (message.clientId === clientId.current) return
|
||||
if (message.clientId === clientId.current) {
|
||||
dispatch({ type: 'RECEIVE_OWN_MESSAGE', message })
|
||||
} else {
|
||||
dispatch({ type: 'RECEIVE_MESSAGE', message })
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({ type: 'RECEIVE_MESSAGE', message })
|
||||
function deletedMessage(message: {
|
||||
messageId: Message['id']
|
||||
userId: User['id']
|
||||
}) {
|
||||
if (message.userId === user.id) return
|
||||
|
||||
dispatch({
|
||||
type: 'DELETE_MESSAGE',
|
||||
messageId: message.messageId,
|
||||
})
|
||||
}
|
||||
|
||||
function editedMessage(message: {
|
||||
messageId: Message['id']
|
||||
userId: User['id']
|
||||
content: string
|
||||
}) {
|
||||
if (message.userId === user.id) return
|
||||
|
||||
dispatch({
|
||||
type: 'RECEIVE_MESSAGE_EDIT',
|
||||
messageId: message.messageId,
|
||||
content: message.content,
|
||||
})
|
||||
}
|
||||
|
||||
socket.on('new-chat-message', receivedMessage)
|
||||
socket.on('delete-global-message', deletedMessage)
|
||||
socket.on('edit-global-message', editedMessage)
|
||||
|
||||
return () => {
|
||||
if (!socket) return
|
||||
|
||||
socket.removeListener('new-chat-message', receivedMessage)
|
||||
socket.removeListener('delete-global-message', deletedMessage)
|
||||
socket.removeListener('edit-global-message', editedMessage)
|
||||
}
|
||||
}, [chatEnabled, socket])
|
||||
}, [chatEnabled, socket, user.id])
|
||||
|
||||
// Handle unread messages
|
||||
useEffect(() => {
|
||||
@@ -370,17 +556,26 @@ export const ChatProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
initialMessagesLoaded: state.initialMessagesLoaded,
|
||||
atEnd: state.atEnd,
|
||||
unreadMessageCount: state.unreadMessageCount,
|
||||
idOfMessageBeingEdited: state.idOfMessageBeingEdited,
|
||||
loadInitialMessages,
|
||||
loadMoreMessages,
|
||||
reset,
|
||||
sendMessage,
|
||||
markMessagesAsRead,
|
||||
deleteMessage,
|
||||
startedEditingMessage,
|
||||
cancelMessageEdit,
|
||||
editMessage,
|
||||
error: state.error,
|
||||
}),
|
||||
[
|
||||
loadInitialMessages,
|
||||
loadMoreMessages,
|
||||
markMessagesAsRead,
|
||||
deleteMessage,
|
||||
startedEditingMessage,
|
||||
cancelMessageEdit,
|
||||
editMessage,
|
||||
reset,
|
||||
sendMessage,
|
||||
state.atEnd,
|
||||
@@ -389,6 +584,7 @@ export const ChatProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
state.messages,
|
||||
state.status,
|
||||
state.unreadMessageCount,
|
||||
state.idOfMessageBeingEdited,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Message, ServerMessageEntry } from '../context/chat-context'
|
||||
|
||||
const TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
export function appendMessage(
|
||||
messageList: Message[],
|
||||
message: ServerMessageEntry,
|
||||
uniqueMessageIds: string[]
|
||||
) {
|
||||
if (uniqueMessageIds.includes(message.id)) {
|
||||
return { messages: messageList, uniqueMessageIds }
|
||||
}
|
||||
|
||||
uniqueMessageIds = uniqueMessageIds.slice(0)
|
||||
|
||||
uniqueMessageIds.push(message.id)
|
||||
|
||||
const lastMessage = messageList[messageList.length - 1]
|
||||
|
||||
const shouldGroup =
|
||||
lastMessage &&
|
||||
message &&
|
||||
message.user &&
|
||||
lastMessage.user &&
|
||||
message.user.id &&
|
||||
message.user.id === lastMessage.user.id &&
|
||||
message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
|
||||
|
||||
if (shouldGroup) {
|
||||
messageList = messageList.slice(0, messageList.length - 1).concat({
|
||||
...lastMessage,
|
||||
// the `id` is updated to the latest received content when a new
|
||||
// message is appended or prepended
|
||||
id: message.id,
|
||||
timestamp: message.timestamp,
|
||||
contents: lastMessage.contents.concat(message.content),
|
||||
})
|
||||
} else {
|
||||
messageList = messageList.slice(0).concat({
|
||||
id: message.id,
|
||||
user: message.user,
|
||||
timestamp: message.timestamp,
|
||||
contents: [message.content],
|
||||
})
|
||||
}
|
||||
|
||||
return { messages: messageList, uniqueMessageIds }
|
||||
}
|
||||
|
||||
export function prependMessages(
|
||||
messageList: Message[],
|
||||
messages: ServerMessageEntry[],
|
||||
uniqueMessageIds: string[]
|
||||
) {
|
||||
const listCopy = messageList.slice(0)
|
||||
|
||||
uniqueMessageIds = uniqueMessageIds.slice(0)
|
||||
|
||||
messages
|
||||
.slice(0)
|
||||
.reverse()
|
||||
.forEach(message => {
|
||||
if (uniqueMessageIds.includes(message.id)) {
|
||||
return
|
||||
}
|
||||
uniqueMessageIds.push(message.id)
|
||||
const firstMessage = listCopy[0]
|
||||
const shouldGroup =
|
||||
firstMessage &&
|
||||
message &&
|
||||
firstMessage.user &&
|
||||
message.user &&
|
||||
message.user.id === firstMessage.user.id &&
|
||||
firstMessage.timestamp - message.timestamp < TIMESTAMP_GROUP_SIZE
|
||||
|
||||
if (shouldGroup) {
|
||||
firstMessage.id = message.id
|
||||
firstMessage.timestamp = message.timestamp
|
||||
firstMessage.contents = [message.content].concat(firstMessage.contents)
|
||||
} else {
|
||||
listCopy.unshift({
|
||||
id: message.id,
|
||||
user: message.user,
|
||||
timestamp: message.timestamp,
|
||||
contents: [message.content],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { messages: listCopy, uniqueMessageIds }
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Message, ServerMessageEntry } from '../context/chat-context'
|
||||
|
||||
export function appendMessage(
|
||||
messageList: Message[],
|
||||
message: ServerMessageEntry,
|
||||
uniqueMessageIds: string[]
|
||||
) {
|
||||
if (uniqueMessageIds.includes(message.id)) {
|
||||
return { messages: messageList, uniqueMessageIds }
|
||||
}
|
||||
|
||||
uniqueMessageIds = uniqueMessageIds.slice(0)
|
||||
|
||||
uniqueMessageIds.push(message.id)
|
||||
|
||||
messageList = messageList.slice(0).concat({
|
||||
id: message.id,
|
||||
user: message.user,
|
||||
timestamp: message.timestamp,
|
||||
content: message.content,
|
||||
pending: message.pending,
|
||||
edited: Boolean(message.edited_at),
|
||||
})
|
||||
|
||||
return { messages: messageList, uniqueMessageIds }
|
||||
}
|
||||
|
||||
export function prependMessages(
|
||||
messageList: Message[],
|
||||
messages: ServerMessageEntry[],
|
||||
uniqueMessageIds: string[]
|
||||
) {
|
||||
const listCopy = messageList.slice(0)
|
||||
|
||||
uniqueMessageIds = uniqueMessageIds.slice(0)
|
||||
|
||||
messages
|
||||
.slice(0)
|
||||
.reverse()
|
||||
.forEach(message => {
|
||||
if (uniqueMessageIds.includes(message.id)) {
|
||||
return
|
||||
}
|
||||
uniqueMessageIds.push(message.id)
|
||||
|
||||
listCopy.unshift({
|
||||
id: message.id,
|
||||
user: message.user,
|
||||
timestamp: message.timestamp,
|
||||
content: message.content,
|
||||
edited: Boolean(message.edited_at),
|
||||
})
|
||||
})
|
||||
|
||||
return { messages: listCopy, uniqueMessageIds }
|
||||
}
|
||||
|
||||
export function confirmMessage(
|
||||
updatedMessage: Message,
|
||||
messageList: Message[]
|
||||
) {
|
||||
// Find our message and change its ID from the temporary one we generated
|
||||
// on creation to the ID assigned to it by the server. This is so that the
|
||||
// message can be deleted later, for which we need the server ID.
|
||||
const ownMessageIndex = messageList.findIndex(
|
||||
message => message.pending && message.content === updatedMessage.content
|
||||
)
|
||||
if (ownMessageIndex === -1) {
|
||||
throw new Error("Couldn't find own message in local state")
|
||||
}
|
||||
const messageWithOldId = messageList[ownMessageIndex]
|
||||
|
||||
const newMessageList = [...messageList]
|
||||
newMessageList.splice(ownMessageIndex, 1, {
|
||||
...messageWithOldId,
|
||||
pending: false,
|
||||
id: updatedMessage.id,
|
||||
user: updatedMessage.user,
|
||||
timestamp: updatedMessage.timestamp,
|
||||
content: updatedMessage.content,
|
||||
})
|
||||
|
||||
return {
|
||||
messages: newMessageList,
|
||||
uniqueMessageIds: Array.from(
|
||||
new Set(newMessageList.map(message => message.id))
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteMessage(messageId: string, messageList: Message[]) {
|
||||
const messageIndex = messageList.findIndex(
|
||||
message => message.id === messageId
|
||||
)
|
||||
if (messageIndex === -1) {
|
||||
throw new Error(`Message with id ${messageId} not found in message list`)
|
||||
}
|
||||
|
||||
const newMessageList = [...messageList]
|
||||
const message = newMessageList[messageIndex]
|
||||
newMessageList.splice(messageIndex, 1, {
|
||||
...message,
|
||||
deleted: true,
|
||||
})
|
||||
|
||||
return {
|
||||
messages: newMessageList,
|
||||
}
|
||||
}
|
||||
|
||||
export function editMessage(
|
||||
messageId: string,
|
||||
content: string,
|
||||
messageList: Message[]
|
||||
) {
|
||||
const messageIndex = messageList.findIndex(
|
||||
message => message.id === messageId
|
||||
)
|
||||
if (messageIndex === -1) {
|
||||
throw new Error(`Message with id ${messageId} not found in message list`)
|
||||
}
|
||||
|
||||
const newMessageList = [...messageList]
|
||||
const message = newMessageList[messageIndex]
|
||||
newMessageList.splice(messageIndex, 1, {
|
||||
...message,
|
||||
content,
|
||||
edited: true,
|
||||
})
|
||||
|
||||
return {
|
||||
messages: newMessageList,
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,6 @@ export const ChatPane = () => {
|
||||
|
||||
const shouldDisplayPlaceholder = status !== 'pending' && messages.length === 0
|
||||
|
||||
const messageContentCount = messages.reduce(
|
||||
(acc, { contents }) => acc + contents.length,
|
||||
0
|
||||
)
|
||||
|
||||
if (error) {
|
||||
// let user try recover from fetch errors
|
||||
if (error instanceof FetchError) {
|
||||
@@ -75,7 +70,7 @@ export const ChatPane = () => {
|
||||
className="messages"
|
||||
fetchData={loadMoreMessages}
|
||||
isLoading={status === 'pending'}
|
||||
itemCount={messageContentCount}
|
||||
itemCount={messages.length}
|
||||
>
|
||||
<div className={classNames({ 'h-100': shouldDisplayPlaceholder })}>
|
||||
<h2 className="visually-hidden">{t('chat')}</h2>
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import type { Message } from '@/features/chat/context/chat-context'
|
||||
import { User } from '../../../../../../types/user'
|
||||
import {
|
||||
getBackgroundColorForUserId,
|
||||
hslStringToLuminance,
|
||||
} from '@/shared/utils/colors'
|
||||
import MessageContent from '@/features/chat/components/message-content'
|
||||
import classNames from 'classnames'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import MessageDropdown from '@/features/chat/components/message-dropdown'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
function getAvatarStyle(user?: User) {
|
||||
if (!user?.id) {
|
||||
// Deleted user
|
||||
return {
|
||||
backgroundColor: 'var(--bg-light-disabled)',
|
||||
borderColor: 'var(--bg-light-disabled)',
|
||||
color: 'var(--content-disabled)',
|
||||
}
|
||||
}
|
||||
|
||||
const backgroundColor = getBackgroundColorForUserId(user.id)
|
||||
|
||||
return {
|
||||
borderColor: backgroundColor,
|
||||
backgroundColor,
|
||||
color:
|
||||
hslStringToLuminance(backgroundColor) < 0.5
|
||||
? 'var(--content-primary-dark)'
|
||||
: 'var(--content-primary)',
|
||||
}
|
||||
}
|
||||
|
||||
export function MessageAndDropdown({
|
||||
message,
|
||||
fromSelf,
|
||||
isLast,
|
||||
isFirst,
|
||||
}: {
|
||||
message: Message
|
||||
fromSelf: boolean
|
||||
isLast: boolean
|
||||
isFirst: boolean
|
||||
}) {
|
||||
const hasChatEditDelete = useFeatureFlag('chat-edit-delete')
|
||||
|
||||
return (
|
||||
<div className="message-row">
|
||||
<>
|
||||
{!fromSelf && isLast ? (
|
||||
<div className="message-avatar">
|
||||
<div className="avatar" style={getAvatarStyle(message.user)}>
|
||||
{message.user?.id && message.user.email ? (
|
||||
message.user.first_name?.charAt(0) ||
|
||||
message.user.email.charAt(0)
|
||||
) : (
|
||||
<MaterialIcon
|
||||
type="delete"
|
||||
className="message-avatar-deleted-user-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-avatar-placeholder" />
|
||||
)}
|
||||
<div
|
||||
className={classNames('message-container', {
|
||||
'message-from-self': fromSelf,
|
||||
'first-row-in-message': isFirst,
|
||||
'last-row-in-message': isLast,
|
||||
'pending-message': message.pending,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
{hasChatEditDelete && fromSelf ? (
|
||||
<MessageDropdown message={message} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
messageId={message.id}
|
||||
edited={message.edited}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MessageGroupProps } from '@/features/chat/components/message-group'
|
||||
import { MessageAndDropdown } from './message-and-dropdown'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function MessageGroup({ messages, user, fromSelf }: MessageGroupProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="chat-message-redesign">
|
||||
<div>
|
||||
<div className="message-row">
|
||||
<div className="message-avatar-placeholder" />
|
||||
{!fromSelf && (
|
||||
<div className="message-author">
|
||||
<span>
|
||||
{user?.id && user.email
|
||||
? user.first_name || user.email
|
||||
: t('deleted_user')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{messages.map((message, index) => {
|
||||
const nonDeletedMessages = messages.filter(m => !m.deleted)
|
||||
const nonDeletedIndex = nonDeletedMessages.findIndex(
|
||||
m => m.id === message.id
|
||||
)
|
||||
return (
|
||||
<MessageAndDropdown
|
||||
key={index}
|
||||
message={message}
|
||||
fromSelf={fromSelf}
|
||||
isLast={nonDeletedIndex === nonDeletedMessages.length - 1}
|
||||
isFirst={nonDeletedIndex === 0}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageGroup
|
||||
@@ -1,87 +0,0 @@
|
||||
import { MessageProps } from '@/features/chat/components/message'
|
||||
import { User } from '../../../../../../types/user'
|
||||
import {
|
||||
getBackgroundColorForUserId,
|
||||
hslStringToLuminance,
|
||||
} from '@/shared/utils/colors'
|
||||
import MessageContent from '@/features/chat/components/message-content'
|
||||
import classNames from 'classnames'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { t } from 'i18next'
|
||||
|
||||
function getAvatarStyle(user?: User) {
|
||||
if (!user?.id) {
|
||||
// Deleted user
|
||||
return {
|
||||
backgroundColor: 'var(--bg-light-disabled)',
|
||||
borderColor: 'var(--bg-light-disabled)',
|
||||
color: 'var(--content-disabled)',
|
||||
}
|
||||
}
|
||||
|
||||
const backgroundColor = getBackgroundColorForUserId(user.id)
|
||||
|
||||
return {
|
||||
borderColor: backgroundColor,
|
||||
backgroundColor,
|
||||
color:
|
||||
hslStringToLuminance(backgroundColor) < 0.5
|
||||
? 'var(--content-primary-dark)'
|
||||
: 'var(--content-primary)',
|
||||
}
|
||||
}
|
||||
|
||||
function Message({ message, fromSelf }: MessageProps) {
|
||||
return (
|
||||
<div className="chat-message-redesign">
|
||||
<div className="message-row">
|
||||
<div className="message-avatar-placeholder" />
|
||||
{!fromSelf && (
|
||||
<div className="message-author">
|
||||
<span>
|
||||
{message.user?.id && message.user.email
|
||||
? message.user.first_name || message.user.email
|
||||
: t('deleted_user')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{message.contents.map((content, index) => (
|
||||
<div key={index} className="message-row">
|
||||
<>
|
||||
{!fromSelf && index === message.contents.length - 1 ? (
|
||||
<div className="message-avatar">
|
||||
<div className="avatar" style={getAvatarStyle(message.user)}>
|
||||
{message.user?.id && message.user.email ? (
|
||||
message.user.first_name?.charAt(0) ||
|
||||
message.user.email.charAt(0)
|
||||
) : (
|
||||
<MaterialIcon
|
||||
type="delete"
|
||||
className="message-avatar-deleted-user-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-avatar-placeholder" />
|
||||
)}
|
||||
<div
|
||||
className={classNames('message-container', {
|
||||
'message-from-self': fromSelf,
|
||||
'first-row-in-message': index === 0,
|
||||
'last-row-in-message': index === message.contents.length - 1,
|
||||
})}
|
||||
>
|
||||
<div className="message-content">
|
||||
<MessageContent content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Message
|
||||
@@ -105,7 +105,7 @@ function AutoExpandingTextArea({
|
||||
}
|
||||
}, [onResize])
|
||||
|
||||
// Maintain a copy onAutoFocus in a ref for use in the autofocus effect
|
||||
// Maintain a copy of onAutoFocus in a ref for use in the autofocus effect
|
||||
// below so that the effect doesn't run when onAutoFocus changes
|
||||
const onAutoFocusRef = useRef(onAutoFocus)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ElementType, ReactNode, PropsWithChildren } from 'react'
|
||||
import type { ButtonProps } from '@/shared/components/types/button-props'
|
||||
import type { DropdownMenuProps as BS5DropdownMenuProps } from 'react-bootstrap'
|
||||
|
||||
type SplitButtonVariants = Extract<
|
||||
ButtonProps['variant'],
|
||||
@@ -71,6 +72,7 @@ export type DropdownMenuProps = PropsWithChildren<{
|
||||
flip?: boolean
|
||||
id?: string
|
||||
renderOnMount?: boolean
|
||||
popperConfig?: BS5DropdownMenuProps['popperConfig']
|
||||
}>
|
||||
|
||||
export type DropdownDividerProps = PropsWithChildren<{
|
||||
|
||||
@@ -109,6 +109,18 @@
|
||||
border-radius: var(--border-radius-base);
|
||||
position: relative;
|
||||
|
||||
.message-and-dropdown {
|
||||
clear: both;
|
||||
|
||||
&.pending-message {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.message-dropdown:not(.show) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: var(--spacing-03) var(--spacing-05);
|
||||
overflow-x: auto;
|
||||
@@ -118,6 +130,12 @@
|
||||
a {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.message-edited {
|
||||
@include body-xs;
|
||||
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
@@ -134,6 +152,28 @@
|
||||
border-bottom-color: transparent !important;
|
||||
border-width: 10px;
|
||||
}
|
||||
|
||||
.message-dropdown-menu {
|
||||
min-width: var(--bs-dropdown-min-width);
|
||||
}
|
||||
|
||||
.message-dropdown-menu-btn {
|
||||
@include reset-button;
|
||||
@include action-button;
|
||||
|
||||
color: var(--white);
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
&.own-message-wrapper .message-and-dropdown:hover {
|
||||
background-color: rgb($neutral-90, 0.08);
|
||||
|
||||
.message-dropdown {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
@@ -273,6 +313,30 @@
|
||||
.message-container {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
|
||||
.message-dropdown:not(.show) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&.pending-message {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.message-dropdown {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.message-dropdown-menu-btn {
|
||||
@include reset-button;
|
||||
@include action-button;
|
||||
|
||||
color: var(--content-primary-themed);
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-author {
|
||||
@@ -292,6 +356,12 @@
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-edited {
|
||||
@include body-xs;
|
||||
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.message-container.message-from-self {
|
||||
|
||||
@@ -520,6 +520,8 @@
|
||||
"delete_comment_thread": "Delete comment thread",
|
||||
"delete_comment_thread_message": "This will delete the whole comment thread. You cannot undo this action.",
|
||||
"delete_figure": "Delete figure",
|
||||
"delete_message": "Delete message",
|
||||
"delete_message_confirmation": "Are you sure you want to delete this message? This can’t be undone.",
|
||||
"delete_projects": "Delete Projects",
|
||||
"delete_row_or_column": "Delete row or column",
|
||||
"delete_sso_config": "Delete SSO configuration",
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { expect } from 'chai'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import MessageGroup from '../../../../../frontend/js/features/chat/components/message-group'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
|
||||
import { User, UserId } from '@ol-types/user'
|
||||
import {
|
||||
ChatContext,
|
||||
Message as MessageType,
|
||||
} from '@/features/chat/context/chat-context'
|
||||
import { UserProvider } from '@/shared/context/user-context'
|
||||
import { ModalsContextProvider } from '@/features/ide-react/context/modals-context'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
|
||||
describe('<MessageGroup />', function () {
|
||||
function ChatProviders({
|
||||
children,
|
||||
idOfMessageBeingEdited = null,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
idOfMessageBeingEdited?: string | null
|
||||
}) {
|
||||
const mockContextValue = {
|
||||
idOfMessageBeingEdited,
|
||||
cancelMessageEdit: () => {},
|
||||
editMessage: () => {},
|
||||
}
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<ModalsContextProvider>
|
||||
<SplitTestProvider>
|
||||
<ChatContext.Provider value={mockContextValue as any}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
</SplitTestProvider>
|
||||
</ModalsContextProvider>
|
||||
</UserProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const currentUser: User = {
|
||||
id: 'fake_user' as UserId,
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', currentUser)
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
|
||||
it('renders a basic message', function () {
|
||||
const message: MessageType = {
|
||||
content: 'a message',
|
||||
user: currentUser,
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
render(
|
||||
<ChatProviders>
|
||||
<MessageGroup messages={[message]} fromSelf />
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
screen.getByText('a message')
|
||||
})
|
||||
|
||||
it('renders a message with multiple contents', function () {
|
||||
const messages: MessageType[] = [
|
||||
{
|
||||
content: 'a message',
|
||||
user: currentUser,
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
},
|
||||
{
|
||||
content: 'another message',
|
||||
user: currentUser,
|
||||
id: 'msg_2',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<ChatProviders>
|
||||
<MessageGroup messages={messages} fromSelf />
|
||||
</ChatProviders>
|
||||
)
|
||||
screen.getByText('a message')
|
||||
screen.getByText('another message')
|
||||
})
|
||||
|
||||
it('renders HTML links within messages', function () {
|
||||
const message: MessageType = {
|
||||
content:
|
||||
'a message with a <a href="https://overleaf.com">link to Overleaf</a>',
|
||||
user: currentUser,
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
render(
|
||||
<ChatProviders>
|
||||
<MessageGroup messages={[message]} fromSelf />
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
screen.getByRole('link', { name: 'https://overleaf.com' })
|
||||
})
|
||||
|
||||
it('renders edited message with "(edited)" indicator', function () {
|
||||
const editedMessage: MessageType = {
|
||||
content: 'this message has been edited',
|
||||
user: currentUser,
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
edited: true,
|
||||
}
|
||||
|
||||
render(
|
||||
<ChatProviders>
|
||||
<MessageGroup messages={[editedMessage]} fromSelf />
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
screen.getByText('this message has been edited')
|
||||
screen.getByText('(edited)')
|
||||
})
|
||||
|
||||
it('does not render "(edited)" indicator for non-edited message', function () {
|
||||
const message: MessageType = {
|
||||
content: 'this message was not edited',
|
||||
user: currentUser,
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
edited: false,
|
||||
}
|
||||
|
||||
render(
|
||||
<ChatProviders>
|
||||
<MessageGroup messages={[message]} fromSelf />
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
screen.getByText('this message was not edited')
|
||||
expect(screen.queryByText('(edited)')).to.not.exist
|
||||
})
|
||||
|
||||
it('renders message being edited with textarea and action buttons', function () {
|
||||
const messageBeingEdited: MessageType = {
|
||||
content: 'original message content',
|
||||
user: currentUser,
|
||||
id: 'msg_being_edited',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
render(
|
||||
<ChatProviders idOfMessageBeingEdited="msg_being_edited">
|
||||
<MessageGroup messages={[messageBeingEdited]} fromSelf />
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
const textarea = screen.getByDisplayValue('original message content')
|
||||
expect(textarea.tagName.toLowerCase()).to.equal('textarea')
|
||||
|
||||
screen.getByRole('button', { name: 'Cancel' })
|
||||
screen.getByRole('button', { name: 'Save' })
|
||||
|
||||
const paragraphs = screen.queryAllByText('original message content', {
|
||||
selector: 'p',
|
||||
})
|
||||
expect(paragraphs).to.have.length(0)
|
||||
})
|
||||
|
||||
describe('when the message is from the user themselves', function () {
|
||||
const message: MessageType = {
|
||||
content: 'a message',
|
||||
user: currentUser,
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
it('does not render the user name nor the email', function () {
|
||||
render(
|
||||
<ChatProviders>
|
||||
<MessageGroup messages={[message]} fromSelf />
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
expect(screen.queryByText(currentUser.first_name!)).to.not.exist
|
||||
expect(screen.queryByText(currentUser.email)).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the message is from other user', function () {
|
||||
const otherUser: User = {
|
||||
id: 'other_user' as UserId,
|
||||
first_name: 'other_user_first_name',
|
||||
email: 'other@example.com',
|
||||
}
|
||||
|
||||
const message: MessageType = {
|
||||
content: 'a message',
|
||||
user: otherUser,
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
it('should render the other user name', function () {
|
||||
render(
|
||||
<ChatProviders>
|
||||
<MessageGroup
|
||||
messages={[message]}
|
||||
user={otherUser}
|
||||
fromSelf={false}
|
||||
/>
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
screen.getByText(otherUser.first_name!)
|
||||
})
|
||||
|
||||
it('should render the other user email when their name is not available', function () {
|
||||
const msg: MessageType = {
|
||||
content: message.content,
|
||||
user: {
|
||||
id: otherUser.id,
|
||||
email: 'other@example.com',
|
||||
},
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
render(
|
||||
<ChatProviders>
|
||||
<MessageGroup messages={[msg]} user={msg.user} fromSelf={false} />
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
expect(screen.queryByText(otherUser.first_name!)).to.not.exist
|
||||
screen.getByText(msg.user!.email)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,34 @@
|
||||
import sinon from 'sinon'
|
||||
import { expect } from 'chai'
|
||||
import { screen, render, fireEvent } from '@testing-library/react'
|
||||
|
||||
import React from 'react'
|
||||
import MessageList from '../../../../../frontend/js/features/chat/components/message-list'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
|
||||
import { UserProvider } from '@/shared/context/user-context'
|
||||
import { User, UserId } from '@ol-types/user'
|
||||
import { Message } from '@/features/chat/context/chat-context'
|
||||
import { Message, ChatContext } from '@/features/chat/context/chat-context'
|
||||
import { ModalsContextProvider } from '@/features/ide-react/context/modals-context'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
|
||||
describe('<MessageList />', function () {
|
||||
function ChatProviders({ children }: { children: React.ReactNode }) {
|
||||
const mockContextValue = {
|
||||
idOfMessageBeingEdited: null,
|
||||
}
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<ModalsContextProvider>
|
||||
<SplitTestProvider>
|
||||
<ChatContext.Provider value={mockContextValue as any}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
</SplitTestProvider>
|
||||
</ModalsContextProvider>
|
||||
</UserProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const currentUser: User = {
|
||||
id: 'fake_user' as UserId,
|
||||
first_name: 'fake_user_first_name',
|
||||
@@ -19,13 +39,13 @@ describe('<MessageList />', function () {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
contents: ['a message'],
|
||||
content: 'a message',
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
contents: ['another message'],
|
||||
content: 'another message',
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
@@ -52,12 +72,12 @@ describe('<MessageList />', function () {
|
||||
|
||||
it('renders multiple messages', function () {
|
||||
render(
|
||||
<UserProvider>
|
||||
<ChatProviders>
|
||||
<MessageList
|
||||
messages={createMessages()}
|
||||
resetUnreadMessages={() => {}}
|
||||
/>
|
||||
</UserProvider>
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
screen.getByText('a message')
|
||||
@@ -70,9 +90,9 @@ describe('<MessageList />', function () {
|
||||
msgs[1].timestamp = new Date(2019, 6, 3, 4, 27).getTime()
|
||||
|
||||
render(
|
||||
<UserProvider>
|
||||
<ChatProviders>
|
||||
<MessageList messages={msgs} resetUnreadMessages={() => {}} />
|
||||
</UserProvider>
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
screen.getByText('4:23 am Wed, 3rd Jul 19')
|
||||
@@ -85,9 +105,9 @@ describe('<MessageList />', function () {
|
||||
msgs[1].timestamp = new Date(2019, 6, 3, 4, 31).getTime()
|
||||
|
||||
render(
|
||||
<UserProvider>
|
||||
<ChatProviders>
|
||||
<MessageList messages={msgs} resetUnreadMessages={() => {}} />
|
||||
</UserProvider>
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
screen.getByText('4:23 am Wed, 3rd Jul 19')
|
||||
@@ -97,15 +117,101 @@ describe('<MessageList />', function () {
|
||||
it('resets the number of unread messages after clicking on the input', function () {
|
||||
const resetUnreadMessages = sinon.stub()
|
||||
render(
|
||||
<UserProvider>
|
||||
<ChatProviders>
|
||||
<MessageList
|
||||
messages={createMessages()}
|
||||
resetUnreadMessages={resetUnreadMessages}
|
||||
/>
|
||||
</UserProvider>
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('list'))
|
||||
expect(resetUnreadMessages).to.be.calledOnce
|
||||
})
|
||||
|
||||
it('groups messages from different users separately', function () {
|
||||
const anotherUser: User = {
|
||||
id: 'another_user' as UserId,
|
||||
first_name: 'another_user_first_name',
|
||||
email: 'another@example.com',
|
||||
}
|
||||
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: '1',
|
||||
content: 'first message from current user',
|
||||
user: currentUser,
|
||||
timestamp: new Date('2025-09-01 4:20:10').getTime(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
content: 'second message from current user',
|
||||
user: currentUser,
|
||||
timestamp: new Date('2025-09-01 4:20:11').getTime(),
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
content: 'first message from another user',
|
||||
user: anotherUser,
|
||||
timestamp: new Date('2025-09-01 4:20:12').getTime(),
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
content: 'second message from another user',
|
||||
user: anotherUser,
|
||||
timestamp: new Date('2025-09-01 4:20:13').getTime(),
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<ChatProviders>
|
||||
<MessageList messages={messages} resetUnreadMessages={() => {}} />
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
const messageGroups = screen.getAllByRole('listitem')
|
||||
|
||||
// Should have 2 message groups
|
||||
expect(messageGroups).to.have.length(2)
|
||||
|
||||
screen.getByText('first message from current user')
|
||||
screen.getByText('second message from current user')
|
||||
screen.getByText('first message from another user')
|
||||
screen.getByText('second message from another user')
|
||||
})
|
||||
|
||||
it('does not show deleted messages', function () {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: '1',
|
||||
content: 'visible message',
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime(),
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
content: 'deleted message',
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime() + 1000,
|
||||
deleted: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
content: 'another visible message',
|
||||
user: currentUser,
|
||||
timestamp: new Date().getTime() + 2000,
|
||||
},
|
||||
]
|
||||
|
||||
render(
|
||||
<ChatProviders>
|
||||
<MessageList messages={messages} resetUnreadMessages={() => {}} />
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
screen.getByText('visible message')
|
||||
screen.getByText('another visible message')
|
||||
|
||||
expect(screen.queryByText('deleted message')).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { expect } from 'chai'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import Message from '../../../../../frontend/js/features/chat/components/message'
|
||||
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
|
||||
import { User, UserId } from '@ol-types/user'
|
||||
import { Message as MessageType } from '@/features/chat/context/chat-context'
|
||||
|
||||
describe('<Message />', function () {
|
||||
const currentUser: User = {
|
||||
id: 'fake_user' as UserId,
|
||||
first_name: 'fake_user_first_name',
|
||||
email: 'fake@example.com',
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-user', currentUser)
|
||||
stubMathJax()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
tearDownMathJaxStubs()
|
||||
})
|
||||
|
||||
it('renders a basic message', function () {
|
||||
const message: MessageType = {
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
screen.getByText('a message')
|
||||
})
|
||||
|
||||
it('renders a message with multiple contents', function () {
|
||||
const message: MessageType = {
|
||||
contents: ['a message', 'another message'],
|
||||
user: currentUser,
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
screen.getByText('a message')
|
||||
screen.getByText('another message')
|
||||
})
|
||||
|
||||
it('renders HTML links within messages', function () {
|
||||
const message: MessageType = {
|
||||
contents: [
|
||||
'a message with a <a href="https://overleaf.com">link to Overleaf</a>',
|
||||
],
|
||||
user: currentUser,
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
screen.getByRole('link', { name: 'https://overleaf.com' })
|
||||
})
|
||||
|
||||
describe('when the message is from the user themselves', function () {
|
||||
const message: MessageType = {
|
||||
contents: ['a message'],
|
||||
user: currentUser,
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
it('does not render the user name nor the email', function () {
|
||||
render(<Message message={message} fromSelf />)
|
||||
|
||||
expect(screen.queryByText(currentUser.first_name!)).to.not.exist
|
||||
expect(screen.queryByText(currentUser.email)).to.not.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the message is from other user', function () {
|
||||
const otherUser: User = {
|
||||
id: 'other_user' as UserId,
|
||||
first_name: 'other_user_first_name',
|
||||
email: 'other@example.com',
|
||||
}
|
||||
|
||||
const message: MessageType = {
|
||||
contents: ['a message'],
|
||||
user: otherUser,
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
it('should render the other user name', function () {
|
||||
render(<Message message={message} fromSelf={false} />)
|
||||
|
||||
screen.getByText(otherUser.first_name!)
|
||||
})
|
||||
|
||||
it('should render the other user email when their name is not available', function () {
|
||||
const msg: MessageType = {
|
||||
contents: message.contents,
|
||||
user: {
|
||||
id: otherUser.id,
|
||||
email: 'other@example.com',
|
||||
},
|
||||
id: 'msg_1',
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
render(<Message message={msg} fromSelf={false} />)
|
||||
|
||||
expect(screen.queryByText(otherUser.first_name!)).to.not.exist
|
||||
screen.getByText(msg.user!.email)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -109,7 +109,7 @@ describe('ChatContext', function () {
|
||||
await waitFor(() => {
|
||||
const message = result.current.messages[0]
|
||||
expect(message.id).to.equal('msg_1')
|
||||
expect(message.contents).to.deep.equal(['new message'])
|
||||
expect(message.content).to.deep.equal('new message')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('ChatContext', function () {
|
||||
|
||||
const message = result.current.messages[0]
|
||||
expect(message.id).to.equal('msg_1')
|
||||
expect(message.contents).to.deep.equal(['new message'])
|
||||
expect(message.content).to.deep.equal('new message')
|
||||
})
|
||||
|
||||
it('deduplicate messages from websocket', async function () {
|
||||
@@ -209,7 +209,7 @@ describe('ChatContext', function () {
|
||||
|
||||
const message = result.current.messages[0]
|
||||
expect(message.id).to.equal('msg_1')
|
||||
expect(message.contents).to.deep.equal(['new message'])
|
||||
expect(message.content).to.deep.equal('new message')
|
||||
})
|
||||
|
||||
it("doesn't add received messages from the current user if a message was just sent", async function () {
|
||||
@@ -225,14 +225,14 @@ describe('ChatContext', function () {
|
||||
)
|
||||
|
||||
// Send a message from the current user
|
||||
const sentMsg = 'sent message'
|
||||
result.current.sendMessage(sentMsg)
|
||||
const content = 'sent message'
|
||||
result.current.sendMessage(content)
|
||||
|
||||
act(() => {
|
||||
// Receive a message from the current user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_1',
|
||||
content: 'received message',
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
user,
|
||||
clientId: uuidValue,
|
||||
@@ -243,7 +243,7 @@ describe('ChatContext', function () {
|
||||
|
||||
const [message] = result.current.messages
|
||||
|
||||
expect(message.contents).to.deep.equal([sentMsg])
|
||||
expect(message.content).to.deep.equal(content)
|
||||
})
|
||||
|
||||
it('adds the new message from the current user if another message was received after sending', async function () {
|
||||
@@ -259,13 +259,13 @@ describe('ChatContext', function () {
|
||||
)
|
||||
|
||||
// Send a message from the current user
|
||||
const sentMsg = 'sent message from current user'
|
||||
const content = 'sent message from current user'
|
||||
act(() => {
|
||||
result.current.sendMessage(sentMsg)
|
||||
result.current.sendMessage(content)
|
||||
})
|
||||
|
||||
const [sentMessageFromCurrentUser] = result.current.messages
|
||||
expect(sentMessageFromCurrentUser.contents).to.deep.equal([sentMsg])
|
||||
expect(sentMessageFromCurrentUser.content).to.deep.equal(content)
|
||||
|
||||
const otherMsg = 'new message from other user'
|
||||
|
||||
@@ -285,25 +285,105 @@ describe('ChatContext', function () {
|
||||
})
|
||||
|
||||
const [, messageFromOtherUser] = result.current.messages
|
||||
expect(messageFromOtherUser.contents).to.deep.equal([otherMsg])
|
||||
expect(messageFromOtherUser.content).to.deep.equal(otherMsg)
|
||||
|
||||
const receivedMessageTimestamp = Date.now()
|
||||
act(() => {
|
||||
// Receive a message from the current user
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'msg_2',
|
||||
content: 'received message from current user',
|
||||
timestamp: Date.now(),
|
||||
content,
|
||||
timestamp: receivedMessageTimestamp,
|
||||
user,
|
||||
clientId: uuidValue,
|
||||
})
|
||||
})
|
||||
|
||||
// Since the current user didn't just send a message, it is now shown
|
||||
// Since this message has the same clientId, it should update the pending message
|
||||
const updatedSentMessage = {
|
||||
...sentMessageFromCurrentUser,
|
||||
id: 'msg_2',
|
||||
content,
|
||||
pending: false,
|
||||
user,
|
||||
timestamp: receivedMessageTimestamp,
|
||||
}
|
||||
expect(result.current.messages).to.deep.equal([
|
||||
sentMessageFromCurrentUser,
|
||||
updatedSentMessage,
|
||||
messageFromOtherUser,
|
||||
])
|
||||
})
|
||||
|
||||
it('handles multiple pending messages correctly when confirmed out of order', async function () {
|
||||
const socket = new SocketIOMock()
|
||||
const { result } = renderChatContextHook({
|
||||
socket: socket as any as Socket,
|
||||
})
|
||||
|
||||
// Wait until initial messages have loaded
|
||||
result.current.loadInitialMessages()
|
||||
await waitFor(
|
||||
() => expect(result.current.initialMessagesLoaded).to.be.true
|
||||
)
|
||||
|
||||
// Send first message
|
||||
act(() => {
|
||||
result.current.sendMessage('first message')
|
||||
})
|
||||
|
||||
// Send second message quickly
|
||||
act(() => {
|
||||
result.current.sendMessage('second message')
|
||||
})
|
||||
|
||||
// At this point we should have 2 pending messages
|
||||
expect(result.current.messages).to.have.length(2)
|
||||
expect(result.current.messages[0].content).to.equal('first message')
|
||||
expect(result.current.messages[0].pending).to.be.true
|
||||
expect(result.current.messages[1].content).to.equal('second message')
|
||||
expect(result.current.messages[1].pending).to.be.true
|
||||
|
||||
// Server confirms the second message first
|
||||
const secondMessageTimestamp = Date.now()
|
||||
act(() => {
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'server-id-2',
|
||||
content: 'second message',
|
||||
user,
|
||||
timestamp: secondMessageTimestamp,
|
||||
clientId: uuidValue,
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages[0].content).to.equal('first message')
|
||||
expect(result.current.messages[0].pending).to.be.true // Still pending
|
||||
expect(result.current.messages[1].content).to.equal('second message')
|
||||
expect(result.current.messages[1].pending).to.be.false // Confirmed
|
||||
expect(result.current.messages[1].id).to.equal('server-id-2')
|
||||
})
|
||||
|
||||
// Server confirms the first message
|
||||
const firstMessageTimestamp = Date.now()
|
||||
act(() => {
|
||||
socket.emitToClient('new-chat-message', {
|
||||
id: 'server-id-1',
|
||||
content: 'first message',
|
||||
user,
|
||||
timestamp: firstMessageTimestamp,
|
||||
clientId: uuidValue,
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages[0].content).to.equal('first message')
|
||||
expect(result.current.messages[0].pending).to.be.false // Now confirmed
|
||||
expect(result.current.messages[0].id).to.equal('server-id-1')
|
||||
expect(result.current.messages[1].content).to.equal('second message')
|
||||
expect(result.current.messages[1].pending).to.be.false // Still confirmed
|
||||
expect(result.current.messages[1].id).to.equal('server-id-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadInitialMessages', function () {
|
||||
@@ -323,7 +403,7 @@ describe('ChatContext', function () {
|
||||
|
||||
result.current.loadInitialMessages()
|
||||
await waitFor(() =>
|
||||
expect(result.current.messages[0].contents).to.deep.equal(['a message'])
|
||||
expect(result.current.messages[0].content).to.deep.equal('a message')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -374,9 +454,9 @@ describe('ChatContext', function () {
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
await waitFor(() =>
|
||||
expect(result.current.messages[0].contents).to.deep.equal([
|
||||
'first message',
|
||||
])
|
||||
expect(result.current.messages[0].content).to.deep.equal(
|
||||
'first message'
|
||||
)
|
||||
)
|
||||
|
||||
// The before query param is not set
|
||||
@@ -403,9 +483,7 @@ describe('ChatContext', function () {
|
||||
const { result } = renderChatContextHook({})
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
await waitFor(() =>
|
||||
expect(result.current.messages[0].contents).to.have.length(50)
|
||||
)
|
||||
await waitFor(() => expect(result.current.messages).to.have.length(50))
|
||||
|
||||
// Call a second time
|
||||
result.current.loadMoreMessages()
|
||||
@@ -414,7 +492,7 @@ describe('ChatContext', function () {
|
||||
// Since both messages from the same user, they are collapsed into the
|
||||
// same "message"
|
||||
await waitFor(() =>
|
||||
expect(result.current.messages[0].contents).to.include(
|
||||
expect(result.current.messages[0].content).to.include(
|
||||
'message from second page'
|
||||
)
|
||||
)
|
||||
@@ -439,9 +517,7 @@ describe('ChatContext', function () {
|
||||
const { result } = renderChatContextHook({})
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
await waitFor(() =>
|
||||
expect(result.current.messages[0].contents).to.have.length(49)
|
||||
)
|
||||
await waitFor(() => expect(result.current.messages).to.have.length(49))
|
||||
|
||||
result.current.loadMoreMessages()
|
||||
|
||||
@@ -497,7 +573,7 @@ describe('ChatContext', function () {
|
||||
// Although the loaded message was resolved last, it appears first (since
|
||||
// requested messages must have come first)
|
||||
const messageContents = result.current.messages.map(
|
||||
({ contents }) => contents[0]
|
||||
({ content }) => content
|
||||
)
|
||||
expect(messageContents).to.deep.equal([
|
||||
'loaded message',
|
||||
@@ -532,9 +608,7 @@ describe('ChatContext', function () {
|
||||
result.current.sendMessage('sent message')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.messages[0].contents).to.deep.equal([
|
||||
'sent message',
|
||||
])
|
||||
expect(result.current.messages[0].content).to.deep.equal('sent message')
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import { expect } from 'chai'
|
||||
import {
|
||||
appendMessage,
|
||||
prependMessages,
|
||||
} from '../../../../../frontend/js/features/chat/utils/message-list-appender'
|
||||
import { User, UserId } from '@ol-types/user'
|
||||
import {
|
||||
Message,
|
||||
ServerMessageEntry,
|
||||
} from '@/features/chat/context/chat-context'
|
||||
|
||||
const testUser: User = {
|
||||
id: '123abc' as UserId,
|
||||
email: 'test-user@example.com',
|
||||
}
|
||||
|
||||
const otherUser: User = {
|
||||
id: '234other' as UserId,
|
||||
email: 'other-user@example.com',
|
||||
}
|
||||
|
||||
function createTestMessageList(): Message[] {
|
||||
return [
|
||||
{
|
||||
id: 'msg_1',
|
||||
contents: ['hello', 'world'],
|
||||
timestamp: new Date().getTime(),
|
||||
user: otherUser,
|
||||
},
|
||||
{
|
||||
id: 'msg_2',
|
||||
contents: ['foo'],
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
describe('prependMessages()', function () {
|
||||
function createTestMessages(): ServerMessageEntry[] {
|
||||
const message1 = {
|
||||
id: 'prepended_message',
|
||||
content: 'hello',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
}
|
||||
const message2 = { ...message1, id: 'prepended_message_2' }
|
||||
return [message1, message2]
|
||||
}
|
||||
|
||||
it('to an empty list', function () {
|
||||
const messages = createTestMessages()
|
||||
const uniqueMessageIds: string[] = []
|
||||
|
||||
expect(
|
||||
prependMessages([], messages, uniqueMessageIds).messages
|
||||
).to.deep.equal([
|
||||
{
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('when the messages to prepend are from the same user', function () {
|
||||
let list, messages: ServerMessageEntry[], uniqueMessageIds: string[]
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
messages = createTestMessages()
|
||||
messages[0].user = testUser // makes all the messages have the same author
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('when the prepended messages are close in time, contents should be merged into the same message', function () {
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 1)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content],
|
||||
})
|
||||
})
|
||||
|
||||
it('when the prepended messages are separated in time, each message is prepended', function () {
|
||||
messages[0].timestamp = messages[1].timestamp - 6 * 60 * 1000 // 6 minutes before the next message
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content],
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
id: messages[1].id,
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
contents: [messages[1].content],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the messages to prepend are from different users', function () {
|
||||
let list, messages: ServerMessageEntry[], uniqueMessageIds: string[]
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
messages = createTestMessages()
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('should prepend separate messages to the list', function () {
|
||||
messages[0].user = otherUser
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content],
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
id: messages[1].id,
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
contents: [messages[1].content],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should merge the prepended messages into the first existing one when user is same user and are close in time', function () {
|
||||
const list = createTestMessageList()
|
||||
const messages = createTestMessages()
|
||||
messages[0].user = messages[1].user = list[0].user
|
||||
const uniqueMessageIds: string[] = []
|
||||
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
contents: [messages[0].content, messages[1].content, ...list[0].contents],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendMessage()', function () {
|
||||
function createTestMessage() {
|
||||
return {
|
||||
id: 'appended_message',
|
||||
content: 'hi!',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
}
|
||||
}
|
||||
|
||||
it('to an empty list', function () {
|
||||
const testMessage = createTestMessage()
|
||||
const uniqueMessageIds: string[] = []
|
||||
|
||||
expect(
|
||||
appendMessage([], testMessage, uniqueMessageIds).messages
|
||||
).to.deep.equal([
|
||||
{
|
||||
id: 'appended_message',
|
||||
timestamp: testMessage.timestamp,
|
||||
user: testMessage.user,
|
||||
contents: [testMessage.content],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('messages appended shortly after the last message on the list', function () {
|
||||
let list: Message[], message: ServerMessageEntry, uniqueMessageIds: string[]
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
message = createTestMessage()
|
||||
message.timestamp = list[1].timestamp + 6 * 1000 // 6 seconds after the last message in the list
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
describe('when the author is the same as the last message', function () {
|
||||
it('should append the content to the last message', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(list.length)
|
||||
expect(result[1].contents).to.deep.equal(
|
||||
list[1].contents.concat(message.content)
|
||||
)
|
||||
})
|
||||
|
||||
it('should update the last message timestamp', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result[1].timestamp).to.equal(message.timestamp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the author is different than the last message', function () {
|
||||
beforeEach(function () {
|
||||
message.user = otherUser
|
||||
})
|
||||
|
||||
it('should append the new message to the list', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(list.length + 1)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('messages appended later after the last message on the list', function () {
|
||||
let list: Message[], message: ServerMessageEntry, uniqueMessageIds: string[]
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
message = createTestMessage()
|
||||
message.timestamp = list[1].timestamp + 6 * 60 * 1000 // 6 minutes after the last message in the list
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('when the author is the same as the last message, should be appended as new message', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(3)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content],
|
||||
})
|
||||
})
|
||||
|
||||
it('when the author is the different than the last message, should be appended as new message', function () {
|
||||
message.user = otherUser
|
||||
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(3)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
contents: [message.content],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,429 @@
|
||||
import { expect } from 'chai'
|
||||
import {
|
||||
appendMessage,
|
||||
prependMessages,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
confirmMessage,
|
||||
} from '../../../../../frontend/js/features/chat/utils/message-list-utils'
|
||||
import { User, UserId } from '@ol-types/user'
|
||||
import {
|
||||
Message,
|
||||
ServerMessageEntry,
|
||||
} from '@/features/chat/context/chat-context'
|
||||
|
||||
const testUser: User = {
|
||||
id: '123abc' as UserId,
|
||||
email: 'test-user@example.com',
|
||||
}
|
||||
|
||||
const otherUser: User = {
|
||||
id: '234other' as UserId,
|
||||
email: 'other-user@example.com',
|
||||
}
|
||||
|
||||
function createTestMessageList(): Message[] {
|
||||
return [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'hello world',
|
||||
timestamp: new Date().getTime(),
|
||||
user: otherUser,
|
||||
},
|
||||
{
|
||||
id: 'msg_2',
|
||||
content: 'foo',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
},
|
||||
]
|
||||
}
|
||||
describe('message-list-utils', function () {
|
||||
describe('prependMessages()', function () {
|
||||
function createTestMessages(): ServerMessageEntry[] {
|
||||
const message1 = {
|
||||
id: 'prepended_message',
|
||||
content: 'hello',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
}
|
||||
const message2 = { ...message1, id: 'prepended_message_2' }
|
||||
return [message1, message2]
|
||||
}
|
||||
|
||||
it('to an empty list', function () {
|
||||
const messages = createTestMessages()
|
||||
const uniqueMessageIds: string[] = []
|
||||
|
||||
expect(
|
||||
prependMessages([], messages, uniqueMessageIds).messages
|
||||
).to.deep.equal([
|
||||
{
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
content: messages[0].content,
|
||||
edited: false,
|
||||
},
|
||||
{
|
||||
id: messages[1].id,
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
content: messages[1].content,
|
||||
edited: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('when the messages to prepend are from the same user', function () {
|
||||
let list, messages: ServerMessageEntry[], uniqueMessageIds: string[]
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
messages = createTestMessages()
|
||||
messages[0].user = testUser // makes all the messages have the same author
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('when the prepended messages are close in time, contents should be merged into the same message', function () {
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
content: messages[0].content,
|
||||
edited: false,
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
id: messages[1].id,
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
content: messages[1].content,
|
||||
edited: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('when the prepended messages are separated in time, each message is prepended', function () {
|
||||
messages[0].timestamp = messages[1].timestamp - 6 * 60 * 1000 // 6 minutes before the next message
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
content: messages[0].content,
|
||||
edited: false,
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
id: messages[1].id,
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
content: messages[1].content,
|
||||
edited: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the messages to prepend are from different users', function () {
|
||||
let list, messages: ServerMessageEntry[], uniqueMessageIds: string[]
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
messages = createTestMessages()
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('should prepend separate messages to the list', function () {
|
||||
messages[0].user = otherUser
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
content: messages[0].content,
|
||||
edited: false,
|
||||
})
|
||||
expect(result[1]).to.deep.equal({
|
||||
id: messages[1].id,
|
||||
timestamp: messages[1].timestamp,
|
||||
user: messages[1].user,
|
||||
content: messages[1].content,
|
||||
edited: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should merge the prepended messages into the first existing one when user is same user and are close in time', function () {
|
||||
const list = createTestMessageList()
|
||||
const messages = createTestMessages()
|
||||
messages[0].user = messages[1].user = list[0].user
|
||||
const uniqueMessageIds: string[] = []
|
||||
|
||||
const result = prependMessages(
|
||||
createTestMessageList(),
|
||||
messages,
|
||||
uniqueMessageIds
|
||||
).messages
|
||||
expect(result.length).to.equal(list.length + 2)
|
||||
expect(result[0]).to.deep.equal({
|
||||
id: messages[0].id,
|
||||
timestamp: messages[0].timestamp,
|
||||
user: messages[0].user,
|
||||
content: messages[0].content,
|
||||
edited: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendMessage()', function () {
|
||||
function createTestMessage() {
|
||||
return {
|
||||
id: 'appended_message',
|
||||
content: 'hi!',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
it('to an empty list', function () {
|
||||
const testMessage = createTestMessage()
|
||||
const uniqueMessageIds: string[] = []
|
||||
|
||||
expect(
|
||||
appendMessage([], testMessage, uniqueMessageIds).messages
|
||||
).to.deep.equal([
|
||||
{
|
||||
id: 'appended_message',
|
||||
timestamp: testMessage.timestamp,
|
||||
user: testMessage.user,
|
||||
content: testMessage.content,
|
||||
pending: false,
|
||||
edited: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
describe('messages appended shortly after the last message on the list', function () {
|
||||
let list: Message[],
|
||||
message: ServerMessageEntry,
|
||||
uniqueMessageIds: string[]
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
message = createTestMessage()
|
||||
message.timestamp = list[1].timestamp + 6 * 1000 // 6 seconds after the last message in the list
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
describe('when the author is the same as the last message', function () {
|
||||
it('should append the content to the last message', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(list.length + 1)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: message.id,
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
content: message.content,
|
||||
pending: false,
|
||||
edited: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should update the last message timestamp', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result[2].timestamp).to.equal(message.timestamp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the author is different than the last message', function () {
|
||||
beforeEach(function () {
|
||||
message.user = otherUser
|
||||
})
|
||||
|
||||
it('should append the new message to the list', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(list.length + 1)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
content: message.content,
|
||||
pending: false,
|
||||
edited: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('messages appended later after the last message on the list', function () {
|
||||
let list: Message[],
|
||||
message: ServerMessageEntry,
|
||||
uniqueMessageIds: string[]
|
||||
|
||||
beforeEach(function () {
|
||||
list = createTestMessageList()
|
||||
message = createTestMessage()
|
||||
message.timestamp = list[1].timestamp + 6 * 60 * 1000 // 6 minutes after the last message in the list
|
||||
uniqueMessageIds = []
|
||||
})
|
||||
|
||||
it('when the author is the same as the last message, should be appended as new message', function () {
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(3)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
content: message.content,
|
||||
pending: false,
|
||||
edited: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('when the author is the different than the last message, should be appended as new message', function () {
|
||||
message.user = otherUser
|
||||
|
||||
const result = appendMessage(list, message, uniqueMessageIds).messages
|
||||
expect(result.length).to.equal(3)
|
||||
expect(result[2]).to.deep.equal({
|
||||
id: 'appended_message',
|
||||
timestamp: message.timestamp,
|
||||
user: message.user,
|
||||
content: message.content,
|
||||
pending: false,
|
||||
edited: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('editMessage()', function () {
|
||||
it('should edit an existing message', function () {
|
||||
const list = createTestMessageList()
|
||||
const messageId = 'msg_1'
|
||||
const newContent = 'edited content'
|
||||
|
||||
const result = editMessage(messageId, newContent, list)
|
||||
|
||||
expect(result.messages.length).to.equal(list.length)
|
||||
expect(result.messages[0]).to.deep.equal({
|
||||
id: messageId,
|
||||
content: newContent,
|
||||
timestamp: list[0].timestamp,
|
||||
user: list[0].user,
|
||||
edited: true,
|
||||
})
|
||||
expect(result.messages[1]).to.deep.equal(list[1])
|
||||
})
|
||||
|
||||
it('should throw an error if message is not found', function () {
|
||||
const list = createTestMessageList()
|
||||
const nonExistentId = 'non_existent_id'
|
||||
const newContent = 'edited content'
|
||||
|
||||
expect(() => {
|
||||
editMessage(nonExistentId, newContent, list)
|
||||
}).to.throw(`Message with id ${nonExistentId} not found in message list`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteMessage()', function () {
|
||||
it('should mark an existing message as deleted', function () {
|
||||
const list = createTestMessageList()
|
||||
const messageId = 'msg_1'
|
||||
|
||||
const result = deleteMessage(messageId, list)
|
||||
|
||||
expect(result.messages.length).to.equal(list.length)
|
||||
expect(result.messages[0]).to.deep.equal({
|
||||
id: messageId,
|
||||
content: list[0].content,
|
||||
timestamp: list[0].timestamp,
|
||||
user: list[0].user,
|
||||
deleted: true,
|
||||
})
|
||||
expect(result.messages[1]).to.deep.equal(list[1])
|
||||
})
|
||||
|
||||
it('should throw an error if message is not found', function () {
|
||||
const list = createTestMessageList()
|
||||
const nonExistentId = 'non_existent_id'
|
||||
|
||||
expect(() => {
|
||||
deleteMessage(nonExistentId, list)
|
||||
}).to.throw(`Message with id ${nonExistentId} not found in message list`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmMessage()', function () {
|
||||
function createMessageListWithPendingMessage(): Message[] {
|
||||
return [
|
||||
{
|
||||
id: 'msg_1',
|
||||
content: 'hello world',
|
||||
timestamp: new Date().getTime(),
|
||||
user: otherUser,
|
||||
},
|
||||
{
|
||||
id: 'temp_id',
|
||||
content: 'pending message',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
pending: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
it('should confirm a pending message and update its ID', function () {
|
||||
const list = createMessageListWithPendingMessage()
|
||||
const updatedMessage: Message = {
|
||||
id: 'server_id',
|
||||
content: 'pending message',
|
||||
timestamp: new Date().getTime() + 1000,
|
||||
user: testUser,
|
||||
}
|
||||
|
||||
const result = confirmMessage(updatedMessage, list)
|
||||
|
||||
expect(result.messages.length).to.equal(list.length)
|
||||
expect(result.messages[0]).to.deep.equal(list[0])
|
||||
expect(result.messages[1]).to.deep.equal({
|
||||
id: 'server_id',
|
||||
content: 'pending message',
|
||||
timestamp: updatedMessage.timestamp,
|
||||
user: testUser,
|
||||
pending: false,
|
||||
})
|
||||
expect(result.uniqueMessageIds).to.deep.equal(['msg_1', 'server_id'])
|
||||
})
|
||||
|
||||
it('should throw an error if pending message is not found', function () {
|
||||
const list = createTestMessageList() // No pending messages
|
||||
const updatedMessage: Message = {
|
||||
id: 'server_id',
|
||||
content: 'non-existent pending message',
|
||||
timestamp: new Date().getTime(),
|
||||
user: testUser,
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
confirmMessage(updatedMessage, list)
|
||||
}).to.throw("Couldn't find own message in local state")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user