Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 21 additions & 17 deletions packages/api/src/EmbeddedChatApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import {
ApiError,
} from "@embeddedchat/auth";

// mutliple typing status can come at the same time they should be processed in order.
let typingHandlerLock = 0;
export default class EmbeddedChatApi {
host: string;
rid: string;
Expand Down Expand Up @@ -358,13 +356,7 @@ export default class EmbeddedChatApi {
typingUser: string;
isTyping: boolean;
}) {
// don't wait for more than 2 seconds. Though in practical, the waiting time is insignificant.
setTimeout(() => {
typingHandlerLock = 0;
}, 2000);
// eslint-disable-next-line no-empty
while (typingHandlerLock) {}
typingHandlerLock = 1;
// No lock needed — JS is single-threaded, so array operations are already atomic.
// move user to front if typing else remove it.
const idx = this.typingUsers.indexOf(typingUser);
if (idx !== -1) {
Expand All @@ -373,7 +365,6 @@ export default class EmbeddedChatApi {
if (isTyping) {
this.typingUsers.unshift(typingUser);
}
typingHandlerLock = 0;
const newTypingStatus = cloneArray(this.typingUsers);
this.onTypingStatusCallbacks.forEach((callback) =>
callback(newTypingStatus)
Expand Down Expand Up @@ -1065,17 +1056,21 @@ export default class EmbeddedChatApi {
"description",
fileDescription.length !== 0 ? fileDescription : ""
);
const response = fetch(`${this.host}/api/v1/rooms.upload/${this.rid}`, {
const response = await fetch(`${this.host}/api/v1/rooms.upload/${this.rid}`, {
method: "POST",
body: form,
headers: {
"X-Auth-Token": authToken,
"X-User-Id": userId,
},
}).then((r) => r.json());
return response;
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
return await response.json();
} catch (err) {
console.log(err);
console.error(err);
throw err;
}
}

Expand Down Expand Up @@ -1243,7 +1238,10 @@ export default class EmbeddedChatApi {
},
}
);
const data = response.json();
if (!response.ok) {
throw new Error(`getUserStatus failed: ${response.status} ${response.statusText}`);
}
Comment on lines +1241 to +1243
const data = await response.json();
return data;
}

Expand All @@ -1260,7 +1258,10 @@ export default class EmbeddedChatApi {
},
}
);
const data = response.json();
if (!response.ok) {
throw new Error(`userInfo failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
Comment on lines +1261 to +1264
return data;
}

Expand All @@ -1277,7 +1278,10 @@ export default class EmbeddedChatApi {
},
}
);
const data = response.json();
if (!response.ok) {
throw new Error(`userData failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
Comment on lines +1281 to +1284
return data;
}
}
16 changes: 10 additions & 6 deletions packages/react/babel.config.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
const isTest = process.env.NODE_ENV === 'test';

module.exports = {
presets: [
[
'@babel/preset-env',
{
modules: false,
bugfixes: true,
targets: { browsers: '> 0.25%, ie 11, not op_mini all, not dead' },
},
isTest
? { targets: { node: 'current' } }
: {
modules: false,
bugfixes: true,
targets: { browsers: '> 0.25%, ie 11, not op_mini all, not dead' },
},
],
'@babel/preset-react',
'@emotion/babel-preset-css-prop',
...(isTest ? [] : ['@emotion/babel-preset-css-prop']),
],
};
11 changes: 11 additions & 0 deletions packages/react/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = {
testEnvironment: 'jsdom',
// Allow Jest to transform ESM packages that ship /dist/esm/ builds
transformIgnorePatterns: [
'/node_modules/(?!(react-syntax-highlighter)/)',
],
moduleNameMapper: {
// Silence CSS/asset imports that aren't relevant to unit tests
'\\.(css|scss|sass)$': 'identity-obj-proxy',
},
};
190 changes: 190 additions & 0 deletions packages/react/src/hooks/__tests__/useChatInputState.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import React from 'react';
import { render, act } from '@testing-library/react';
import useChatInputState from '../useChatInputState';

// ---------------------------------------------------------------------------
// Helper: renders the hook inside a minimal component and returns a live ref
// to the hook's return value. Works with @testing-library/react v12 which
// does not expose renderHook.
//
// IMPORTANT: always read `result.current` AFTER act() — never destructure
// `current` up front, because re-renders reassign `result.current` to the
// new hook return value while a destructured copy keeps pointing to the old
// object.
// ---------------------------------------------------------------------------
function renderHookShim() {
const result = { current: null };

const TestComponent = () => {
result.current = useChatInputState();
return null;
};

render(<TestComponent />);
return result;
}

// ---------------------------------------------------------------------------
// 1. Initial state
// ---------------------------------------------------------------------------
describe('useChatInputState – initial state', () => {
it('starts with empty text and zero cursor position', () => {
const result = renderHookShim();
expect(result.current.inputState.text).toBe('');
expect(result.current.inputState.cursorPosition).toBe(0);
});
});

