@micha.bigler/ui-core-micha 2.16.0 → 2.18.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/WORK_ORDERS.md +3 -1
- package/dev/entries.jsx +5 -1
- package/dist/i18n/messagingTranslations.js +27 -0
- package/dist/index.js +1 -0
- package/dist/messaging/Composer.js +41 -5
- package/dist/messaging/ConversationList.js +10 -3
- package/dist/messaging/DirectMessageLauncher.js +48 -0
- package/dist/messaging/MessageBubble.js +84 -4
- package/dist/messaging/MessagingProvider.js +102 -21
- package/dist/messaging/PollCard.js +22 -5
- package/dist/messaging/ReactionBar.js +1 -1
- package/dist/messaging/ReadTicks.js +7 -4
- package/dist/messaging/Thread.js +45 -8
- package/dist/messaging/api.js +3 -3
- package/dist/messaging/composerImageCompression.js +36 -0
- package/docs/messaging-deviations.md +108 -26
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +27 -0
- package/src/index.js +1 -0
- package/src/messaging/Composer.jsx +54 -8
- package/src/messaging/ConversationList.jsx +10 -4
- package/src/messaging/DirectMessageLauncher.jsx +96 -0
- package/src/messaging/MessageBubble.jsx +74 -7
- package/src/messaging/MessagingProvider.jsx +124 -15
- package/src/messaging/PollCard.jsx +19 -3
- package/src/messaging/ReactionBar.jsx +1 -1
- package/src/messaging/ReadTicks.jsx +5 -3
- package/src/messaging/Thread.jsx +39 -8
- package/src/messaging/api.js +3 -2
- package/src/messaging/composerImageCompression.js +35 -0
- package/tests/ConversationList.test.jsx +1 -1
- package/tests/Thread.test.jsx +1 -1
- package/tests/messagingChunk6.test.jsx +109 -0
- package/tests/messagingComposerChunk5.test.jsx +110 -0
- package/tests/messagingContractConformance.test.js +69 -0
- package/tests/messagingDirectMessageLauncher.test.jsx +78 -0
- package/tests/messagingExports.test.js +2 -1
- package/tests/messagingInteractions.test.jsx +3 -1
- package/tests/messagingLastMessagePreview.test.jsx +93 -0
- package/tests/messagingMessageActions.test.jsx +93 -0
- package/tests/messagingPollRendering.test.jsx +115 -0
- package/tests/messagingReactionMerge.test.jsx +79 -0
- package/tests/messagingThreadingQuoting.test.jsx +86 -0
- package/tests/messagingUnreadLifecycle.test.jsx +63 -0
- package/work-orders/MSG-3b.md +1002 -0
- package/work-orders/MSG-3c.md +378 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, 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?.message ? `${key}:${options.message}` : 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 { PollCard } from '../src/messaging/PollCard';
|
|
19
|
+
import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
20
|
+
|
|
21
|
+
function baseApi(overrides = {}) {
|
|
22
|
+
return {
|
|
23
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
24
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
25
|
+
votePoll: vi.fn(), closePoll: vi.fn(),
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A genuinely server-shaped serialize_poll payload (django_core_micha
|
|
31
|
+
// serializers.py:52-64): id/question/allow_multiple/closed_at/created_by_id,
|
|
32
|
+
// options carry {id,text,order,vote_count,voters} — never selected/voted
|
|
33
|
+
// flags, never created_by as an object. voted_option_ids is added ONLY on
|
|
34
|
+
// the vote/create/close REST responses (views.py _poll_response), never here.
|
|
35
|
+
function serverPoll(overrides = {}) {
|
|
36
|
+
return {
|
|
37
|
+
id: 'poll-1', question: 'Where should we meet?', allow_multiple: false, closed_at: null, created_by_id: 'user-9',
|
|
38
|
+
options: [
|
|
39
|
+
{ id: 'opt-a', text: 'Library', order: 0, vote_count: 2, voters: ['user-1', 'user-2'] },
|
|
40
|
+
{ id: 'opt-b', text: 'Cafe', order: 1, vote_count: 1, voters: ['user-3'] },
|
|
41
|
+
],
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function State() { const { cache } = useMessaging(); return <output data-testid="cache">{JSON.stringify(cache)}</output>; }
|
|
46
|
+
// PollCard is a "dumb" prop-driven component (matches Thread.jsx's usage,
|
|
47
|
+
// which passes down cache.messages[id] directly) — it does not subscribe to
|
|
48
|
+
// the cache itself. A live-frame test must read the message back out of the
|
|
49
|
+
// cache, not hold a static prop, or it can never observe a frame update.
|
|
50
|
+
function ConnectedPollCard({ messageId }) {
|
|
51
|
+
const { cache } = useMessaging();
|
|
52
|
+
return <PollCard message={cache.messages[messageId]} />;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
|
|
56
|
+
|
|
57
|
+
describe('poll rendering against the real server contract', () => {
|
|
58
|
+
it('renders question, options, vote counts and per-option voters from a server-shaped payload', () => {
|
|
59
|
+
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
|
|
60
|
+
expect(screen.getByText('Where should we meet?')).toBeTruthy();
|
|
61
|
+
expect(screen.getByText(/Library \(2\)/)).toBeTruthy();
|
|
62
|
+
expect(screen.getByText(/Cafe \(1\)/)).toBeTruthy();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('derives selection from voted_option_ids (REST-only) and marks the viewer\'s own vote', () => {
|
|
66
|
+
const poll = serverPoll({ voted_option_ids: ['opt-a'] });
|
|
67
|
+
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
|
|
68
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
69
|
+
expect(checkboxes[0].checked).toBe(true);
|
|
70
|
+
expect(checkboxes[1].checked).toBe(false);
|
|
71
|
+
expect(screen.getByText(/Library.*MessagingPoll\.YOU_VOTED/)).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('does not clear or invert the viewer\'s own vote when a poll_updated frame carries no voted_option_ids', async () => {
|
|
75
|
+
const api = baseApi();
|
|
76
|
+
const votedPoll = serverPoll({ voted_option_ids: ['opt-a'] });
|
|
77
|
+
render(<MessagingProvider api={api} active={false}><ConnectedPollCard messageId="msg-1" /><State /></MessagingProvider>);
|
|
78
|
+
// Seed the cache the way a real poll message arrives: a `message` frame
|
|
79
|
+
// (kind: 'poll') carrying the just-voted poll, exactly as this viewer's
|
|
80
|
+
// own castPollVote reconciliation would leave it — not a static prop the
|
|
81
|
+
// component can never observe a later frame update against.
|
|
82
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', kind: 'poll', poll: votedPoll } });
|
|
83
|
+
await waitFor(() => expect(screen.getAllByRole('checkbox')[0].checked).toBe(true));
|
|
84
|
+
// The real poll_updated frame shape: payload.poll is a bare serialize_poll
|
|
85
|
+
// result (verified against services.py's _poll_updated_payload) — never
|
|
86
|
+
// carries voted_option_ids. A vote_count bump from another viewer must
|
|
87
|
+
// not touch this viewer's own selection.
|
|
88
|
+
dispatchMessagingFrame({
|
|
89
|
+
envelope: 'messaging', type: 'poll_updated', event_id: 'evt-1', conversation_id: 'conv-1', message_id: 'msg-1', poll_id: 'poll-1',
|
|
90
|
+
poll: { ...serverPoll(), options: [{ id: 'opt-a', text: 'Library', order: 0, vote_count: 3, voters: ['user-1', 'user-2', 'user-4'] }, { id: 'opt-b', text: 'Cafe', order: 1, vote_count: 1, voters: ['user-3'] }] },
|
|
91
|
+
});
|
|
92
|
+
await waitFor(() => expect(screen.getByText(/Library \(3\)/)).toBeTruthy());
|
|
93
|
+
// The frame carries no voted_option_ids at all — the viewer's REST-known
|
|
94
|
+
// vote must be carried forward, not wiped, by this update.
|
|
95
|
+
expect(screen.getAllByRole('checkbox')[0].checked).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('grants close only to the poll creator (created_by_id) or a host override, never a guess from an object shape', () => {
|
|
99
|
+
const poll = serverPoll({ created_by_id: 'user-9' });
|
|
100
|
+
const { rerender } = render(<AuthContext.Provider value={{ user: { id: 'user-9' } }}><MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider></AuthContext.Provider>);
|
|
101
|
+
expect(screen.getByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeTruthy();
|
|
102
|
+
rerender(<AuthContext.Provider value={{ user: { id: 'user-2' } }}><MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider></AuthContext.Provider>);
|
|
103
|
+
expect(screen.queryByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeNull();
|
|
104
|
+
rerender(<AuthContext.Provider value={{ user: { id: 'user-2' } }}><MessagingProvider api={baseApi()} active={false}><PollCard canClose message={{ id: 'msg-1', poll }} /></MessagingProvider></AuthContext.Provider>);
|
|
105
|
+
expect(screen.getByRole('button', { name: 'MessagingPoll.CLOSE' })).toBeTruthy();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('disables voting once the poll is closed', () => {
|
|
109
|
+
const poll = serverPoll({ closed_at: '2026-07-31T10:00:00Z' });
|
|
110
|
+
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll }} /></MessagingProvider>);
|
|
111
|
+
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox.disabled).toBe(true));
|
|
112
|
+
expect(screen.getByRole('button', { name: 'MessagingPoll.VOTE' }).disabled).toBe(true);
|
|
113
|
+
expect(screen.getByText('MessagingPoll.CLOSED')).toBeTruthy();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, 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?.emoji ? `${key}:${options.emoji}:${options.count}` : 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 { ReactionBar } from '../src/messaging/ReactionBar';
|
|
18
|
+
import { MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
19
|
+
|
|
20
|
+
function baseApi(overrides = {}) {
|
|
21
|
+
return {
|
|
22
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }),
|
|
23
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
24
|
+
addReaction: vi.fn(), removeReaction: vi.fn(),
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// ReactionBar is prop-driven (matches every other messaging leaf component) —
|
|
29
|
+
// a live-frame test must read the message back out of the cache.
|
|
30
|
+
function ConnectedReactionBar({ messageId }) {
|
|
31
|
+
const { cache } = useMessaging();
|
|
32
|
+
return <ReactionBar message={cache.messages[messageId]} />;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterEach(() => { cleanup(); realtimeSubscribers.clear(); });
|
|
36
|
+
|
|
37
|
+
describe('reaction frame/REST merge against the real aggregate-only contract', () => {
|
|
38
|
+
it('an unrelated reaction frame updates its own emoji without touching the viewer\'s known reacted state on other emoji', async () => {
|
|
39
|
+
const api = baseApi();
|
|
40
|
+
render(<MessagingProvider api={api} active={false}><ConnectedReactionBar messageId="msg-1" /></MessagingProvider>);
|
|
41
|
+
// Seed: the viewer has already reacted with 👍 (locally known — e.g. from
|
|
42
|
+
// an earlier REST confirmation), the server aggregate never carries that.
|
|
43
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [{ emoji: '👍', count: 1, reacted: true }] } });
|
|
44
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:1' })).toBeTruthy());
|
|
45
|
+
// A different viewer reacts with 🎉 — dcm's real reaction frame shape
|
|
46
|
+
// (serialize_reactions): {emoji, count} only, never reacted, for the
|
|
47
|
+
// WHOLE aggregate list including 👍.
|
|
48
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'reaction', event_id: 'evt-1', conversation_id: 'conv-1', message_id: 'msg-1', reactions: [{ emoji: '👍', count: 1 }, { emoji: '🎉', count: 1 }] });
|
|
49
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:🎉:1' })).toBeTruthy());
|
|
50
|
+
const thumbsUp = screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:1' });
|
|
51
|
+
expect(thumbsUp.className.includes('MuiChip-colorPrimary')).toBe(true); // still marked as the viewer's own reaction
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('the viewer\'s own optimistic reacted flag survives its own REST confirmation, which returns an aggregate-only shape', async () => {
|
|
55
|
+
const api = baseApi({ addReaction: vi.fn().mockResolvedValue({ reactions: [{ emoji: '👍', count: 1 }] }) });
|
|
56
|
+
render(<MessagingProvider api={api} active={false}><ConnectedReactionBar messageId="msg-1" /></MessagingProvider>);
|
|
57
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [] } });
|
|
58
|
+
await waitFor(() => screen.getByLabelText('MessagingReactions.ADD'));
|
|
59
|
+
fireEvent.click(screen.getByLabelText('MessagingReactions.ADD'));
|
|
60
|
+
fireEvent.click(screen.getByText('👍'));
|
|
61
|
+
await waitFor(() => expect(api.addReaction).toHaveBeenCalledWith('msg-1', '👍'));
|
|
62
|
+
await waitFor(() => {
|
|
63
|
+
const chip = screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:1' });
|
|
64
|
+
expect(chip.className.includes('MuiChip-colorPrimary')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('a duplicate event_id changes nothing', async () => {
|
|
69
|
+
const api = baseApi();
|
|
70
|
+
render(<MessagingProvider api={api} active={false}><ConnectedReactionBar messageId="msg-1" /></MessagingProvider>);
|
|
71
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'message', event_id: 'seed', conversation_id: 'conv-1', message: { id: 'msg-1', conversation_id: 'conv-1', body: 'Hi', reactions: [{ emoji: '👍', count: 1 }] } });
|
|
72
|
+
await waitFor(() => screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:1' }));
|
|
73
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'reaction', event_id: 'dup-1', conversation_id: 'conv-1', message_id: 'msg-1', reactions: [{ emoji: '👍', count: 2 }] });
|
|
74
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:2' })).toBeTruthy());
|
|
75
|
+
dispatchMessagingFrame({ envelope: 'messaging', type: 'reaction', event_id: 'dup-1', conversation_id: 'conv-1', message_id: 'msg-1', reactions: [{ emoji: '👍', count: 99 }] });
|
|
76
|
+
expect(screen.queryByRole('button', { name: 'MessagingReactions.TOGGLE:👍:99' })).toBeNull();
|
|
77
|
+
expect(screen.getByRole('button', { name: 'MessagingReactions.TOGGLE:👍:2' })).toBeTruthy();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, 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?.sender ? `${key}:${options.sender}` : 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 { applyFrame, MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
12
|
+
import { Thread } from '../src/messaging/Thread';
|
|
13
|
+
|
|
14
|
+
function makeApi(messages = []) {
|
|
15
|
+
return {
|
|
16
|
+
listConversations: vi.fn().mockResolvedValue({ results: [{ id: 1, kind: 'group' }] }),
|
|
17
|
+
listMessages: vi.fn().mockResolvedValue({ results: messages }),
|
|
18
|
+
listThread: vi.fn().mockResolvedValue({ results: [] }),
|
|
19
|
+
getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
20
|
+
markConversationRead: vi.fn().mockResolvedValue({}),
|
|
21
|
+
markThreadRead: vi.fn().mockResolvedValue({}),
|
|
22
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false, delivered_count: 0 }),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function renderBubble(ui) {
|
|
26
|
+
return render(<MessagingProvider api={makeApi()} active={false}>{ui}</MessagingProvider>);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(cleanup);
|
|
30
|
+
|
|
31
|
+
describe('messaging threading and quoting', () => {
|
|
32
|
+
it('groups REST-loaded server-shaped reply_to_id messages under their root', async () => {
|
|
33
|
+
const api = makeApi([
|
|
34
|
+
{ id: 10, conversation_id: 1, body: 'Root', sender: { display_name: 'Ava' }, created_at: '2026-07-31T10:00:00Z' },
|
|
35
|
+
{ id: 11, conversation_id: 1, body: 'Server reply', reply_to_id: 10, sender: { display_name: 'Ben' }, created_at: '2026-07-31T10:01:00Z' },
|
|
36
|
+
]);
|
|
37
|
+
render(<MessagingProvider api={api} activeConversationId={1}><Thread conversationId={1} /></MessagingProvider>);
|
|
38
|
+
await screen.findByText('Root');
|
|
39
|
+
expect(screen.queryByText('Server reply')).toBeNull();
|
|
40
|
+
fireEvent.click(screen.getByRole('button', { name: /MessagingThread\.SHOW_REPLIES/ }));
|
|
41
|
+
expect(await screen.findByText('Server reply')).toBeTruthy();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('groups realtime server-shaped reply_to_id messages under their root', () => {
|
|
45
|
+
const state = { conversations: {}, messages: { 10: { id: 10, conversation_id: 1, body: 'Root' } }, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } };
|
|
46
|
+
const next = applyFrame(state, { type: 'message', conversation_id: 1, message: { id: 11, body: 'Realtime reply', reply_to_id: 10 } }, 1);
|
|
47
|
+
expect(next.messages[11].reply_to_id).toBe(10);
|
|
48
|
+
expect(Object.values(next.messages).filter((message) => !((message.reply_to_id ?? message.reply_to))).map((message) => message.id)).toEqual([10]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('renders a quoted sender and snippet, blanking deleted quoted content', () => {
|
|
52
|
+
const { rerender } = renderBubble(<MessageBubble message={{ id: 2, body: 'Reply' }} replyTo={{ id: 1, body: 'Quoted words', sender: { display_name: 'Ava' } }} />);
|
|
53
|
+
expect(screen.getByText('MessagingThread.REPLY_TO:Ava')).toBeTruthy();
|
|
54
|
+
expect(screen.getByText('Quoted words')).toBeTruthy();
|
|
55
|
+
rerender(<MessagingProvider api={makeApi()} active={false}><MessageBubble message={{ id: 2, body: 'Reply' }} replyTo={{ id: 1, body: 'Secret', deleted_at: '2026-07-31T10:00:00Z', sender: { display_name: 'Ava' } }} /></MessagingProvider>);
|
|
56
|
+
expect(screen.getByText('MessagingThread.DELETED')).toBeTruthy();
|
|
57
|
+
expect(screen.queryByText('Secret')).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('labels an attachment-only quoted source distinctly from a deleted one', () => {
|
|
61
|
+
renderBubble(<MessageBubble message={{ id: 2, body: 'Reply' }} replyTo={{ id: 1, attachments: [{ id: 9, filename: 'photo.png' }], sender: { display_name: 'Ava' } }} />);
|
|
62
|
+
expect(screen.getByText('MessagingThread.QUOTE_ATTACHMENT')).toBeTruthy();
|
|
63
|
+
expect(screen.queryByText('MessagingThread.DELETED')).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("clears the Thread's own reply-target state when the message being replied to is deleted", async () => {
|
|
67
|
+
const api = makeApi([{ id: 20, conversation_id: 1, body: 'Reply target', sender: { id: 1, display_name: 'Ava' }, created_at: '2026-07-31T10:00:00Z' }]);
|
|
68
|
+
api.deleteMessage = vi.fn().mockResolvedValue({});
|
|
69
|
+
render(<AuthContext.Provider value={{ user: { id: 1 } }}><MessagingProvider api={api} activeConversationId={1}><Thread conversationId={1} /></MessagingProvider></AuthContext.Provider>);
|
|
70
|
+
await screen.findByText('Reply target');
|
|
71
|
+
fireEvent.click(screen.getByRole('button', { name: /MessagingThread\.REPLY\b/ }));
|
|
72
|
+
expect(screen.getByText(/MessagingThread\.REPLYING_TO/)).toBeTruthy();
|
|
73
|
+
fireEvent.click(screen.getByRole('button', { name: /MessagingActions\.MENU/ }));
|
|
74
|
+
fireEvent.click(screen.getByRole('menuitem', { name: /MessagingActions\.DELETE/ }));
|
|
75
|
+
fireEvent.click(screen.getByRole('button', { name: /MessagingActions\.DELETE/ }));
|
|
76
|
+
await waitFor(() => expect(api.deleteMessage).toHaveBeenCalledWith(20));
|
|
77
|
+
await waitFor(() => expect(screen.queryByText(/MessagingThread\.REPLYING_TO/)).toBeNull());
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('jumps from a quote to the original message', () => {
|
|
81
|
+
const onJumpToMessage = vi.fn();
|
|
82
|
+
renderBubble(<MessageBubble message={{ id: 2, body: 'Reply' }} replyTo={{ id: 1, body: 'Quoted words', sender: { display_name: 'Ava' } }} onJumpToMessage={onJumpToMessage} />);
|
|
83
|
+
fireEvent.click(screen.getByText('Quoted words'));
|
|
84
|
+
expect(onJumpToMessage).toHaveBeenCalledWith(1);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, 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 { messagingReducer, MessagingProvider, useMessaging } from '../src/messaging/MessagingProvider';
|
|
11
|
+
import { Thread } from '../src/messaging/Thread';
|
|
12
|
+
|
|
13
|
+
function cache() { return { conversations: {}, messages: {}, threads: {}, polls: {}, reactions: {}, receipts: {}, unread: { unread_count: 0, by_conversation: {} }, cursors: { conversations: null, messages: {}, threads: {} } }; }
|
|
14
|
+
function State() { const { cache: current } = useMessaging(); return <output data-testid="cache">{JSON.stringify(current)}</output>; }
|
|
15
|
+
function makeApi(overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
listConversations: vi.fn().mockResolvedValue({ results: [] }), listMessages: vi.fn().mockResolvedValue({ results: [] }), listThread: vi.fn().mockResolvedValue({ results: [] }), getUnreadCount: vi.fn().mockResolvedValue({ unread_count: 0, by_conversation: {} }),
|
|
18
|
+
markConversationRead: vi.fn().mockResolvedValue({}), markThreadRead: vi.fn().mockResolvedValue({}),
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(cleanup);
|
|
24
|
+
|
|
25
|
+
describe('messaging unread lifecycle', () => {
|
|
26
|
+
it('marks the selected conversation read', async () => {
|
|
27
|
+
const api = makeApi({ listConversations: vi.fn().mockResolvedValue({ results: [{ id: 12, title: 'Support' }] }) });
|
|
28
|
+
render(<MessagingProvider api={api}><ConversationList /></MessagingProvider>);
|
|
29
|
+
fireEvent.click(await screen.findByRole('button', { name: /Support/ }));
|
|
30
|
+
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledWith(12, undefined));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('marks a conversation read when its thread opens', async () => {
|
|
34
|
+
const api = makeApi();
|
|
35
|
+
render(<MessagingProvider api={api} active={false}><Thread conversationId={12} /></MessagingProvider>);
|
|
36
|
+
await waitFor(() => expect(api.markConversationRead).toHaveBeenCalledWith(12, undefined));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('decrements the global unread count by the selected conversation prior count', () => {
|
|
40
|
+
const initial = { ...cache(), unread: { unread_count: 5, by_conversation: { 12: 2, 7: 3 } } };
|
|
41
|
+
const next = messagingReducer(initial, { type: 'conversationRead', conversationId: 12 });
|
|
42
|
+
expect(next.unread).toEqual({ unread_count: 3, by_conversation: { 12: 0, 7: 3 } });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('marks a reply thread read when it expands', async () => {
|
|
46
|
+
const api = makeApi({ listMessages: vi.fn().mockResolvedValue({ results: [{ id: 40, conversation_id: 12, body: 'Root', reply_count: 1 }] }) });
|
|
47
|
+
render(<MessagingProvider api={api} activeConversationId={12}><Thread conversationId={12} /></MessagingProvider>);
|
|
48
|
+
fireEvent.click(await screen.findByRole('button', { name: 'MessagingThread.SHOW_REPLIES' }));
|
|
49
|
+
await waitFor(() => expect(api.markThreadRead).toHaveBeenCalledWith(40, undefined));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('increments unread only for a new message in a non-active conversation', () => {
|
|
53
|
+
const initial = { ...cache(), unread: { unread_count: 2, by_conversation: { 7: 2 } } };
|
|
54
|
+
const incoming = { type: 'frame', activeConversationId: 7, frame: { type: 'message', conversation_id: 12, message: { id: 50, body: 'hello' } } };
|
|
55
|
+
const nonActive = messagingReducer(initial, incoming);
|
|
56
|
+
expect(nonActive.unread).toEqual({ unread_count: 3, by_conversation: { 7: 2, 12: 1 } });
|
|
57
|
+
const active = messagingReducer(nonActive, { ...incoming, frame: { type: 'message', conversation_id: 7, message: { id: 51, body: 'active' } } });
|
|
58
|
+
expect(active.unread).toEqual(nonActive.unread);
|
|
59
|
+
const optimistic = messagingReducer(initial, { type: 'messageOptimistic', message: { id: 'local-r1', conversation_id: 12, client_request_id: 'r1', status: 'pending' } });
|
|
60
|
+
const reconciled = messagingReducer(optimistic, { ...incoming, frame: { type: 'message', conversation_id: 12, message: { id: 52, client_request_id: 'r1', body: 'mine' } } });
|
|
61
|
+
expect(reconciled.unread).toEqual(initial.unread);
|
|
62
|
+
});
|
|
63
|
+
});
|