diff --git a/services/web/frontend/js/features/chat/context/chat-context.tsx b/services/web/frontend/js/features/chat/context/chat-context.tsx index 2ef6fc9af3..fe6fe96afa 100644 --- a/services/web/frontend/js/features/chat/context/chat-context.tsx +++ b/services/web/frontend/js/features/chat/context/chat-context.tsx @@ -32,6 +32,10 @@ export type Message = { user?: User } +export type ServerMessageEntry = Omit & { + content: string +} + type State = { status: 'idle' | 'pending' | 'error' messages: Message[] @@ -52,16 +56,16 @@ type Action = } | { type: 'FETCH_MESSAGES_SUCCESS' - messages: Message[] + messages: ServerMessageEntry[] } | { type: 'SEND_MESSAGE' user: any - content: any + content: string } | { type: 'RECEIVE_MESSAGE' - message: any + message: ServerMessageEntry } | { type: 'MARK_MESSAGES_AS_READ' diff --git a/services/web/frontend/js/features/chat/utils/message-list-appender.js b/services/web/frontend/js/features/chat/utils/message-list-appender.ts similarity index 87% rename from services/web/frontend/js/features/chat/utils/message-list-appender.js rename to services/web/frontend/js/features/chat/utils/message-list-appender.ts index be7ed0cb37..26a18150c8 100644 --- a/services/web/frontend/js/features/chat/utils/message-list-appender.js +++ b/services/web/frontend/js/features/chat/utils/message-list-appender.ts @@ -1,6 +1,12 @@ +import { Message, ServerMessageEntry } from '../context/chat-context' + const TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 // 5 minutes -export function appendMessage(messageList, message, uniqueMessageIds) { +export function appendMessage( + messageList: Message[], + message: ServerMessageEntry, + uniqueMessageIds: string[] +) { if (uniqueMessageIds.includes(message.id)) { return { messages: messageList, uniqueMessageIds } } @@ -41,7 +47,11 @@ export function appendMessage(messageList, message, uniqueMessageIds) { return { messages: messageList, uniqueMessageIds } } -export function prependMessages(messageList, messages, uniqueMessageIds) { +export function prependMessages( + messageList: Message[], + messages: ServerMessageEntry[], + uniqueMessageIds: string[] +) { const listCopy = messageList.slice(0) uniqueMessageIds = uniqueMessageIds.slice(0) diff --git a/services/web/test/frontend/features/chat/components/chat-pane.test.jsx b/services/web/test/frontend/features/chat/components/chat-pane.test.tsx similarity index 100% rename from services/web/test/frontend/features/chat/components/chat-pane.test.jsx rename to services/web/test/frontend/features/chat/components/chat-pane.test.tsx diff --git a/services/web/test/frontend/features/chat/components/message-input.test.jsx b/services/web/test/frontend/features/chat/components/message-input.test.tsx similarity index 94% rename from services/web/test/frontend/features/chat/components/message-input.test.jsx rename to services/web/test/frontend/features/chat/components/message-input.test.tsx index 8db59fa96e..8e6d988cc2 100644 --- a/services/web/test/frontend/features/chat/components/message-input.test.jsx +++ b/services/web/test/frontend/features/chat/components/message-input.test.tsx @@ -5,7 +5,8 @@ import { screen, render, fireEvent } from '@testing-library/react' import MessageInput from '../../../../../frontend/js/features/chat/components/message-input' describe('', function () { - let resetUnreadMessages, sendMessage + let resetUnreadMessages: () => void + let sendMessage: (content: string) => void beforeEach(function () { resetUnreadMessages = sinon.stub() diff --git a/services/web/test/frontend/features/chat/components/message-list.test.jsx b/services/web/test/frontend/features/chat/components/message-list.test.tsx similarity index 86% rename from services/web/test/frontend/features/chat/components/message-list.test.jsx rename to services/web/test/frontend/features/chat/components/message-list.test.tsx index a1016edd60..f8cc896443 100644 --- a/services/web/test/frontend/features/chat/components/message-list.test.jsx +++ b/services/web/test/frontend/features/chat/components/message-list.test.tsx @@ -5,15 +5,17 @@ import { screen, render, fireEvent } from '@testing-library/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' describe('', function () { - const currentUser = { - id: 'fake_user', + const currentUser: User = { + id: 'fake_user' as UserId, first_name: 'fake_user_first_name', email: 'fake@example.com', } - function createMessages() { + function createMessages(): Message[] { return [ { id: '1', @@ -38,7 +40,7 @@ describe('', function () { tearDownMathJaxStubs() }) - let olUser + let olUser: User beforeEach(function () { olUser = window.metaAttributesCache.get('ol-user') window.metaAttributesCache.set('ol-user', currentUser) @@ -52,7 +54,6 @@ describe('', function () { render( {}} /> @@ -70,11 +71,7 @@ describe('', function () { render( - {}} - /> + {}} /> ) @@ -89,11 +86,7 @@ describe('', function () { render( - {}} - /> + {}} /> ) @@ -106,7 +99,6 @@ describe('', function () { render( diff --git a/services/web/test/frontend/features/chat/components/message.test.jsx b/services/web/test/frontend/features/chat/components/message.test.tsx similarity index 61% rename from services/web/test/frontend/features/chat/components/message.test.jsx rename to services/web/test/frontend/features/chat/components/message.test.tsx index a3b4574242..2106c74241 100644 --- a/services/web/test/frontend/features/chat/components/message.test.jsx +++ b/services/web/test/frontend/features/chat/components/message.test.tsx @@ -3,10 +3,12 @@ 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('', function () { - const currentUser = { - id: 'fake_user', + const currentUser: User = { + id: 'fake_user' as UserId, first_name: 'fake_user_first_name', email: 'fake@example.com', } @@ -21,9 +23,11 @@ describe('', function () { }) it('renders a basic message', function () { - const message = { + const message: MessageType = { contents: ['a message'], user: currentUser, + id: 'msg_1', + timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(), } render() @@ -32,9 +36,11 @@ describe('', function () { }) it('renders a message with multiple contents', function () { - const message = { + const message: MessageType = { contents: ['a message', 'another message'], user: currentUser, + id: 'msg_1', + timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(), } render() @@ -44,11 +50,13 @@ describe('', function () { }) it('renders HTML links within messages', function () { - const message = { + const message: MessageType = { contents: [ 'a message with a link to Overleaf', ], user: currentUser, + id: 'msg_1', + timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(), } render() @@ -57,49 +65,56 @@ describe('', function () { }) describe('when the message is from the user themselves', function () { - const message = { + 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() - expect(screen.queryByText(currentUser.first_name)).to.not.exist + 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 = { - id: 'other_user', + const otherUser: User = { + id: 'other_user' as UserId, first_name: 'other_user_first_name', + email: 'other@example.com', } - const message = { + 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() + render() - screen.getByText(otherUser.first_name) + screen.getByText(otherUser.first_name!) }) it('should render the other user email when their name is not available', function () { - const msg = { + 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() + render() - expect(screen.queryByText(otherUser.first_name)).to.not.exist - screen.getByText(msg.user.email) + expect(screen.queryByText(otherUser.first_name!)).to.not.exist + screen.getByText(msg.user!.email) }) }) }) diff --git a/services/web/test/frontend/features/chat/components/stubs.js b/services/web/test/frontend/features/chat/components/stubs.ts similarity index 76% rename from services/web/test/frontend/features/chat/components/stubs.js rename to services/web/test/frontend/features/chat/components/stubs.ts index 47b93b8b3b..3e2bac14ab 100644 --- a/services/web/test/frontend/features/chat/components/stubs.js +++ b/services/web/test/frontend/features/chat/components/stubs.ts @@ -10,5 +10,6 @@ export function stubMathJax() { } export function tearDownMathJaxStubs() { + // @ts-expect-error - this is a stub that we're setting ourselves per test delete window.MathJax } diff --git a/services/web/test/frontend/features/chat/context/chat-context.test.jsx b/services/web/test/frontend/features/chat/context/chat-context.test.tsx similarity index 90% rename from services/web/test/frontend/features/chat/context/chat-context.test.jsx rename to services/web/test/frontend/features/chat/context/chat-context.test.tsx index a930ba3a9c..550cef7c0a 100644 --- a/services/web/test/frontend/features/chat/context/chat-context.test.jsx +++ b/services/web/test/frontend/features/chat/context/chat-context.test.tsx @@ -8,14 +8,20 @@ import fetchMock from 'fetch-mock' import { useChatContext, chatClientIdGenerator, + ServerMessageEntry, } from '@/features/chat/context/chat-context' import { stubMathJax, tearDownMathJaxStubs } from '../components/stubs' import { SocketIOMock } from '@/ide/connection/SocketIoShim' -import { EditorProviders } from '../../../helpers/editor-providers' +import { + EditorProviders, + EditorProvidersProps, +} from '../../../helpers/editor-providers' +import { User, UserId } from '@ol-types/user' +import { Socket } from '@/features/ide-react/connection/types/socket' describe('ChatContext', function () { - const user = { - id: 'fake_user', + const user: User = { + id: 'fake_user' as UserId, first_name: 'fake_user_first_name', email: 'fake@example.com', } @@ -55,13 +61,17 @@ describe('ChatContext', function () { it('subscribes when mounted', function () { const socket = new SocketIOMock() - renderChatContextHook({ socket }) + renderChatContextHook({ + socket: socket as any as Socket, + }) expect(socket.countEventListeners('new-chat-message')).to.equal(1) }) it('unsubscribes when unmounted', function () { const socket = new SocketIOMock() - const { unmount } = renderChatContextHook({ socket }) + const { unmount } = renderChatContextHook({ + socket: socket as any as Socket, + }) unmount() @@ -72,7 +82,7 @@ describe('ChatContext', function () { // Mock socket: we only need to emit events, not mock actual connections const socket = new SocketIOMock() const { result } = renderChatContextHook({ - socket, + socket: socket as any as Socket, }) // Wait until initial messages have loaded @@ -107,7 +117,7 @@ describe('ChatContext', function () { // Mock socket: we only need to emit events, not mock actual connections const socket = new SocketIOMock() const { result } = renderChatContextHook({ - socket, + socket: socket as any as Socket, }) fetchMock.modifyRoute('fetchMessages', { @@ -158,7 +168,7 @@ describe('ChatContext', function () { // Mock socket: we only need to emit events, not mock actual connections const socket = new SocketIOMock() const { result } = renderChatContextHook({ - socket, + socket: socket as any as Socket, }) fetchMock.modifyRoute('fetchMessages', { @@ -205,7 +215,7 @@ describe('ChatContext', function () { it("doesn't add received messages from the current user if a message was just sent", async function () { const socket = new SocketIOMock() const { result } = renderChatContextHook({ - socket, + socket: socket as any as Socket, }) // Wait until initial messages have loaded @@ -239,7 +249,7 @@ describe('ChatContext', function () { it('adds the new message from the current user if another message was received after sending', async function () { const socket = new SocketIOMock() const { result } = renderChatContextHook({ - socket, + socket: socket as any as Socket, }) // Wait until initial messages have loaded @@ -411,7 +421,9 @@ describe('ChatContext', function () { // The before query param for the second request matches the timestamp // of the first message - const beforeParam = parseInt(getLastFetchMockQueryParam('before'), 10) + const beforeParamRaw = getLastFetchMockQueryParam('before') + expect(beforeParamRaw).to.not.be.null + const beforeParam = parseInt(beforeParamRaw!, 10) expect(beforeParam).to.equal(new Date('2021-03-04T10:00:00').getTime()) }) @@ -442,7 +454,7 @@ describe('ChatContext', function () { it('handles socket messages while loading', async function () { // Mock GET messages so that we can control when the promise is resolved - let resolveLoadingMessages + let resolveLoadingMessages: (messages: ServerMessageEntry[]) => void fetchMock.get( 'express:/project/:projectId/messages', new Promise(resolve => { @@ -452,7 +464,7 @@ describe('ChatContext', function () { const socket = new SocketIOMock() const { result } = renderChatContextHook({ - socket, + socket: socket as any as Socket, }) // Start loading messages @@ -472,7 +484,7 @@ describe('ChatContext', function () { }) // Resolve messages being loaded - resolveLoadingMessages([ + resolveLoadingMessages!([ { id: 'fetched_msg', content: 'loaded message', @@ -531,12 +543,19 @@ describe('ChatContext', function () { result.current.sendMessage('sent message') + const calls = fetchMock.callHistory.calls( + 'express:/project/:projectId/messages', + { method: 'POST' } + ) + expect(calls.length).to.be.greaterThanOrEqual(1) + const { options: { body }, - } = fetchMock.callHistory - .calls('express:/project/:projectId/messages', { method: 'POST' }) - .at(-1) - expect(JSON.parse(body)).to.deep.include({ content: 'sent message' }) + } = calls.at(-1)! + expect(body).to.not.be.undefined + expect(JSON.parse(body!.toString())).to.deep.include({ + content: 'sent message', + }) }) it("doesn't send if the content is empty", function () { @@ -574,7 +593,9 @@ describe('ChatContext', function () { it('increments unreadMessageCount when a new message is received', function () { const socket = new SocketIOMock() - const { result } = renderChatContextHook({ socket }) + const { result } = renderChatContextHook({ + socket: socket as any as Socket, + }) // Receive a new message from the socket act(() => { @@ -591,7 +612,9 @@ describe('ChatContext', function () { it('resets unreadMessageCount when markMessagesAsRead is called', function () { const socket = new SocketIOMock() - const { result } = renderChatContextHook({ socket }) + const { result } = renderChatContextHook({ + socket: socket as any as Socket, + }) // Receive a new message from the socket, incrementing unreadMessageCount // by 1 @@ -609,7 +632,7 @@ describe('ChatContext', function () { }) }) -function renderChatContextHook(props) { +function renderChatContextHook(props: EditorProvidersProps) { return renderHook(() => useChatContext(), { // Wrap with ChatContext.Provider (and the other editor context providers) // eslint-disable-next-line react/display-name @@ -619,7 +642,11 @@ function renderChatContextHook(props) { }) } -function createMessages(number, user, timestamp = Date.now()) { +function createMessages( + number: number, + user: User, + timestamp = Date.now() +): ServerMessageEntry[] { return Array.from({ length: number }, (_m, idx) => ({ id: `msg_${idx + 1}`, content: `message ${idx + 1}`, @@ -631,8 +658,12 @@ function createMessages(number, user, timestamp = Date.now()) { /* * Get query param by key from the last fetchMock response */ -function getLastFetchMockQueryParam(key) { - const { url } = fetchMock.callHistory.calls().at(-1) +function getLastFetchMockQueryParam(key: string) { + const calls = fetchMock.callHistory.calls() + if (calls.length === 0) { + throw new Error('No fetchMock calls found') + } + const { url } = calls.at(-1)! const { searchParams } = new URL(url, 'https://www.overleaf.com') return searchParams.get(key) } diff --git a/services/web/test/frontend/features/chat/util/message-list-appender.test.js b/services/web/test/frontend/features/chat/util/message-list-appender.test.tsx similarity index 90% rename from services/web/test/frontend/features/chat/util/message-list-appender.test.js rename to services/web/test/frontend/features/chat/util/message-list-appender.test.tsx index 0338f1a12a..894dab8639 100644 --- a/services/web/test/frontend/features/chat/util/message-list-appender.test.js +++ b/services/web/test/frontend/features/chat/util/message-list-appender.test.tsx @@ -3,16 +3,23 @@ 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 = { - id: '123abc', +const testUser: User = { + id: '123abc' as UserId, + email: 'test-user@example.com', } -const otherUser = { - id: '234other', +const otherUser: User = { + id: '234other' as UserId, + email: 'other-user@example.com', } -function createTestMessageList() { +function createTestMessageList(): Message[] { return [ { id: 'msg_1', @@ -30,7 +37,7 @@ function createTestMessageList() { } describe('prependMessages()', function () { - function createTestMessages() { + function createTestMessages(): ServerMessageEntry[] { const message1 = { id: 'prepended_message', content: 'hello', @@ -43,7 +50,7 @@ describe('prependMessages()', function () { it('to an empty list', function () { const messages = createTestMessages() - const uniqueMessageIds = [] + const uniqueMessageIds: string[] = [] expect( prependMessages([], messages, uniqueMessageIds).messages @@ -58,7 +65,7 @@ describe('prependMessages()', function () { }) describe('when the messages to prepend are from the same user', function () { - let list, messages, uniqueMessageIds + let list, messages: ServerMessageEntry[], uniqueMessageIds: string[] beforeEach(function () { list = createTestMessageList() @@ -106,7 +113,7 @@ describe('prependMessages()', function () { }) describe('when the messages to prepend are from different users', function () { - let list, messages, uniqueMessageIds + let list, messages: ServerMessageEntry[], uniqueMessageIds: string[] beforeEach(function () { list = createTestMessageList() @@ -141,7 +148,7 @@ describe('prependMessages()', function () { const list = createTestMessageList() const messages = createTestMessages() messages[0].user = messages[1].user = list[0].user - const uniqueMessageIds = [] + const uniqueMessageIds: string[] = [] const result = prependMessages( createTestMessageList(), @@ -170,7 +177,7 @@ describe('appendMessage()', function () { it('to an empty list', function () { const testMessage = createTestMessage() - const uniqueMessageIds = [] + const uniqueMessageIds: string[] = [] expect( appendMessage([], testMessage, uniqueMessageIds).messages @@ -185,7 +192,7 @@ describe('appendMessage()', function () { }) describe('messages appended shortly after the last message on the list', function () { - let list, message, uniqueMessageIds + let list: Message[], message: ServerMessageEntry, uniqueMessageIds: string[] beforeEach(function () { list = createTestMessageList() @@ -228,7 +235,7 @@ describe('appendMessage()', function () { }) describe('messages appended later after the last message on the list', function () { - let list, message, uniqueMessageIds + let list: Message[], message: ServerMessageEntry, uniqueMessageIds: string[] beforeEach(function () { list = createTestMessageList()