// ---------------------------------------------------------------------------
// 2. setText
// ---------------------------------------------------------------------------
describe('useChatInputState – setText', () => {
it('updates the text field', () => {
const result = renderHookShim();
act(() => result.current.setText('hello world'));
expect(result.current.inputState.text).toBe('hello world');
});

it('does not change cursor position when only text is set', () => {
const result = renderHookShim();
act(() => result.current.setText('some text'));
expect(result.current.inputState.cursorPosition).toBe(0);
});
});

// ---------------------------------------------------------------------------
// 3. setCursorPosition
// ---------------------------------------------------------------------------
describe('useChatInputState – setCursorPosition', () => {
it('updates cursor position', () => {
const result = renderHookShim();
act(() => result.current.setCursorPosition(5));
expect(result.current.inputState.cursorPosition).toBe(5);
});

it('does not change text when only cursor is updated', () => {
const result = renderHookShim();
act(() => result.current.setText('hello'));
act(() => result.current.setCursorPosition(3));
expect(result.current.inputState.text).toBe('hello');
expect(result.current.inputState.cursorPosition).toBe(3);
});
});

// ---------------------------------------------------------------------------
// 4. clearInput
// ---------------------------------------------------------------------------
describe('useChatInputState – clearInput', () => {
it('resets text and cursorPosition to initial values', () => {
const result = renderHookShim();
act(() => result.current.setText('typing something'));
act(() => result.current.setCursorPosition(8));
act(() => result.current.clearInput());
expect(result.current.inputState.text).toBe('');
expect(result.current.inputState.cursorPosition).toBe(0);
});
});

// ---------------------------------------------------------------------------
// 5. getFinalMarkdown – the core bug fix
//
// The old code mutated `quotedMessages` inside Promise.all.map() and then
// joined the cumulative array, producing duplicated link prefixes for every
// quote after the first. These tests verify the new behaviour is correct.
// ---------------------------------------------------------------------------
describe('useChatInputState – getFinalMarkdown', () => {
const makeLinkFn = (map) => (id) => Promise.resolve(map[id]);

it('returns plain text when there are no quotes', async () => {
const hookRef = renderHookShim();
const result = await hookRef.current.getFinalMarkdown('hello', [], makeLinkFn({}));
expect(result).toBe('hello');
});

it('returns plain text when quotes array is null/undefined', async () => {
const hookRef = renderHookShim();
const result = await hookRef.current.getFinalMarkdown(
'hello',
null,
makeLinkFn({})
);
expect(result).toBe('hello');
});

it('prepends a single quote link separated by a newline', async () => {
const hookRef = renderHookShim();
const quotes = [{ _id: 'msg1', msg: 'original message', attachments: undefined }];
const linkMap = { msg1: 'https://host/channel/general/?msg=msg1' };

const result = await hookRef.current.getFinalMarkdown(
'my reply',
quotes,
makeLinkFn(linkMap)
);

expect(result).toBe(
'[ ](https://host/channel/general/?msg=msg1)\nmy reply'
);
});

it('prepends multiple quote links WITHOUT duplication (old bug regression)', async () => {
// OLD behaviour: link1 was emitted twice → "[ ](link1)[ ](link1)[ ](link2)\ntext"
// NEW behaviour: each link appears exactly once → "[ ](link1)[ ](link2)\ntext"
const hookRef = renderHookShim();
const quotes = [
{ _id: 'msg1', msg: 'first quoted', attachments: undefined },
{ _id: 'msg2', msg: 'second quoted', attachments: undefined },
];
const linkMap = {
msg1: 'https://host/channel/general/?msg=msg1',
msg2: 'https://host/channel/general/?msg=msg2',
};

const result = await hookRef.current.getFinalMarkdown(
'my reply',
quotes,
makeLinkFn(linkMap)
);

expect(result).toBe(
'[ ](https://host/channel/general/?msg=msg1)' +
'[ ](https://host/channel/general/?msg=msg2)' +
'\nmy reply'
);
// Ensure first link does NOT appear twice (the old bug)
const occurrences = (result.match(/msg1/g) || []).length;
expect(occurrences).toBe(1);
});

it('skips quotes that have neither msg nor attachments', async () => {
const hookRef = renderHookShim();
const quotes = [
{ _id: 'msg1', msg: undefined, attachments: undefined },
{ _id: 'msg2', msg: 'valid message', attachments: undefined },
];
const linkMap = { msg2: 'https://host/channel/general/?msg=msg2' };

const result = await hookRef.current.getFinalMarkdown(
'reply',
quotes,
makeLinkFn(linkMap)
);

// Only msg2 link should appear; msg1 had no content so it's skipped
expect(result).toBe('[ ](https://host/channel/general/?msg=msg2)\nreply');
expect(result).not.toContain('msg1');
});

it('returns plain text if all quotes have no msg or attachments', async () => {
const hookRef = renderHookShim();
const quotes = [{ _id: 'msg1', msg: undefined, attachments: undefined }];

const result = await hookRef.current.getFinalMarkdown(
'just text',
quotes,
makeLinkFn({})
);

expect(result).toBe('just text');
});
});
Loading