@micha.bigler/ui-core-micha 2.15.0 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/WORK_ORDERS.md +6 -1
- package/dev/entries.jsx +151 -0
- package/dev/fixtures.js +26 -0
- package/dev/main.jsx +56 -0
- package/dev/mockTransport.jsx +128 -0
- package/dist/i18n/messagingTranslations.js +107 -0
- package/dist/index.js +15 -0
- package/dist/messaging/AttachmentList.js +43 -0
- package/dist/messaging/Composer.js +156 -0
- package/dist/messaging/ConversationLaunchers.js +39 -0
- package/dist/messaging/ConversationList.js +82 -0
- package/dist/messaging/DirectMessageLauncher.js +48 -0
- package/dist/messaging/MessageBubble.js +97 -0
- package/dist/messaging/MessagingProvider.js +363 -0
- package/dist/messaging/MessagingScopeConfig.js +28 -0
- package/dist/messaging/PollCard.js +53 -0
- package/dist/messaging/ReactionBar.js +44 -0
- package/dist/messaging/ReadTicks.js +28 -0
- package/dist/messaging/Thread.js +110 -0
- package/dist/messaging/api.js +51 -0
- package/dist/messaging/composerImageCompression.js +36 -0
- package/dist/notifications/NotificationsProvider.js +2 -2
- package/dist/notifications/realtime.js +21 -1
- package/docs/messaging-deviations.md +101 -0
- package/index.html +12 -0
- package/package.json +4 -1
- package/src/i18n/messagingTranslations.ts +107 -0
- package/src/index.js +16 -0
- package/src/messaging/AttachmentList.jsx +38 -0
- package/src/messaging/Composer.jsx +156 -0
- package/src/messaging/ConversationLaunchers.jsx +51 -0
- package/src/messaging/ConversationList.jsx +84 -0
- package/src/messaging/DirectMessageLauncher.jsx +96 -0
- package/src/messaging/MessageBubble.jsx +104 -0
- package/src/messaging/MessagingProvider.jsx +395 -0
- package/src/messaging/MessagingScopeConfig.jsx +24 -0
- package/src/messaging/PollCard.jsx +36 -0
- package/src/messaging/ReactionBar.jsx +46 -0
- package/src/messaging/ReadTicks.jsx +25 -0
- package/src/messaging/Thread.jsx +102 -0
- package/src/messaging/api.js +48 -0
- package/src/messaging/composerImageCompression.js +35 -0
- package/src/notifications/NotificationsProvider.jsx +2 -2
- package/src/notifications/realtime.jsx +21 -1
- package/tests/ConversationList.test.jsx +66 -0
- package/tests/MessagingProvider.test.jsx +50 -0
- package/tests/Thread.test.jsx +100 -0
- package/tests/messagingApi.test.js +32 -0
- package/tests/messagingChunk6.test.jsx +109 -0
- package/tests/messagingComposer.test.jsx +111 -0
- package/tests/messagingComposerChunk5.test.jsx +110 -0
- package/tests/messagingContractConformance.test.js +42 -0
- package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
- package/tests/messagingExports.test.js +21 -0
- package/tests/messagingInteractions.test.jsx +112 -0
- package/tests/messagingMessageActions.test.jsx +93 -0
- package/tests/messagingThreadingQuoting.test.jsx +86 -0
- package/tests/messagingUnreadLifecycle.test.jsx +63 -0
- package/tests/realtime.test.jsx +27 -0
- package/vite.config.mjs +6 -0
- package/work-orders/DX-1.md +166 -0
- package/work-orders/MSG-3.md +375 -0
- package/work-orders/MSG-3b.md +1002 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import apiClient from '../auth/apiClient';
|
|
2
|
+
|
|
3
|
+
const BASE_URL = '/api/messaging/';
|
|
4
|
+
const conversationsUrl = `${BASE_URL}conversations/`;
|
|
5
|
+
|
|
6
|
+
function data(response) {
|
|
7
|
+
return response.data;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function requestConfig({ cursor, params, headers, onUploadProgress } = {}) {
|
|
11
|
+
return {
|
|
12
|
+
...(params ? { params } : {}),
|
|
13
|
+
...(cursor ? { params: { ...(params || {}), cursor } } : {}),
|
|
14
|
+
...(headers ? { headers } : {}),
|
|
15
|
+
...(onUploadProgress ? { onUploadProgress } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function listConversations({ cursor, ...filters } = {}) {
|
|
20
|
+
return apiClient.get(conversationsUrl, requestConfig({ cursor, params: filters })).then(data);
|
|
21
|
+
}
|
|
22
|
+
export function createDirectConversation(payload) { return apiClient.post(`${conversationsUrl}direct/`, payload).then(data); }
|
|
23
|
+
export function createGroupConversation(payload) { return apiClient.post(`${conversationsUrl}group/`, payload).then(data); }
|
|
24
|
+
export function createBroadcastConversation(payload) { return apiClient.post(`${conversationsUrl}broadcast/`, payload).then(data); }
|
|
25
|
+
export function createManagedConversation(payload) { return apiClient.post(`${conversationsUrl}managed/`, payload).then(data); }
|
|
26
|
+
export function createObjectThreadConversation(payload) { return apiClient.post(`${conversationsUrl}object-thread/`, payload).then(data); }
|
|
27
|
+
export function getConversationConfig(conversationId) { return apiClient.get(`${conversationsUrl}${conversationId}/config/`).then(data); }
|
|
28
|
+
export function patchConversationConfig(conversationId, patch) { return apiClient.patch(`${conversationsUrl}${conversationId}/config/`, patch).then(data); }
|
|
29
|
+
export function archiveConversation(conversationId, archived) { return apiClient.post(`${conversationsUrl}${conversationId}/archive/`, { archived }).then(data); }
|
|
30
|
+
export function patchConversationPreferences(conversationId, patch) { return apiClient.post(`${conversationsUrl}${conversationId}/preferences/`, patch).then(data); }
|
|
31
|
+
export function listMessages(conversationId, { cursor } = {}) { return apiClient.get(`${conversationsUrl}${conversationId}/messages/`, requestConfig({ cursor })).then(data); }
|
|
32
|
+
export function createMessage(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/messages/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
|
|
33
|
+
export function uploadAttachments(conversationId, formData, { idempotencyKey, onUploadProgress } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/attachments/`, formData, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined, onUploadProgress })).then(data); }
|
|
34
|
+
export function createPoll(conversationId, payload, { idempotencyKey } = {}) { return apiClient.post(`${conversationsUrl}${conversationId}/polls/`, payload, requestConfig({ headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined })).then(data); }
|
|
35
|
+
export function getMessage(messageId) { return apiClient.get(`${BASE_URL}messages/${messageId}/`).then(data); }
|
|
36
|
+
export function patchMessage(messageId, patch) { return apiClient.patch(`${BASE_URL}messages/${messageId}/`, patch).then(data); }
|
|
37
|
+
export function deleteMessage(messageId) { return apiClient.delete(`${BASE_URL}messages/${messageId}/`).then(data); }
|
|
38
|
+
export function addReaction(messageId, emoji) { return apiClient.post(`${BASE_URL}messages/${messageId}/reactions/`, { emoji }).then(data); }
|
|
39
|
+
export function removeReaction(messageId, emoji) { return apiClient.delete(`${BASE_URL}messages/${messageId}/reactions/${encodeURIComponent(emoji)}/`).then(data); }
|
|
40
|
+
export function listThread(rootId, { cursor } = {}) { return apiClient.get(`${BASE_URL}messages/${rootId}/thread/`, requestConfig({ cursor })).then(data); }
|
|
41
|
+
export function markThreadRead(rootId, readAt) { return apiClient.post(`${BASE_URL}messages/${rootId}/thread/read/`, readAt ? { read_at: readAt } : {}).then(data); }
|
|
42
|
+
export function markConversationRead(conversationId, readAt) { return apiClient.post(`${conversationsUrl}${conversationId}/read/`, readAt ? { read_at: readAt } : {}).then(data); }
|
|
43
|
+
export function getReadStatus(messageId) { return apiClient.get(`${BASE_URL}messages/${messageId}/read-status/`).then(data); }
|
|
44
|
+
export function votePoll(pollId, optionIds) { return apiClient.post(`${BASE_URL}polls/${pollId}/vote/`, { option_ids: optionIds }).then(data); }
|
|
45
|
+
export function closePoll(pollId) { return apiClient.post(`${BASE_URL}polls/${pollId}/close/`).then(data); }
|
|
46
|
+
export function getAttachment(attachmentId) { return apiClient.get(`${BASE_URL}attachments/${attachmentId}/`, { responseType: 'blob' }).then(data); }
|
|
47
|
+
export function getAttachmentThumbnail(attachmentId) { return apiClient.get(`${BASE_URL}attachments/${attachmentId}/thumbnail/`, { responseType: 'blob' }).then(data); }
|
|
48
|
+
export function getUnreadCount() { return apiClient.get(`${BASE_URL}unread-count/`).then(data); }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
2
|
+
const MAX_IMAGE_EDGE = 2560;
|
|
3
|
+
|
|
4
|
+
function imageDimensions(file) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const url = URL.createObjectURL(file);
|
|
7
|
+
const image = new Image();
|
|
8
|
+
image.onload = () => { URL.revokeObjectURL(url); resolve(image); };
|
|
9
|
+
image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Unable to decode image')); };
|
|
10
|
+
image.src = url;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function canvasBlob(canvas) {
|
|
15
|
+
return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Compresses large images opportunistically; callers always receive a sendable file. */
|
|
19
|
+
export async function compressImageForUpload(file) {
|
|
20
|
+
if (!file?.type?.startsWith('image/') || file.size <= MAX_IMAGE_BYTES) return file;
|
|
21
|
+
try {
|
|
22
|
+
const image = await imageDimensions(file);
|
|
23
|
+
const largestEdge = Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height);
|
|
24
|
+
if (!largestEdge || largestEdge <= MAX_IMAGE_EDGE) return file;
|
|
25
|
+
const scale = MAX_IMAGE_EDGE / largestEdge;
|
|
26
|
+
const canvas = document.createElement('canvas');
|
|
27
|
+
canvas.width = Math.round((image.naturalWidth || image.width) * scale);
|
|
28
|
+
canvas.height = Math.round((image.naturalHeight || image.height) * scale);
|
|
29
|
+
canvas.getContext('2d')?.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
30
|
+
const blob = await canvasBlob(canvas);
|
|
31
|
+
return blob ? new File([blob], file.name.replace(/\.[^.]+$/, '') + '.jpg', { type: 'image/jpeg', lastModified: file.lastModified }) : file;
|
|
32
|
+
} catch {
|
|
33
|
+
return file;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -89,7 +89,7 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
89
89
|
// arrived and patched state before the initial REST response landed.
|
|
90
90
|
const [seeded, setSeeded] = useState(false);
|
|
91
91
|
|
|
92
|
-
const { subscribe } = useRealtimeCore({
|
|
92
|
+
const { subscribe, onReconnect } = useRealtimeCore({
|
|
93
93
|
active: authenticated && seeded,
|
|
94
94
|
wsUrl: getWebSocketUrl(wsUrlBase),
|
|
95
95
|
});
|
|
@@ -200,7 +200,7 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
200
200
|
const markDone = useCallback((ids) => mark('done', ids), [mark]);
|
|
201
201
|
|
|
202
202
|
return (
|
|
203
|
-
<RealtimeContext.Provider value={{ subscribe }}>
|
|
203
|
+
<RealtimeContext.Provider value={{ subscribe, onReconnect }}>
|
|
204
204
|
<NotificationsContext.Provider value={{
|
|
205
205
|
notifications,
|
|
206
206
|
unreadCount,
|
|
@@ -33,6 +33,7 @@ export { RealtimeContext };
|
|
|
33
33
|
export function useRealtimeCore({ active, wsUrl }) {
|
|
34
34
|
const socketRef = useRef(null);
|
|
35
35
|
const subscribersRef = useRef(new Map());
|
|
36
|
+
const reconnectSubscribersRef = useRef(new Set());
|
|
36
37
|
|
|
37
38
|
const subscribe = useCallback((envelope, handler) => {
|
|
38
39
|
const subscribers = subscribersRef.current;
|
|
@@ -50,12 +51,21 @@ export function useRealtimeCore({ active, wsUrl }) {
|
|
|
50
51
|
};
|
|
51
52
|
}, []);
|
|
52
53
|
|
|
54
|
+
// Additive lifecycle signal for domains whose REST projections must be
|
|
55
|
+
// refreshed after the one shared transport reconnects. It deliberately does
|
|
56
|
+
// not alter the socket/backoff ownership or the envelope subscriber API.
|
|
57
|
+
const onReconnect = useCallback((handler) => {
|
|
58
|
+
reconnectSubscribersRef.current.add(handler);
|
|
59
|
+
return () => reconnectSubscribersRef.current.delete(handler);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
53
62
|
useEffect(() => {
|
|
54
63
|
if (!active) return undefined;
|
|
55
64
|
|
|
56
65
|
let isActive = true;
|
|
57
66
|
let reconnectTimer = null;
|
|
58
67
|
let backoffMs = INITIAL_BACKOFF_MS;
|
|
68
|
+
let hasConnected = false;
|
|
59
69
|
|
|
60
70
|
const handleMessage = (event) => {
|
|
61
71
|
let data;
|
|
@@ -85,6 +95,16 @@ export function useRealtimeCore({ active, wsUrl }) {
|
|
|
85
95
|
|
|
86
96
|
socket.onopen = () => {
|
|
87
97
|
backoffMs = INITIAL_BACKOFF_MS;
|
|
98
|
+
if (hasConnected) {
|
|
99
|
+
reconnectSubscribersRef.current.forEach((handler) => {
|
|
100
|
+
try {
|
|
101
|
+
handler();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error('Realtime reconnect subscriber error', error);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
hasConnected = true;
|
|
88
108
|
};
|
|
89
109
|
socket.onmessage = handleMessage;
|
|
90
110
|
socket.onerror = () => {
|
|
@@ -114,5 +134,5 @@ export function useRealtimeCore({ active, wsUrl }) {
|
|
|
114
134
|
};
|
|
115
135
|
}, [active, wsUrl]);
|
|
116
136
|
|
|
117
|
-
return { subscribe };
|
|
137
|
+
return { subscribe, onReconnect };
|
|
118
138
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) }));
|
|
7
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
|
|
8
|
+
|
|
9
|
+
import { ConversationList } from '../src/messaging/ConversationList';
|
|
10
|
+
import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
11
|
+
|
|
12
|
+
function renderList(api, props = {}) {
|
|
13
|
+
return render(<MessagingProvider api={api}><ConversationList onOpen={props.onOpen} {...props} /></MessagingProvider>);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('ConversationList', () => {
|
|
17
|
+
let api;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
api = {
|
|
20
|
+
listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, title: 'First', unread_count: 2, last_message: { body: 'Hello' } }], next_cursor: 'opaque-cursor' }),
|
|
21
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 2, by_conversation: { 1: 2 } }),
|
|
22
|
+
listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
23
|
+
archiveConversation: vi.fn().mockResolvedValue({ id: 1, archived: true }),
|
|
24
|
+
patchConversationPreferences: vi.fn().mockResolvedValue({ id: 1, muted: true }),
|
|
25
|
+
createGroupConversation: vi.fn().mockResolvedValue({ id: 4, title: 'Volunteers', kind: 'group' }),
|
|
26
|
+
createBroadcastConversation: vi.fn().mockResolvedValue({ id: 5, title: 'Announcements', kind: 'broadcast' }),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
afterEach(cleanup);
|
|
30
|
+
|
|
31
|
+
it('loads the next opaque cursor page without offset assumptions', async () => {
|
|
32
|
+
api.listConversations.mockResolvedValueOnce({ results: [{ id: 1, title: 'First' }], next_cursor: 'opaque-cursor' }).mockResolvedValueOnce({ results: [{ id: 2, title: 'Second' }], next_cursor: null });
|
|
33
|
+
renderList(api);
|
|
34
|
+
await screen.findByText('First');
|
|
35
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingList.LOAD_MORE' }));
|
|
36
|
+
await screen.findByText('Second');
|
|
37
|
+
// Append, not replace: the first page's item must still be present.
|
|
38
|
+
expect(screen.getByText('First')).toBeTruthy();
|
|
39
|
+
expect(api.listConversations).toHaveBeenLastCalledWith({ cursor: 'opaque-cursor' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('opens host-provided group and broadcast launchers into normalized list state', async () => {
|
|
43
|
+
const onOpen = vi.fn();
|
|
44
|
+
renderList(api, { onOpen, groupLaunchers: [{ id: 'volunteers', label: 'Volunteers', payload: { title: 'Volunteers' } }], broadcastLauncher: { label: 'Announcements', payload: {} } });
|
|
45
|
+
await screen.findByText('First');
|
|
46
|
+
fireEvent.click(screen.getByRole('button', { name: 'Volunteers' }));
|
|
47
|
+
await waitFor(() => expect(api.createGroupConversation).toHaveBeenCalledWith({ title: 'Volunteers' }));
|
|
48
|
+
expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: 4 }));
|
|
49
|
+
fireEvent.click(screen.getByRole('button', { name: 'Announcements' }));
|
|
50
|
+
await waitFor(() => expect(api.createBroadcastConversation).toHaveBeenCalled());
|
|
51
|
+
// Once opened it becomes a normal (unread/preview-bearing) list row; the
|
|
52
|
+
// static launcher button must not keep showing it a second time.
|
|
53
|
+
await waitFor(() => expect(screen.queryByRole('button', { name: 'Announcements' })).toBeNull());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('wires mute and archive through participant APIs', async () => {
|
|
57
|
+
renderList(api);
|
|
58
|
+
await screen.findByText('First');
|
|
59
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingList.ACTIONS' }));
|
|
60
|
+
fireEvent.click(screen.getByText('MessagingList.MUTE'));
|
|
61
|
+
await waitFor(() => expect(api.patchConversationPreferences).toHaveBeenCalledWith(1, { muted: true }));
|
|
62
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingList.ACTIONS' }));
|
|
63
|
+
fireEvent.click(screen.getByText('MessagingList.ARCHIVE'));
|
|
64
|
+
await waitFor(() => expect(api.archiveConversation).toHaveBeenCalledWith(1, true));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
import { RealtimeContext } from '../src/notifications/realtime';
|
|
7
|
+
import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
8
|
+
|
|
9
|
+
function Harness() { const { cache } = useMessaging(); return <output data-testid="cache">{JSON.stringify(cache)}</output>; }
|
|
10
|
+
function makeTransport() {
|
|
11
|
+
const handlers = new Set(); const reconnectHandlers = new Set();
|
|
12
|
+
return { subscribe: vi.fn((_envelope, handler) => { handlers.add(handler); return () => handlers.delete(handler); }), onReconnect: vi.fn((handler) => { reconnectHandlers.add(handler); return () => reconnectHandlers.delete(handler); }), frame: (frame) => handlers.forEach((handler) => handler(frame)), reconnect: () => reconnectHandlers.forEach((handler) => handler()) };
|
|
13
|
+
}
|
|
14
|
+
function renderProvider({ transport = makeTransport(), api, activeConversationId = 1, active } = {}) {
|
|
15
|
+
const props = { api, activeConversationId, ...(active === undefined ? {} : { active }) };
|
|
16
|
+
render(<RealtimeContext.Provider value={transport}><MessagingProvider {...props}><Harness /></MessagingProvider></RealtimeContext.Provider>);
|
|
17
|
+
return transport;
|
|
18
|
+
}
|
|
19
|
+
const cache = () => JSON.parse(screen.getByTestId('cache').textContent);
|
|
20
|
+
|
|
21
|
+
describe('MessagingProvider', () => {
|
|
22
|
+
it('deduplicates repeated event IDs and only changes the affected message slice', async () => {
|
|
23
|
+
const api = { listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1 }, { id: 2 }] }), listMessages: vi.fn().mockResolvedValue({ results: [{ id: 10, conversation_id: 2, body: 'unchanged' }] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }) };
|
|
24
|
+
const transport = renderProvider({ api }); await waitFor(() => expect(api.listConversations).toHaveBeenCalled());
|
|
25
|
+
const frame = { envelope: 'messaging', type: 'message', event_id: 'once', conversation_id: 1, message: { id: 11, body: 'new' } };
|
|
26
|
+
act(() => { transport.frame(frame); transport.frame(frame); });
|
|
27
|
+
expect(Object.keys(cache().messages)).toEqual(['10', '11']);
|
|
28
|
+
expect(cache().messages['10'].body).toBe('unchanged');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('refetches list, active thread and unread state when Layer 1 reconnects', async () => {
|
|
32
|
+
const api = { listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }) };
|
|
33
|
+
const transport = renderProvider({ api }); await waitFor(() => expect(api.listMessages).toHaveBeenCalledTimes(1));
|
|
34
|
+
await act(async () => transport.reconnect());
|
|
35
|
+
await waitFor(() => { expect(api.listConversations).toHaveBeenCalledTimes(2); expect(api.listMessages).toHaveBeenLastCalledWith(1); expect(api.getUnreadCount).toHaveBeenCalledTimes(2); });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not call the authenticated-only REST surface while active=false, and starts fetching once it flips true', async () => {
|
|
39
|
+
const api = { listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }) };
|
|
40
|
+
const transport = makeTransport();
|
|
41
|
+
const { rerender } = render(<RealtimeContext.Provider value={transport}><MessagingProvider api={api} activeConversationId={1} active={false}><Harness /></MessagingProvider></RealtimeContext.Provider>);
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
43
|
+
expect(api.listConversations).not.toHaveBeenCalled();
|
|
44
|
+
expect(api.listMessages).not.toHaveBeenCalled();
|
|
45
|
+
expect(api.getUnreadCount).not.toHaveBeenCalled();
|
|
46
|
+
|
|
47
|
+
rerender(<RealtimeContext.Provider value={transport}><MessagingProvider api={api} activeConversationId={1} active><Harness /></MessagingProvider></RealtimeContext.Provider>);
|
|
48
|
+
await waitFor(() => expect(api.listConversations).toHaveBeenCalledTimes(1));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count != null ? `${key}:${options.count}` : key }) }));
|
|
7
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
|
|
8
|
+
|
|
9
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
10
|
+
import { MessageBubble } from '../src/messaging/MessageBubble';
|
|
11
|
+
import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
12
|
+
import { ReadTicks } from '../src/messaging/ReadTicks';
|
|
13
|
+
import { Thread } from '../src/messaging/Thread';
|
|
14
|
+
|
|
15
|
+
function makeApi() {
|
|
16
|
+
return {
|
|
17
|
+
listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, kind: 'group' }] }),
|
|
18
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
19
|
+
listMessages: vi.fn().mockResolvedValue({ results: [{ id: 2, conversation_id: 1, body: 'Visible message', created_at: '2026-07-31T10:00:00Z' }], next_cursor: 'signed-opaque-cursor' }),
|
|
20
|
+
listThread: vi.fn().mockResolvedValue({ results: [] }),
|
|
21
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2 }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function renderThread(api, { userId = 9 } = {}) {
|
|
25
|
+
return render(
|
|
26
|
+
<AuthContext.Provider value={{ user: { id: userId } }}>
|
|
27
|
+
<MessagingProvider api={api} activeConversationId={1}><Thread conversationId={1} /></MessagingProvider>
|
|
28
|
+
</AuthContext.Provider>,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('messaging Thread surfaces', () => {
|
|
33
|
+
let api;
|
|
34
|
+
beforeEach(() => { api = makeApi(); });
|
|
35
|
+
afterEach(cleanup);
|
|
36
|
+
|
|
37
|
+
it('uses the opaque message cursor and shows a rejected older page as an error', async () => {
|
|
38
|
+
api.listMessages.mockResolvedValueOnce({ results: [{ id: 2, conversation_id: 1, body: 'Visible message' }], next_cursor: 'signed-opaque-cursor' }).mockRejectedValueOnce(new Error('bad cursor'));
|
|
39
|
+
renderThread(api);
|
|
40
|
+
await screen.findByText('Visible message');
|
|
41
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingThread.LOAD_OLDER' }));
|
|
42
|
+
await screen.findByRole('alert');
|
|
43
|
+
expect(api.listMessages).toHaveBeenLastCalledWith(1, { cursor: 'signed-opaque-cursor' });
|
|
44
|
+
expect(screen.getByText('Visible message')).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders deleted content as a redacted placeholder', () => {
|
|
48
|
+
render(<MessageBubble message={{ id: 4, body: 'Stale secret', deleted_at: '2026-07-31T10:00:00Z' }} />);
|
|
49
|
+
expect(screen.getByText('MessagingThread.DELETED')).toBeTruthy();
|
|
50
|
+
expect(screen.queryByText('Stale secret')).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('renders aggregate read state and never exposes supplied recipient details for a direct conversation', async () => {
|
|
54
|
+
api.getReadStatus.mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Private recipient' }] });
|
|
55
|
+
render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'direct' }} /></MessagingProvider>);
|
|
56
|
+
await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
|
|
57
|
+
expect(screen.queryByText('Private recipient')).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('renders supplied recipient detail for a non-direct conversation (the permitted case)', async () => {
|
|
61
|
+
api.getReadStatus.mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Visible recipient' }] });
|
|
62
|
+
render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
|
|
63
|
+
const tick = await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
|
|
64
|
+
fireEvent.click(tick);
|
|
65
|
+
await screen.findByText('Visible recipient');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('only mounts ReadTicks (and its REST call) for the current user\'s own messages, not every message in the timeline', async () => {
|
|
69
|
+
api.listMessages.mockResolvedValue({
|
|
70
|
+
results: [
|
|
71
|
+
{ id: 2, conversation_id: 1, body: 'Mine', created_at: '2026-07-31T10:00:00Z', sender: { id: 9 } },
|
|
72
|
+
{ id: 3, conversation_id: 1, body: 'Theirs', created_at: '2026-07-31T10:01:00Z', sender: { id: 42 } },
|
|
73
|
+
],
|
|
74
|
+
next_cursor: null,
|
|
75
|
+
});
|
|
76
|
+
renderThread(api, { userId: 9 });
|
|
77
|
+
await screen.findByText('Mine');
|
|
78
|
+
await screen.findByText('Theirs');
|
|
79
|
+
await waitFor(() => expect(api.getReadStatus).toHaveBeenCalledTimes(1));
|
|
80
|
+
expect(api.getReadStatus).toHaveBeenCalledWith(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not mount ReadTicks (or fire a REST call) for a still-pending optimistic own message', async () => {
|
|
84
|
+
api.listMessages.mockResolvedValue({
|
|
85
|
+
results: [{ id: 'local-r9', conversation_id: 1, body: 'Sending...', created_at: '2026-07-31T10:00:00Z', sender: { id: 9 }, status: 'pending', client_request_id: 'r9' }],
|
|
86
|
+
next_cursor: null,
|
|
87
|
+
});
|
|
88
|
+
renderThread(api, { userId: 9 });
|
|
89
|
+
await screen.findByText('Sending...');
|
|
90
|
+
expect(api.getReadStatus).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('mounts Thread and ReadTicks independently', async () => {
|
|
94
|
+
renderThread(api);
|
|
95
|
+
await screen.findByLabelText('MessagingThread.TIMELINE');
|
|
96
|
+
cleanup();
|
|
97
|
+
render(<MessagingProvider api={api} active={false}><ReadTicks messageId={2} conversation={{ id: 1, kind: 'group' }} /></MessagingProvider>);
|
|
98
|
+
await waitFor(() => expect(screen.getByLabelText('MessagingReadTicks.DELIVERED:2')).toBeTruthy());
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const client = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() }));
|
|
4
|
+
vi.mock('../src/auth/apiClient', () => ({ default: client }));
|
|
5
|
+
|
|
6
|
+
import * as api from '../src/messaging/api';
|
|
7
|
+
|
|
8
|
+
const response = { data: { ok: true } };
|
|
9
|
+
beforeEach(() => { vi.clearAllMocks(); Object.values(client).forEach((method) => method.mockResolvedValue(response)); });
|
|
10
|
+
|
|
11
|
+
describe('messaging API contract', () => {
|
|
12
|
+
it('uses the published conversations and message routes', async () => {
|
|
13
|
+
await api.listConversations({ scope_kind: 'container', cursor: 'opaque' });
|
|
14
|
+
await api.createDirectConversation({ target_user_id: 8 });
|
|
15
|
+
await api.createGroupConversation({ title: 'Group' }); await api.createBroadcastConversation({}); await api.createManagedConversation({}); await api.createObjectThreadConversation({});
|
|
16
|
+
await api.getConversationConfig(1); await api.patchConversationConfig(1, {}); await api.archiveConversation(1, true); await api.patchConversationPreferences(1, {});
|
|
17
|
+
await api.listMessages(1, { cursor: 'opaque' }); await api.createMessage(1, { body: 'x' }, { idempotencyKey: 'key' }); await api.uploadAttachments(1, new FormData()); await api.createPoll(1, {});
|
|
18
|
+
expect(client.get).toHaveBeenCalledWith('/api/messaging/conversations/', { params: { scope_kind: 'container', cursor: 'opaque' } });
|
|
19
|
+
expect(client.post).toHaveBeenCalledWith('/api/messaging/conversations/direct/', { target_user_id: 8 });
|
|
20
|
+
expect(client.post).toHaveBeenCalledWith('/api/messaging/conversations/1/messages/', { body: 'x' }, { headers: { 'Idempotency-Key': 'key' } });
|
|
21
|
+
expect(client.get).toHaveBeenCalledWith('/api/messaging/conversations/1/messages/', { params: { cursor: 'opaque' } });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('uses the published detail, receipt, poll and attachment routes', async () => {
|
|
25
|
+
await api.getMessage(2); await api.patchMessage(2, {}); await api.deleteMessage(2); await api.addReaction(2, '👍'); await api.removeReaction(2, '👍');
|
|
26
|
+
await api.listThread(2, { cursor: 'opaque' }); await api.markThreadRead(2); await api.markConversationRead(3); await api.getReadStatus(2); await api.votePoll(4, [5]); await api.closePoll(4); await api.getAttachment(6); await api.getAttachmentThumbnail(6); await api.getUnreadCount();
|
|
27
|
+
expect(client.delete).toHaveBeenCalledWith('/api/messaging/messages/2/reactions/%F0%9F%91%8D/');
|
|
28
|
+
expect(client.get).toHaveBeenCalledWith('/api/messaging/messages/2/thread/', { params: { cursor: 'opaque' } });
|
|
29
|
+
expect(client.post).toHaveBeenCalledWith('/api/messaging/conversations/3/read/', {});
|
|
30
|
+
expect(client.get).toHaveBeenCalledWith('/api/messaging/unread-count/');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
let frameHandler;
|
|
7
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.count != null ? `${key}:${options.count}` : key, i18n: { language: 'en' } }) }));
|
|
8
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: (_envelope, handler) => { frameHandler = handler; return () => {}; }, onReconnect: () => () => {} }) }));
|
|
9
|
+
|
|
10
|
+
import { Composer } from '../src/messaging/Composer';
|
|
11
|
+
import { ConversationList } from '../src/messaging/ConversationList';
|
|
12
|
+
import { MessageBubble } from '../src/messaging/MessageBubble';
|
|
13
|
+
import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
14
|
+
import { ReadTicks } from '../src/messaging/ReadTicks';
|
|
15
|
+
import { Thread } from '../src/messaging/Thread';
|
|
16
|
+
|
|
17
|
+
function makeApi(overrides = {}) {
|
|
18
|
+
return {
|
|
19
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }),
|
|
20
|
+
listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
21
|
+
listThread: vi.fn().mockResolvedValue({ results: [] }),
|
|
22
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
23
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2 }),
|
|
24
|
+
markConversationRead: vi.fn().mockResolvedValue({}), markThreadRead: vi.fn().mockResolvedValue({}),
|
|
25
|
+
createPoll: vi.fn().mockResolvedValue({}),
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); frameHandler = undefined; });
|
|
31
|
+
|
|
32
|
+
describe('MSG-3b chunk 6', () => {
|
|
33
|
+
it('renders localized relative list timestamps for now, minutes, hours, and days', async () => {
|
|
34
|
+
vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-07-31T12:00:00Z').getTime());
|
|
35
|
+
const api = makeApi({ listConversations: vi.fn().mockResolvedValue({ results: [
|
|
36
|
+
{ id: 1, title: 'Now', last_message_at: '2026-07-31T11:59:50Z' },
|
|
37
|
+
{ id: 2, title: 'Minutes', last_message_at: '2026-07-31T11:55:00Z' },
|
|
38
|
+
{ id: 3, title: 'Hours', last_message_at: '2026-07-31T09:00:00Z' },
|
|
39
|
+
{ id: 4, title: 'Days', last_message_at: '2026-07-29T12:00:00Z' },
|
|
40
|
+
] }) });
|
|
41
|
+
render(<MessagingProvider api={api}><ConversationList /></MessagingProvider>);
|
|
42
|
+
await screen.findByText('Now');
|
|
43
|
+
expect(screen.getByText('now')).toBeTruthy();
|
|
44
|
+
expect(screen.getByText(/5m/)).toBeTruthy();
|
|
45
|
+
expect(screen.getByText(/3h/)).toBeTruthy();
|
|
46
|
+
expect(screen.getByText(/2d/)).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('auto-scrolls only for active-conversation messages when the reader was near the bottom', async () => {
|
|
50
|
+
const api = makeApi({ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, kind: 'group' }] }), listMessages: vi.fn().mockResolvedValue({ results: [{ id: 1, conversation_id: 1, body: 'First' }] }) });
|
|
51
|
+
render(<MessagingProvider api={api} activeConversationId={1}><Thread conversationId={1} /></MessagingProvider>);
|
|
52
|
+
const timeline = await screen.findByLabelText('MessagingThread.TIMELINE');
|
|
53
|
+
Object.defineProperties(timeline, { scrollHeight: { configurable: true, value: 1000 }, clientHeight: { configurable: true, value: 300 }, scrollTop: { configurable: true, writable: true, value: 690 } });
|
|
54
|
+
timeline.scrollTo = vi.fn();
|
|
55
|
+
fireEvent.scroll(timeline);
|
|
56
|
+
frameHandler({ type: 'message', conversation_id: 1, message: { id: 2, body: 'Near bottom' } });
|
|
57
|
+
await waitFor(() => expect(timeline.scrollTo).toHaveBeenCalledTimes(1));
|
|
58
|
+
timeline.scrollTo.mockClear(); timeline.scrollTop = 100;
|
|
59
|
+
fireEvent.scroll(timeline);
|
|
60
|
+
frameHandler({ type: 'message', conversation_id: 1, message: { id: 3, body: 'Reading history' } });
|
|
61
|
+
await screen.findByText('Reading history');
|
|
62
|
+
expect(timeline.scrollTo).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders announcement deep links only with a host handler and supplies its exact target', () => {
|
|
66
|
+
const onAnnouncementLink = vi.fn();
|
|
67
|
+
const message = { id: 3, kind: 'announcement', title: 'Notice', body: 'Read this', link_target: '/events/7' };
|
|
68
|
+
const bubble = (props) => <MessagingProvider api={makeApi()} active={false}><MessageBubble {...props} /></MessagingProvider>;
|
|
69
|
+
const view = render(bubble({ message, onAnnouncementLink }));
|
|
70
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingAnnouncement.OPEN_LINK' }));
|
|
71
|
+
expect(onAnnouncementLink).toHaveBeenCalledWith('/events/7');
|
|
72
|
+
view.rerender(bubble({ message }));
|
|
73
|
+
expect(screen.queryByRole('button', { name: 'MessagingAnnouncement.OPEN_LINK' })).toBeNull();
|
|
74
|
+
view.rerender(bubble({ message: { ...message, link_target: null }, onAnnouncementLink }));
|
|
75
|
+
expect(screen.queryByRole('button', { name: 'MessagingAnnouncement.OPEN_LINK' })).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('shows senders for group messages and suppresses them for direct messages', () => {
|
|
79
|
+
const message = { id: 4, body: 'Hi', sender: { display_name: 'Ava' } };
|
|
80
|
+
const bubble = (conversation) => <MessagingProvider api={makeApi()} active={false}><MessageBubble message={message} conversation={conversation} /></MessagingProvider>;
|
|
81
|
+
const view = render(bubble({ kind: 'group' }));
|
|
82
|
+
expect(screen.getByText('Ava')).toBeTruthy();
|
|
83
|
+
view.rerender(bubble({ kind: 'direct' }));
|
|
84
|
+
expect(screen.queryByText('Ava')).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('opens receipt recipients by click for non-direct conversations and never exposes direct details', async () => {
|
|
88
|
+
const api = makeApi({ getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 2, recipient_detail: [{ display_name: 'Ava' }] }) });
|
|
89
|
+
const view = render(<MessagingProvider api={api} active={false}><ReadTicks messageId={7} conversation={{ kind: 'group' }} /></MessagingProvider>);
|
|
90
|
+
const detail = await screen.findByRole('button', { name: 'MessagingReadTicks.DELIVERED:2' });
|
|
91
|
+
fireEvent.click(detail);
|
|
92
|
+
expect(await screen.findByText('Ava')).toBeTruthy();
|
|
93
|
+
view.unmount();
|
|
94
|
+
render(<MessagingProvider api={api} active={false}><ReadTicks messageId={7} conversation={{ kind: 'direct' }} /></MessagingProvider>);
|
|
95
|
+
await screen.findByLabelText('MessagingReadTicks.DELIVERED:2');
|
|
96
|
+
expect(screen.queryByRole('button', { name: 'MessagingReadTicks.DELIVERED:2' })).toBeNull();
|
|
97
|
+
expect(screen.queryByText('Ava')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('caps poll composer options at ten without changing the existing two-option start', () => {
|
|
101
|
+
render(<MessagingProvider api={makeApi()} active={false}><Composer conversationId={1} /></MessagingProvider>);
|
|
102
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingPoll.CREATE' }));
|
|
103
|
+
const addOption = screen.getByRole('button', { name: 'MessagingPoll.ADD_OPTION' });
|
|
104
|
+
expect(screen.getAllByLabelText(/MessagingPoll.OPTION/)).toHaveLength(2);
|
|
105
|
+
for (let count = 0; count < 8; count += 1) fireEvent.click(addOption);
|
|
106
|
+
expect(screen.getAllByLabelText(/MessagingPoll.OPTION/)).toHaveLength(10);
|
|
107
|
+
expect(addOption.disabled).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.message ? `${key}:${options.message}` : (options?.sender ? `${key}:${options.sender}` : key) }) }));
|
|
7
|
+
|
|
8
|
+
const realtimeSubscribers = new Map();
|
|
9
|
+
vi.mock('../src/notifications/realtime', () => ({
|
|
10
|
+
useRealtime: () => ({
|
|
11
|
+
subscribe: (envelope, handler) => { realtimeSubscribers.set(envelope, handler); return () => realtimeSubscribers.delete(envelope); },
|
|
12
|
+
onReconnect: () => () => {},
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
function dispatchMessagingFrame(frame) { realtimeSubscribers.get('messaging')?.(frame); }
|
|
16
|
+
|
|
17
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
18
|
+
import { AttachmentList } from '../src/messaging/AttachmentList';
|
|
19
|
+
import { Composer } from '../src/messaging/Composer';
|
|
20
|
+
import { messagingReducer, MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
21
|
+
|
|
22
|
+
function State() { const { cache } = useMessaging(); return <output data-testid="cache">{JSON.stringify(cache)}</output>; }
|
|
23
|
+
function makeApi() {
|
|
24
|
+
return {
|
|
25
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
26
|
+
createMessage: vi.fn(), uploadAttachments: vi.fn(), getAttachment: vi.fn().mockResolvedValue(new Blob(['file'])), getAttachmentThumbnail: vi.fn().mockResolvedValue(new Blob(['image'], { type: 'image/png' })),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function renderComposer(api, { userId } = {}) {
|
|
30
|
+
const tree = <MessagingProvider api={api} active={false}><Composer conversationId={7} /><State /></MessagingProvider>;
|
|
31
|
+
return render(userId ? <AuthContext.Provider value={{ user: { id: userId, username: 'me' } }}>{tree}</AuthContext.Provider> : tree);
|
|
32
|
+
}
|
|
33
|
+
function typeAndSend(text = 'hello') { fireEvent.change(screen.getByLabelText('MessagingComposer.MESSAGE'), { target: { value: text } }); fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.SEND' })); }
|
|
34
|
+
const state = () => JSON.parse(screen.getByTestId('cache').textContent);
|
|
35
|
+
|
|
36
|
+
afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
|
|
37
|
+
|
|
38
|
+
describe('Composer optimistic send and attachments', () => {
|
|
39
|
+
it('reconciles REST then websocket confirmation into exactly one row', () => {
|
|
40
|
+
const pending = { id: 'local-r1', conversation_id: 7, client_request_id: 'r1', status: 'pending' };
|
|
41
|
+
const rest = { id: 88, conversation_id: 7, body: 'hello', client_request_id: 'r1' };
|
|
42
|
+
let next = messagingReducer({ conversations: {}, messages: {}, cursors: { conversations: null, messages: {}, threads: {} } }, { type: 'messageOptimistic', message: pending });
|
|
43
|
+
next = messagingReducer(next, { type: 'messageReconciled', message: rest });
|
|
44
|
+
next = messagingReducer(next, { type: 'frame', frame: { type: 'message', conversation_id: 7, message: rest } });
|
|
45
|
+
expect(Object.values(next.messages)).toHaveLength(1);
|
|
46
|
+
expect(next.messages[88].client_request_id).toBe('r1');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('reconciles websocket then REST confirmation into exactly one row', () => {
|
|
50
|
+
const pending = { id: 'local-r2', conversation_id: 7, client_request_id: 'r2', status: 'pending' };
|
|
51
|
+
const websocket = { id: 89, conversation_id: 7, body: 'hello', client_request_id: 'r2' };
|
|
52
|
+
let next = messagingReducer({ conversations: {}, messages: {}, cursors: { conversations: null, messages: {}, threads: {} } }, { type: 'messageOptimistic', message: pending });
|
|
53
|
+
next = messagingReducer(next, { type: 'frame', frame: { type: 'message', conversation_id: 7, message: websocket } });
|
|
54
|
+
next = messagingReducer(next, { type: 'messageReconciled', message: websocket });
|
|
55
|
+
expect(Object.values(next.messages)).toHaveLength(1);
|
|
56
|
+
expect(next.messages[89].client_request_id).toBe('r2');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('retries a failed send with the same idempotency key and optimistic row', async () => {
|
|
60
|
+
const api = makeApi(); api.createMessage.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ id: 90, conversation_id: 7, body: 'hello' });
|
|
61
|
+
renderComposer(api); typeAndSend(); await screen.findByRole('alert'); const first = api.createMessage.mock.calls[0][1].client_request_id;
|
|
62
|
+
fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.RETRY' }));
|
|
63
|
+
await waitFor(() => expect(api.createMessage).toHaveBeenCalledTimes(2));
|
|
64
|
+
expect(api.createMessage.mock.calls[1][1].client_request_id).toBe(first);
|
|
65
|
+
await waitFor(() => expect(Object.keys(state().messages)).toEqual(['90']));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('shows the server validation message for a rejected attachment upload', async () => {
|
|
69
|
+
// dcm's ConversationAttachmentView translates a rejected upload into a
|
|
70
|
+
// field-keyed {"files": [...]} body (the MSG-2 chunk-4 fix), not {detail}.
|
|
71
|
+
const api = makeApi(); api.uploadAttachments.mockRejectedValue({ response: { data: { files: ['File type application/octet-stream is not allowed.'] } } });
|
|
72
|
+
renderComposer(api); const input = document.querySelector('input[type=file]'); const file = new File(['x'], 'bad.exe', { type: 'application/octet-stream' });
|
|
73
|
+
fireEvent.change(input, { target: { files: [file] } }); fireEvent.click(screen.getByRole('button', { name: 'MessagingComposer.SEND' }));
|
|
74
|
+
await screen.findByText('MessagingComposer.UPLOAD_ERROR:File type application/octet-stream is not allowed.');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('shows the server validation message when it arrives as {detail} (the text-message error shape)', async () => {
|
|
78
|
+
const api = makeApi(); api.createMessage.mockRejectedValue({ response: { data: { detail: 'Posting is not permitted.' } } });
|
|
79
|
+
renderComposer(api); typeAndSend('hello');
|
|
80
|
+
await screen.findByText('MessagingComposer.SEND_ERROR:Posting is not permitted.');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('populates the optimistic row with the current user as sender, not "unknown sender"', async () => {
|
|
84
|
+
let resolveCreate; const api = makeApi(); api.createMessage.mockReturnValue(new Promise((resolve) => { resolveCreate = resolve; }));
|
|
85
|
+
renderComposer(api, { userId: 9 }); typeAndSend('hello');
|
|
86
|
+
await waitFor(() => expect(Object.keys(state().messages)).toHaveLength(1));
|
|
87
|
+
const [optimistic] = Object.values(state().messages);
|
|
88
|
+
expect(optimistic.status).toBe('pending');
|
|
89
|
+
expect(optimistic.sender).toEqual({ id: 9, display_name: 'me' });
|
|
90
|
+
resolveCreate({ id: 91, conversation_id: 7, body: 'hello' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('end-to-end: a real in-flight send reconciled by a WS frame arriving before the REST response settles produces exactly one row', async () => {
|
|
94
|
+
let resolveCreate; const api = makeApi(); api.createMessage.mockReturnValue(new Promise((resolve) => { resolveCreate = resolve; }));
|
|
95
|
+
renderComposer(api); typeAndSend('hello');
|
|
96
|
+
await waitFor(() => expect(Object.keys(state().messages)).toHaveLength(1));
|
|
97
|
+
const requestId = api.createMessage.mock.calls[0][1].client_request_id;
|
|
98
|
+
// The WS frame for this message's creation arrives while the REST POST is still in flight.
|
|
99
|
+
act(() => dispatchMessagingFrame({ envelope: 'messaging', type: 'message', conversation_id: 7, message: { id: 91, conversation_id: 7, body: 'hello', client_request_id: requestId } }));
|
|
100
|
+
expect(Object.keys(state().messages)).toEqual(['91']);
|
|
101
|
+
// The REST response for the same send resolves afterward and must not create a second row.
|
|
102
|
+
await act(async () => resolveCreate({ id: 91, conversation_id: 7, body: 'hello', client_request_id: requestId }));
|
|
103
|
+
expect(Object.keys(state().messages)).toEqual(['91']);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('mounts Composer and AttachmentList independently', async () => {
|
|
107
|
+
const api = makeApi(); const { unmount } = renderComposer(api); expect(screen.getByLabelText('MessagingComposer.LABEL')).toBeTruthy(); unmount();
|
|
108
|
+
render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[{ id: 1, filename: 'note.pdf', content_type: 'application/pdf' }]} /></MessagingProvider>);
|
|
109
|
+
expect(screen.getByLabelText('MessagingAttachments.LABEL')).toBeTruthy();
|
|
110
|
+
});
|
|
111
|
+
});
|