Merge pull request #27790 from overleaf/mj-chat-typescript

[web] Convert remaining chat frontend and tests to typescript

GitOrigin-RevId: 6b2b485433e0a4530f00496e7ecdd49d9eb450af
This commit is contained in:
Mathias Jakobsen
2025-08-13 10:19:50 +01:00
committed by Copybot
parent 49ac21087b
commit 05bf74cf94
9 changed files with 136 additions and 75 deletions

View File

@@ -32,6 +32,10 @@ export type Message = {
user?: User
}
export type ServerMessageEntry = Omit<Message, 'contents'> & {
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'

View File

@@ -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)

View File

@@ -5,7 +5,8 @@ import { screen, render, fireEvent } from '@testing-library/react'
import MessageInput from '../../../../../frontend/js/features/chat/components/message-input'
describe('<MessageInput />', function () {
let resetUnreadMessages, sendMessage
let resetUnreadMessages: () => void
let sendMessage: (content: string) => void
beforeEach(function () {
resetUnreadMessages = sinon.stub()

View File

@@ -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('<MessageList />', 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('<MessageList />', 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('<MessageList />', function () {
render(
<UserProvider>
<MessageList
userId={currentUser.id}
messages={createMessages()}
resetUnreadMessages={() => {}}
/>
@@ -70,11 +71,7 @@ describe('<MessageList />', function () {
render(
<UserProvider>
<MessageList
userId={currentUser.id}
messages={msgs}
resetUnreadMessages={() => {}}
/>
<MessageList messages={msgs} resetUnreadMessages={() => {}} />
</UserProvider>
)
@@ -89,11 +86,7 @@ describe('<MessageList />', function () {
render(
<UserProvider>
<MessageList
userId={currentUser.id}
messages={msgs}
resetUnreadMessages={() => {}}
/>
<MessageList messages={msgs} resetUnreadMessages={() => {}} />
</UserProvider>
)
@@ -106,7 +99,6 @@ describe('<MessageList />', function () {
render(
<UserProvider>
<MessageList
userId={currentUser.id}
messages={createMessages()}
resetUnreadMessages={resetUnreadMessages}
/>

View File

@@ -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('<Message />', 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('<Message />', 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(<Message message={message} fromSelf />)
@@ -32,9 +36,11 @@ describe('<Message />', 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(<Message message={message} fromSelf />)
@@ -44,11 +50,13 @@ describe('<Message />', function () {
})
it('renders HTML links within messages', function () {
const message = {
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 />)
@@ -57,49 +65,56 @@ describe('<Message />', 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(<Message message={message} fromSelf />)
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(<Message message={message} />)
render(<Message message={message} fromSelf={false} />)
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(<Message message={msg} />)
render(<Message message={msg} fromSelf={false} />)
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)
})
})
})

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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()